Use TSX in an SDL application
This guide helps developers integrate React/TypeScript components into C3's SDL ecosystem, enabling modern React development while leveraging SDL's infrastructure for routing, testing and bundling in the cloud.
Key Capabilities
- Create TSX pages and components in
ui/c3/src/customInstances/(container components) - Build reusable React components in
ui/c3/src/components/ - Manage state with Redux Toolkit slices in
ui/c3/src/slices/(auto-discovered) - Access C3 backend APIs via SDL hooks (
useC3Action,useDispatch,useSelectedState, etc.) - Import existing JSON metadata components via
@c3/ui/components/ComponentId
Prerequisites
- C3 package with standard structure (see C3 Package Structure docs)
- Redux Toolkit v2.10.1+ available in the C3 environment
- Basic understanding of React hooks and TypeScript
Project Structure Overview
C3 packages follow a specific directory structure. Relevant folders for TSX development:
myPackage/
├── myPackage.c3pkg.json # Package manifest
├── ui/
│ └── c3/ # UI Namespace name
│ ├── meta/ # JSON component metadata (legacy/when needed)
│ └── src/
│ ├── customInstances/ # TSX pages (ModuleName.PageName.tsx)
│ ├── components/ # Reusable React components
│ ├── slices/ # Redux Toolkit slices (auto-discovered)
│ └── styling/ # SCSS files (recommended for performance)
├── metadata/
│ └── UiSdlRoute/ # Route definitions (CSV files)
└── src/
└── ui/
└── styling/ # SCSS files (legacy - requires federated modules)Import Path Aliases
@c3/ui/*- Auto-generated NPM package from C3 metadata (types, hooks, federated modules)@c3/app/ui/src/*- Mergedui/c3/src/folder across all C3 packages (local bundling)@c3/ui/components/*- Existing JSON/TSX components by component ID
Important - Bundling Performance
- Recommended: Place files in
ui/c3/src/for local bundling (better performance) - Avoid when possible: Files in
src/become federated modules via@c3/ui/*(slower) - This is particularly important for SCSS files - use
ui/c3/src/styling/instead ofsrc/ui/styling/
NPM Dependencies
When integrating existing React/NPM packages, add dependencies to the C3 runtime configuration.
Adding NPM Dependencies
// metadata/ImplLanguage.Runtime/js-webpack_c3.json
{
"name": "js-webpack_c3",
"npmPackages": {
"your-new-npm-package": "1.0.0"
}
}Naming Conventions
Component IDs: ModuleName.ComponentName
ModuleName: Logical namespace (e.g.,MyApp,WindTurbine,Dashboard)ComponentName: Specific component/page name- Multiple module names can exist in one package. Overriding other packages' components is allowed.
- Module name does NOT need to match package name
Examples
- Package
uiDemocould have:UiDemo.Home,WindTurbine.Dashboard,Analytics.Report - Custom instances:
ui/c3/src/customInstances/MyApp.HomePage.tsx - Routes reference:
targetModuleName=MyApp, targetPageName=HomePage
New page
Create a route and corresponding TSX custom instance.
Step 1: Define the route
Create metadata/UiSdlRoute/UiRoute.csv:
targetModuleName,targetPageName,name,urlPath
WindTurbine,Home,/wind-turbine/home,/wind-turbine/homeRoute fields
targetModuleName- Module namespace (e.g.,WindTurbine,MyApp)targetPageName- Page component name (e.g.,Home,Dashboard)name- Route identifier (should matchurlPath)urlPath- URL path (e.g.,/wind-turbine/home,/item/{{id}}for dynamic params)
Dynamic routes: Use {{paramName}} for URL parameters (e.g., /turbine/{{id}})
Step 2: Create the TSX custom instance
// ui/c3/src/customInstances/WindTurbine.Home.tsx
import React from 'react';
import { useC3Action } from '@c3/ui/UiSdlUseC3Action';
import TurbineList from '@c3/app/ui/src/components/TurbineList';
export default function WindTurbineHome() {
const { data, status } = useC3Action({
typeName: 'WindTurbine',
actionName: 'fetch',
argsArray: [{ limit: 10 }],
});
if (status === 'loading') return <div>Loading...</div>;
if (status === 'error') return <div>Error loading turbines</div>;
return (
<div>
<h1>Wind Turbines</h1>
<TurbineList turbines={data?.objs || []} />
</div>
);
}Component requirements
- Must use
export default - File name must match route:
{targetModuleName}.{targetPageName}.tsx - Located in
ui/c3/src/customInstances/
Reusable react components
Create shared components in ui/c3/src/components/ (supports nested folders).
// ui/c3/src/components/TurbineList.tsx
import React from 'react';
interface TurbineListProps {
turbines: any[];
onSelect?: (turbine: any) => void;
}
export default function TurbineList({ turbines, onSelect }: TurbineListProps) {
return (
<ul>
{turbines.map((turbine) => (
<li key={turbine.id} onClick={() => onSelect?.(turbine)}>
{turbine.name} - {turbine.status}
</li>
))}
</ul>
);
}Usage in custom instances
// ui/c3/src/customInstances/MyApp.MyPage.tsx
import TurbineList from '@c3/app/ui/src/components/TurbineList';
// Use with props
<TurbineList turbines={data} onSelect={(t) => console.log(t)} />;Import path: Always use @c3/app/ui/src/components/* alias (points to merged ui/c3/src/ across all packages)
Styling with SCSS
Create SCSS files in ui/c3/src/styling/ for optimal bundling performance.
// ui/c3/src/styling/TurbineList.scss
.turbine-list {
list-style: none;
padding: 0;
&__item {
padding: 10px;
border-bottom: 1px solid #ccc;
cursor: pointer;
&:hover {
background-color: #f5f5f5;
}
}
}Import in components
// ui/c3/src/components/TurbineList.tsx
import '@c3/app/ui/src/styling/TurbineList.scss';
export default function TurbineList({ turbines, onSelect }: TurbineListProps) {
return (
<ul className="turbine-list">
{turbines.map((turbine) => (
<li key={turbine.id} className="turbine-list__item" onClick={() => onSelect?.(turbine)}>
{turbine.name} - {turbine.status}
</li>
))}
</ul>
);
}Performance note
- Recommended:
ui/c3/src/styling/→ Import via@c3/app/ui/src/styling/*(local bundling) - Avoid:
src/ui/styling/→ Import via@c3/ui/*(federated modules, slower)
Import and render JSON components
Legacy JSON metadata components are auto-converted to React components in the @c3/ui NPM pacakge. Import by component ID using @c3/ui/components/*:
import WindTurbineCard from '@c3/ui/components/WindTurbine.Card';
import TurbineModal from '@c3/ui/components/WindTurbine.CreateModal';
export default function MyPage() {
return (
<div>
<WindTurbineCard />
{/* Note: Props should not be passed to imported JSON components */}
<TurbineModal />
</div>
);
}Note: Imported JSON components do not accept props. They are self-contained instances as their props are defined in the JSON.
Redux state management
Redux Toolkit slices (C3 AI Platform 8.9.0+)
Create slices in ui/c3/src/slices/ - they are auto-discovered at runtime (no imports needed).
// ui/c3/src/slices/turbines.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
interface TurbinesState {
selectedId: string | null;
filter: string;
}
export const turbinesSlice = createSlice({
name: 'turbines',
initialState: { selectedId: null, filter: '' } as TurbinesState,
reducers: {
selectTurbine: (state, action: PayloadAction<string>) => {
state.selectedId = action.payload;
},
setFilter: (state, action: PayloadAction<string>) => {
state.filter = action.payload;
},
},
});
export const { selectTurbine, setFilter } = turbinesSlice.actions;
export default turbinesSlice.reducer;Key requirements
- Export slice object (e.g.,
turbinesSlice) for action access - Export default reducer
- Slice name becomes the state key (e.g.,
state.get('turbines'))
Using in components
import { useDispatch } from '@c3/ui/UiSdlUseDispatch';
import { useSelectedState } from '@c3/ui/UiSdlUseData';
import { selectTurbine } from '@c3/app/ui/src/slices/turbines';
const dispatch = useDispatch();
const turbines = useSelectedState((state) => state.get('turbines'));
// Dispatch actions
dispatch(selectTurbine('turbine-123'));State access pattern
- RTK slices:
state.get('sliceName')- returns plain JavaScript object - SDL state:
state.getIn(['metadata', 'components', 'byId', 'ComponentId'])?.toJS()- requires.toJS()
Immutable.js and RTK State
RTK slices are stored as plain JavaScript objects, while SDL state uses Immutable.js.
Accessing RTK slices (plain JavaScript - no .toJS() needed)
// In components
const turbines = useSelectedState((state) => state.get('turbines'));
// turbines is already a plain JavaScript object
// In thunks
export const myThunk = createAsyncThunk('...', async (arg, { getState }) => {
const state = getState();
const turbinesState = state.get('turbines');
// turbinesState is already plain JavaScript
const selectedId = turbinesState.selectedId; // Direct property access
});Accessing SDL state (Immutable.js - requires .toJS())
// SDL state needs .toJS() conversion
const sdlData = useSelectedState((state) => state.getIn(['metadata', 'components', 'byId', 'MyComponent'])?.toJS());Inside RTK slice reducers
export const turbinesSlice = createSlice({
name: 'turbines',
initialState: { items: [], selectedId: null },
reducers: {
selectTurbine: (state, action: PayloadAction<string>) => {
// Direct property access - state is a plain object (Immer draft)
state.selectedId = action.payload;
state.items.push(newItem);
},
},
});Why this works
- RTK slices are stored as plain JavaScript objects in the Immutable.js Map
- You can read them with
state.get('sliceName')- no conversion needed - SDL state remains Immutable.js and requires
.toJS()for conversion - RTK reducers use Immer for immutability (write code as if mutating)
Key takeaway
- RTK slices:
state.get('sliceName')returns plain JavaScript - SDL state:
state.getIn([...])?.toJS()converts from Immutable.js - Inside RTK reducers: plain object with Immer (no Immutable.js)
Async logic & side effects
RTK Thunks (createAsyncThunk)
Handle async operations with Redux Toolkit thunks:
// In ui/c3/src/slices/turbines.ts
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchTurbines = createAsyncThunk('turbines/fetchTurbines', async (limit: number) => {
const response = await fetch(`/api/turbines?limit=${limit}`);
return response.json();
});
export const turbinesSlice = createSlice({
name: 'turbines',
initialState: { items: [], loading: false },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchTurbines.pending, (state) => {
state.loading = true;
})
.addCase(fetchTurbines.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
});
},
});
// Usage: dispatch(fetchTurbines(10))Note: Prefer useC3Action for C3 backend calls (see React Hooks section below).
Legacy SDL Epics (redux-observable)
Effect triggers in JSON metadata continue to work with RTK actions:
{
"effectTriggers": [
{
"trigger": "turbines/selectTurbine",
"actions": [{ "type": "WindTurbine.Panel.UPDATE", "payload": "..." }]
}
]
}Reading state in async logic
// RTK Thunk - access both RTK and legacy state
export const loadTurbineDetails = createAsyncThunk('turbines/loadDetails', async (id: string, { getState }) => {
const state = getState();
const turbinesState = state.get('turbines'); // RTK state - plain JavaScript
const sdlState = state.getIn(['metadata', 'components']); // SDL state - Immutable.js
// ... async logic
});React Hooks
Data fetching - useC3Action
Recommended for C3 backend API calls. Automatically handles loading states and errors.
import { useC3Action } from '@c3/ui/UiSdlUseC3Action';
const { data, status, error } = useC3Action({
typeName: 'WindTurbine',
actionName: 'fetch',
argsArray: [{ limit: 10 }],
});
// status: 'loading' | 'done' | 'error'Parameters
typeName- C3 type name (e.g.,'WindTurbine')actionName- C3 action/method name (e.g.,'fetch','get')argsArray- Positional arguments array- Instance methods:
[instanceOrNull, param1, param2, ...] - Static methods:
[param1, param2, ...]
- Instance methods:
Migration tip: Replace fetch/axios calls to C3 backend with useC3Action.
Dispatch Redux actions - useDispatch
Dispatch both RTK and legacy SDL actions:
import { useDispatch } from '@c3/ui/UiSdlUseDispatch';
import { selectTurbine } from '@c3/app/ui/src/slices/turbines';
const dispatch = useDispatch();
// RTK slice actions
dispatch(selectTurbine('turbine-123'));
// Legacy SDL actions (plain objects)
dispatch({ type: 'WindTurbine.Modal.OPEN', payload: { id: '123' } });Integration tip: Replace useState with Redux slices + useDispatch for shared state.
Config values - useConfig
Access C3 configuration values:
import { useConfig } from '@c3/ui/UiSdlUseConfig';
const myConfig = useConfig('UiSdlConfig', 'app', null, true);
const apiUrl = useConfig('UiSdlConfig', 'apiUrl', 'http://localhost');Integration tip: Replace environment variables (import.meta.env in Vite) with useConfig.
Listen to actions - useEpic
Listen for specific Redux actions and execute side effects in components:
import { useEpic } from '@c3/ui/UiSdlUseEpic';
// Basic usage - listen for a specific action
useEpic(
'FETCH_USER_DATA',
(action, state) => {
console.log('User data fetch initiated:', action.payload);
},
[],
);
// With dependencies - callback updates when userId changes
useEpic(
'UPDATE_USER',
(action, state) => {
if (action.payload.userId === userId) {
setUserData(action.payload.data);
}
},
[userId],
);
// With action filter - only handle specific action variants
useEpic(
'API_RESPONSE',
(action, state) => {
handleApiResponse(action.payload);
},
[],
(action) => action.payload.endpoint === 'users',
);
// Listen to RTK thunk actions
useEpic(
'turbines/fetchTurbines/fulfilled',
(action, state) => {
console.log('Turbines loaded:', action.payload);
},
[],
);Reading Redux state - useSelectedState
Recommended for performance. Only triggers re-renders when state actually changes (not on epic-only actions):
import { useSelectedState } from '@c3/ui/UiSdlUseData';
// RTK slice - no .toJS() needed
const turbines = useSelectedState((state) => state.get('turbines'));
// With custom equality function
const selectedId = useSelectedState(
(state) => state.get('turbines')?.selectedId,
(prev, next) => prev === next,
);
// SDL state - requires .toJS()
const sdlData = useSelectedState((state) => state.getIn(['metadata', 'components', 'byId', 'MyComponent'])?.toJS());State access
- RTK slices:
state.get('sliceName')- returns plain JavaScript - SDL state:
state.getIn(['path', 'to', 'value'])?.toJS()- requires.toJS()conversion
Alternative
Standard useSelector from react-redux works but re-renders more often:
import { useSelector } from 'react-redux';
const turbines = useSelector((state) => state.get('turbines'));Integration tip: Replace useState/useContext with useSelectedState for derived/global state.
Interoperability patterns
RTK actions → SDL Effect Triggers
RTK slice actions automatically trigger SDL effect triggers in JSON metadata:
// Dispatch RTK action
dispatch(selectTurbine('123'));
// JSON component can listen via effectTriggers
// "trigger": "turbines/selectTurbine"SDL actions → RTK listeners
Listen to legacy SDL actions in RTK using extraReducers:
export const turbinesSlice = createSlice({
name: 'turbines',
initialState: {
/* ... */
},
reducers: {
/* ... */
},
extraReducers: (builder) => {
builder.addMatcher(
(action) => action.type === 'WindTurbine.Card.CLICK',
(state, action) => {
state.selectedId = action.payload.id;
},
);
},
});Access both state worlds
// In components
const dispatch = useDispatch();
const rtkState = useSelector((state) => state.get('turbines')); // Plain JavaScript
const sdlState = useSelector((state) => state.getIn(['metadata', 'components', 'byId', 'MyComponent'])?.toJS());Memoized selectors with RTK
Use createSelector for expensive computations:
import { createSelector } from '@reduxjs/toolkit';
// Memoized selector - only recomputes when turbines change
const selectFilteredTurbines = createSelector([(state) => state.get('turbines')], (turbines) =>
turbines?.items.filter((t) => t.status === 'active'),
);
// In component (with useSelectedState for best performance)
const activeTurbines = useSelectedState(selectFilteredTurbines);Performance notes
useSelectedState(fromUiSdlUseData) - Optimized for SDL, works with RTK. Only triggers on reducer actions. UsesshallowEqualby default.useSelector(fromreact-redux) - Standard Redux hook. Triggers on all actions (less optimized).createSelector(from RTK) - Memoizes expensive selector computations. Combine withuseSelectedStatefor best results.- RTK slices are compatible with
useSelectedStatebecause they trigger state updates via reducers.
Integration Quick Reference
Common patterns when integrating existing React/Vite apps into SDL:
| React/Vite Pattern | SDL Equivalent | Notes |
|---|---|---|
| React Router routes | CSV routes in metadata/UiSdlRoute/ | Define routes, create TSX custom instances |
fetch('/api/data') | useC3Action({ typeName, actionName, argsArray }) | For C3 backend APIs |
| Auth headers/tokens | Remove - use document.cookie | SDL environment provides auth automatically |
useState (local) | Keep useState | Component-local state unchanged |
useState (shared) | Redux Toolkit slices | Auto-discovered from ui/c3/src/slices/ |
useContext | useSelectedState + Redux slices | Global state via Redux |
import.meta.env.VITE_* | useConfig('UiSdlConfig', 'key') | Configuration values |
| Route params | useSelector(state => getPageParamFromState(state, 'id')) | From @c3/ui/UiSdlConnected |
| CSS Modules / SCSS | SCSS in ui/c3/src/styling/ | Import via @c3/app/ui/src/styling/* (performant) |
| Static assets | ui/content/ folder | Web-accessible static files |
| NPM dependencies | metadata/ImplLanguage.Runtime/js-webpack_c3.json | Then run Js.upsertRuntime() in C3 console |
Build/Dev
- No separate build commands - C3 VSCode extension handles bundling
- Redux store automatically created and available (no Provider setup needed)
- Hot reload via C3 extension
Performance Best Practices
- Place UI code in
ui/c3/src/*for local bundling - Avoid
src/*for UI files (creates federated modules via@c3/ui/*) - This integration approach leverages SDL bundling while preparing for future phases