← Files NetSuite SuiteCloudARCHIVED FILE
skills/netsuite-sdf-safe-guide/references/12-uif-spa-best-practices.md
79.2 KB · Oct 4, 2026 · 12:30 UTC
# UIF SPA Best Practices
> Author: Oracle NetSuite
> Layout, rendering, and component patterns for NetSuite UIF Single Page Applications
>
> UIF Version: **8.0.x** (NetSuite 2025.2) — `@oracle/netsuite-uif-types: "^8.0.0"`
>
> Official Oracle SPA samples: [oracle-samples/netsuite-suitecloud-samples/spa-suiteapp-samples](https://github.com/oracle-samples/netsuite-suitecloud-samples/tree/main/spa-suiteapp-samples)
## UIF Version Mapping
| `@oracle/netsuite-uif-types` | NetSuite Version |
|-------------------------------|-----------------|
| 5.0.x | 2024.1 |
| 6.0.x | 2024.2 |
| 7.0.x | 2025.1 |
| 8.0.x | 2025.2 |
Always match the UIF types version to your target NetSuite account version.
## 1. Application Shell Pattern (from Oracle airport360 sample)
The official Oracle sample uses a two-column layout with navigation drawer and scroll panel:
```tsx
// Airport.tsx; official Oracle pattern
<StackPanel>
<StackPanel.Item shrink={0}>
<Menu />
</StackPanel.Item>
<StackPanel.Item grow={1}>
<ScrollPanel {...scrollOptions}>
<Router.Routes>
<Router.Route path={Route.DASHBOARD} exact={true}>
<Dashboard flights={flights} gates={gates} />
</Router.Route>
...
</Router.Routes>
</ScrollPanel>
</StackPanel.Item>
</StackPanel>
```
Key patterns from the official sample:
- **Navigation** uses `shrink={0}` to prevent compression.
- **Content area** uses `grow={1}` with `ScrollPanel` wrapping routes.
- **ScrollPanel** configured with explicit scroll options: `scrollAmount`, `hoverScrollAmount`, `orientation`.
- Root container needs bounded height (`rootStyle={{ height: "100vh" }}`) for ScrollPanel to scroll.
## 2. Page Layout Pattern (from Oracle airport360 sample)
Each page uses `StackPanel.Vertical` with `ApplicationHeader` and `ContentPanel`:
```tsx
// FlightList.tsx; official Oracle pattern
<StackPanel.Vertical>
<StackPanel.Item shrink={0}>
<ApplicationHeader icon={SystemIcon.LOCALIZE} title={'Flights'} />
</StackPanel.Item>
<StackPanel.Item grow={1}>
<ContentPanel outerGap={ContentPanel.GapSize.LARGE}>
{content}
</ContentPanel>
</StackPanel.Item>
</StackPanel.Vertical>
```
Note: `StackPanel.Vertical` is shorthand for `<StackPanel orientation={StackPanel.Orientation.VERTICAL}>`.
## 3. Multi-Column Layouts: Prefer GridPanel Over Horizontal StackPanels
**For any multi-column layout (KPI rows, card grids, master-detail splits, file pickers), use `GridPanel` instead of horizontal `StackPanel`.** GridPanel uses CSS Grid with `1fr` columns that properly distribute space — no `grow`/`basis`/`wrap` hacks needed.
```jsx
// BAD: horizontal StackPanel with grow/basis/wrap hacks.
<StackPanel orientation={StackPanel.Orientation.HORIZONTAL} itemGap={StackPanel.GapSize.M} wrap={true}>
<StackPanel.Item grow={1} basis="200px">...</StackPanel.Item>
<StackPanel.Item grow={1} basis="200px">...</StackPanel.Item>
<StackPanel.Item grow={1} basis="200px">...</StackPanel.Item>
</StackPanel>
// GOOD: GridPanel with equal columns.
<GridPanel columns={3} defaultColumnWidth="1fr" gap={GridPanel.GapSize.MEDIUM}>
<GridPanel.Item>...</GridPanel.Item>
<GridPanel.Item>...</GridPanel.Item>
<GridPanel.Item>...</GridPanel.Item>
</GridPanel>
```
Common GridPanel patterns:
| Layout | GridPanel Config |
|--------|-----------------|
| KPI cards (3-across) | `columns={3} defaultColumnWidth="1fr"` |
| Master-detail (1/3 + 2/3) | `columns={3}` with `columnSpan={1}` + `columnSpan={2}` |
| Agenda + detail (3/4 + 1/4) | `columns={4}` with `columnSpan={3}` + `columnSpan={1}` |
| File pickers (4-across) | `columns={4} defaultColumnWidth="1fr"` |
| Summary totals (2-across) | `columns={2} defaultColumnWidth="1fr"` |
**When to still use horizontal StackPanel:** Only for simple inline elements like a label + value pair, or button groups where items have natural widths (not equal-width columns).
### Use `shrink={0}` for Fixed-Width Items
Navigation drawers, action buttons, and sidebars should use `shrink={0}` to prevent compression.
```jsx
<StackPanel.Item shrink={0}>
<NavigationDrawer ... />
</StackPanel.Item>
<StackPanel.Item grow={1}>
{/* main content */}
</StackPanel.Item>
```
## 4. Conditional Rendering: Never Use `&&` Short-Circuit (CRITICAL)
UIF-JS VDom rejects `false` as a child node everywhere, not just in StackPanel, but for any element including native HTML (`<td>`, `<col>`, `<div>`, etc.). The `&&` short-circuit idiom common in React evaluates to `false` when the condition is falsy, which crashes the component with no visible error boundary.
The `VDom.Node` type is `(string | number | VDomElement | Component | Translation | null)` — `boolean` is **not** included.
```tsx
// BAD: Evaluates to `false` when condition is falsy → VDom crash.
{selectedCustomer && <td style={{color: 'green'}}>{price}</td>}
{items.length > 0 && <div className="badges">{items.map(...)}</div>}
// GOOD: `null` is a valid no-op in UIF-JS VDom.
{selectedCustomer ? <td style={{color: 'green'}}>{price}</td> : null}
{items.length > 0 ? <div className="badges">{items.map(...)}</div> : null}
```
**Rule:** Never use `{condition && <element>}` in UIF-JS JSX. Always use `{condition ? <element> : null}` or the always-render pattern (render the element unconditionally; let it be empty when there's no data).
### Conditional Rendering: Use `null`, Not `<Text />`
An empty `<Text />` component creates a phantom flex item that consumes space in the layout. Always return `null` for the falsy branch of conditional rendering.
```jsx
// BAD. Creates invisible element consuming space.
const errorBanner = error
? <Card>...</Card>
: <Text />;
// GOOD. No element rendered.
const errorBanner = error
? <Card>...</Card>
: null;
```
This applies to: error banners, loading indicators, conditional modals, conditional sections.
### 4b. Conditional StackPanel.Item Rendering (CRITICAL)
UIF's `StackPanel` validates its children strictly. **Null children in a StackPanel are rejected as "Invalid StackPanel Item"**. This means:
- `<StackPanel.Item>{null}</StackPanel.Item>` — INVALID (Item with null content)
- `{condition ? <StackPanel.Item>...</StackPanel.Item> : null}` — ALSO INVALID (null in children array)
- A `<StackPanel.Item>` with multiple children — INVALID (must have exactly one child)
**The ONLY safe pattern is the imperative array approach.** Build an array of StackPanel.Items, including only items with valid content:
```jsx
// BAD. Ternary produces null children that UIF rejects.
<StackPanel orientation={StackPanel.Orientation.VERTICAL}>
{errorBanner ? <StackPanel.Item>{errorBanner}</StackPanel.Item> : null}
<StackPanel.Item>{uploadSection}</StackPanel.Item>
{summarySection ? <StackPanel.Item>{summarySection}</StackPanel.Item> : null}
</StackPanel>
// GOOD. Imperative array with zero null entries.
var contentItems = [];
if (errorBanner) contentItems.push(<StackPanel.Item key="error">{errorBanner}</StackPanel.Item>);
contentItems.push(<StackPanel.Item key="upload">{uploadSection}</StackPanel.Item>);
if (summarySection) contentItems.push(<StackPanel.Item key="summary">{summarySection}</StackPanel.Item>);
<StackPanel orientation={StackPanel.Orientation.VERTICAL}>
{contentItems}
</StackPanel>
```
**The two-step pattern for conditional sections:**
1. Define the section variable with `null` fallback (not `<Text />`):
```jsx
var summarySection = hasData ? <Card>...</Card> : null;
```
2. Build an array and push only truthy items:
```jsx
var items = [];
if (summarySection) items.push(<StackPanel.Item key="summary">{summarySection}</StackPanel.Item>);
```
**Why arrays work but ternaries don't:** When JSX processes `{contentItems}` (an array with no nulls), UIF receives only valid StackPanel.Items. When JSX processes inline ternaries, UIF receives `[null, <StackPanel.Item>, null, null]` — the nulls trigger validation errors.
### 4c. Modal Placement in StackPanel Layouts
Modals must be included in the imperative items array, not as siblings of other components inside a `<StackPanel.Item>`. A StackPanel.Item must have **exactly one child**; placing a modal alongside ContentPanel gives it two children.
```jsx
// BAD: StackPanel.Item has 2 children (ContentPanel + modal).
<StackPanel.Item grow={1}>
<ContentPanel>...</ContentPanel>
{modalContent}
</StackPanel.Item>
// GOOD: Modal is a separate entry in the items array.
var contentItems = [];
contentItems.push(<StackPanel.Item key="upload">{uploadSection}</StackPanel.Item>);
if (modalContent) contentItems.push(<StackPanel.Item key="modal">{modalContent}</StackPanel.Item>);
<StackPanel.Item grow={1}>
<ContentPanel outerGap={ContentPanel.GapSize.LARGE}>
<StackPanel orientation={StackPanel.Orientation.VERTICAL}>
{contentItems}
</StackPanel>
</ContentPanel>
</StackPanel.Item>
```
### 4d. Modal `owner` Prop is MANDATORY
The `owner` property is the **only required property** in `Window.Options` (the parent class of Modal). Every Modal instance must receive an `owner` prop referencing the parent component, or UIF throws runtime errors:
- `Error: Property 'owner' must be any of: instance of Da, instance of Hr or Element`
- `Error: Cannot detach Component that is currently being updated`
The `owner` accepts a `useRef`, a `PackageCore.Component` instance, or a DOM `Element`.
**Pattern:** Create one ref per component file, attach it to the root element, and pass it to every Modal:
```jsx
function MyComponent(props) {
var myRef = useRef(null);
var [showModal, setShowModal] = useState(false);
// Build content imperatively (see Section 4b)
var items = [];
items.push(<StackPanel.Item key="main">{mainContent}</StackPanel.Item>);
// Modal with mandatory owner
if (showModal) {
items.push(
<StackPanel.Item key="modal">
<Modal
owner={myRef}
title="My Modal"
withTitleBar={true}
closeButton={true}
rootStyle={{ width: '500px', maxWidth: '90vw' }}
onClose={function() { setShowModal(false); }}
content={<Text>Modal content here</Text>}
/>
</StackPanel.Item>
);
}
return (
<StackPanel.Vertical ref={myRef}>
{items}
</StackPanel.Vertical>
);
}
```
**Convention across the codebase:** Each component uses its own ref name: `settingsRef`, `calendarRef`, `plannerRef`, `meetingsRef`, and `dashboardRef`.
**Additional Modal notes:**
- **Show/hide with conditional rendering**, not an `opened` prop; `opened` is a runtime-only instance property, NOT a constructor option
- **For modals wider than `Modal.Size.LARGE`**, omit the `size` prop entirely and use only `rootStyle={{ width: '80vw', maxWidth: '1100px' }}`; the `size` prop's internal CSS overrides `rootStyle` width
- **`Modal.DeprecatedNoOwner`** exists as an escape hatch but is deprecated and should NOT be used; it will be removed in a future UIF release
## 5. Loading State Pattern (from Oracle airport360 sample)
The official sample uses `Skeleton` components for loading states instead of `Loader`:
```tsx
// Dashboard.tsx; official Oracle pattern
const content = loading ? (
<Skeleton.Table rows={10} columns={8} />
) : (
actualContent
);
```
Available skeleton types:
- `<Skeleton width="100%" height={170} />` — generic block
- `<Skeleton.Table rows={10} columns={3} />` — table placeholder
## 6. GridPanel for Dashboard Layouts (from Oracle airport360 sample)
For complex dashboard layouts with varying column/row spans, use `GridPanel` instead of nested StackPanels:
```tsx
// Dashboard.tsx; official Oracle pattern
<GridPanel
columns={6}
defaultColumnWidth={'1fr'}
gap={GridPanel.GapSize.LARGE}
outerGap={GridPanel.GapSize.LARGE}
>
<GridPanel.Item columnSpan={2} rowIndex={0} columnIndex={0}>
<PortletTime />
</GridPanel.Item>
<GridPanel.Item columnSpan={4} rowSpan={3} rowIndex={0} columnIndex={2}>
<PortletFlights flights={data} />
</GridPanel.Item>
<GridPanel.Item columnSpan={2} rowIndex={1} columnIndex={0}>
<Portlet title={'Closed gates'}>...</Portlet>
</GridPanel.Item>
</GridPanel>
```
## 7. DataGrid Patterns
### Always Set `maxViewportHeight`
Without `maxViewportHeight`, DataGrids expand infinitely and push content off-screen.
```jsx
// BAD: Grid grows without bound.
<DataGrid dataSource={data} columns={columns} />
// GOOD: Grid scrolls internally after 400px.
<DataGrid dataSource={data} columns={columns} maxViewportHeight={400} />
```
Recommended values:
- Primary data grid (full-width): `400` - `600`
- Secondary/sidebar grid: `300` - `400`
- Modal grid: `250` - `350`
### Key DataGrid Options Reference
Beyond the commonly used props, DataGrid accepts many constructor options that control behavior:
| Option | Type | Purpose |
|--------|------|---------|
| `stripedRows` | boolean | Alternating row stripes for readability |
| `editingMode` | `DataGrid.EditingMode.CELL` or `.ROW` | Cell-level vs row-level editing |
| `multiColumnSort` | boolean | Sort by multiple columns (off by default) |
| `allowUnsort` | boolean | Allow clicking sorted column back to unsorted |
| `maxViewportWidth` | number (px) | Max width before horizontal scroll |
| `preload` | `DataGrid.Preload.ALL`, `.VISIBLE`, `.NONE` | Virtualization preload strategy |
| `defaultColumnOptions` | Partial<GridColumn.Options> | Defaults applied to all columns |
| `beforeEditCell` | callback | Intercept and conditionally prevent cell editing |
| `keepSelection` | boolean | Preserve selection across data rebind |
**Constructor-only options** (cannot change after render): `stretchStrategy`, `autoSize`, `editingMode`, `preload`, `defaultColumnOptions`, `lockedLevels`, `beforeEditCell`, and `bindingController`.
### Row Height for Multi-Line TEMPLATED Cells
When using TEMPLATED columns with stacked content (for example, name + address, badges + objective), the default row height clips content. Use `dataRowHeight` to increase it:
```jsx
// BAD: rowHeight is silently ignored.
<DataGrid dataSource={data} columns={columns} rowHeight={96} />
// BAD: customizeRow row properties are READ-ONLY at runtime.
<DataGrid dataSource={data} columns={columns}
customizeRow={function(args) {
args.row.heightType = DataGrid.Row.Height.AUTO; // TypeError: readonly!
args.row.maxHeight = 112; // TypeError: readonly!
}}
/>
// GOOD: dataRowHeight is the correct writable prop.
<DataGrid dataSource={data} columns={columns} dataRowHeight={96} />
```
Recommended values:
- Single-line text rows: `32` - `40`
- Two-line content (name + subtitle): `64` - `80`
- Multi-line with badges/dropdowns: `96` - `112`
**Note:** Despite type definitions showing `heightType`, `minHeight`, and `maxHeight` as properties on `GridRow`, they are getter-only at runtime. The `customizeRow` callback cannot set them. Use `dataRowHeight` for fixed row height instead.
### Column Alignment and Responsive Visibility
DataGrid columns support cell alignment and responsive visibility:
```jsx
{
name: 'amount',
label: 'Amount',
binding: 'amount',
type: DataGrid.ColumnType.TEXT_BOX,
horizontalAlignment: GridColumn.HorizontalAlignment.RIGHT, // right-align numbers
verticalAlignment: GridColumn.VerticalAlignment.CENTER,
visibility: GridColumn.VisibilityBreakpoint.MEDIUM, // hide on small screens
stretchFactor: 1,
minWidth: 80,
maxWidth: 200,
}
```
Available alignments:
- **Horizontal**: `LEFT`, `CENTER`, `RIGHT`, `STRETCH`
- **Vertical**: `TOP`, `CENTER`, `BOTTOM`, `STRETCH`
Visibility breakpoints (smallest to largest): `XX_SMALL`, `X_SMALL`, `SMALL`, `MEDIUM`, `LARGE`, `X_LARGE`
### CHECK_BOX Columns Require Grid-Level `editable`
`DataGrid.ColumnType.CHECK_BOX` columns have two requirements that are easy to miss:
1. **The DataGrid itself must have `editable={true}`** — setting `editable: true` on the column definition alone is NOT sufficient. Without the grid-level prop, the column space renders but the checkbox widget is invisible.
2. **`CELL_UPDATE` may not fire for CHECK_BOX toggles** — the checkboxes toggle visually, but `DataGrid.Event.CELL_UPDATE` may never dispatch, making it impossible to track selection state from the handler.
```jsx
// BAD: Checkbox column space appears but no checkbox widget.
<DataGrid dataSource={data}
columns={[
{ type: DataGrid.ColumnType.CHECK_BOX, name: 'sel', binding: 'selected', editable: true }
]}
/>
// BETTER: Checkbox renders, but CELL_UPDATE may not fire.
<DataGrid dataSource={data} editable={true}
columns={[
{ type: DataGrid.ColumnType.CHECK_BOX, name: 'sel', binding: 'selected',
editable: true, inputMode: DataGrid.InputMode.EDIT_ONLY }
]}
on={{ [DataGrid.Event.CELL_UPDATE]: handler }} // May not fire.
/>
// BEST: TEMPLATED toggle button, fully reliable.
<DataGrid dataSource={data}
columns={[
{ type: DataGrid.ColumnType.TEMPLATED, name: 'sel', label: '', binding: 'name', width: 40,
content: function(args) {
var name = args.cell.value;
var checked = !!selectionRef.current[name];
return <Button label={checked ? '\u2611' : '\u2610'}
size={Button.Size.SMALL}
hierarchy={Button.Hierarchy.SECONDARY}
action={function() {
selectionRef.current[name] = !selectionRef.current[name];
var count = 0;
for (var k in selectionRef.current) { if (selectionRef.current[k]) count++; }
setSelectionCount(count); // Triggers re-render.
}}
/>;
}
}
]}
/>
```
### SelectionColumn for Row Selection
For reliable row selection, prefer `SelectionColumn` over `CHECK_BOX` columns:
```jsx
<DataGrid dataSource={data}
columns={[
{ type: DataGrid.ColumnType.SELECTION, name: 'sel', width: 40 },
{ type: DataGrid.ColumnType.TEXT_BOX, name: 'name', binding: 'name', label: 'Name' },
]}
on={{
[DataGrid.Event.ROW_SELECTION_CHANGED]: function(args) {
// args contains selection state
console.log('Selection changed');
}
}}
/>
```
`SelectionColumn` works with the DataGrid's built-in selection system. Use `DataGrid.SingleSelection` (alias for `GridSingleSelection`) or `DataGrid.MultiSelection` (alias for `GridMultiSelection`) from `@uif-js/component` — more reliable than the CHECK_BOX + CELL_UPDATE workaround.
### DataGrid Events Reference
| Event | Fires When | Usage |
|-------|-----------|-------|
| `DataGrid.Event.CELL_UPDATE` | Cell value changes | `on={{ [DataGrid.Event.CELL_UPDATE]: handler }}` |
| `DataGrid.Event.ROW_UPDATE` | Row added/removed/moved | Row lifecycle tracking |
| `DataGrid.Event.ROW_SELECTION_CHANGED` | Row selection changes | Selection tracking |
| `DataGrid.Event.CURSOR_UPDATED` | Cursor position changes | Active row/cell tracking |
| `DataGrid.Event.SORT` | Sort direction changes | Custom sort handling with `onSort` |
| `DataGrid.Event.DATA_BOUND` | Data binding complete | Post-bind initialization |
### Useful Imperative Methods
When you have a DataGrid ref, these methods are available:
| Method | Purpose |
|--------|---------|
| `grid.autoSize()` | Auto-size rows and columns to fit content |
| `grid.autoSizeWidth()` | Auto-size all column widths |
| `grid.stretchColumns()` | Force column stretch to fill grid width |
| `grid.reload()` | Reload data rows from data source |
| `grid.rowForDataItem(item)` | Get the GridDataRow for a data item |
| `grid.scrollTo({ row })` | Scroll to a specific row/cell/column |
| `grid.pinRow(row, DataGrid.RowSection.HEADER)` | Pin a row to header/footer |
| `grid.expandAll()` | Expand all rows in hierarchical grid |
### ListView for Full-Featured Data Tables (from Oracle airport360 sample)
For advanced features (search, layout switching, customized rows), use `ListView` wrapping a DataGrid:
```tsx
// GateList.tsx; official Oracle pattern
const listView = ListView.ofStaticData({
dataProvider: () => data,
layout: {
[ListView.Layout.TABLE]: {
columns: this.columns,
gridOptions: {
customizeRow: this.customizeRow,
},
},
},
availableLayouts: {
[ListView.Layout.TABLE]: true,
},
searchBoxVisible: false,
});
```
### Column Definitions with `stretchFactor` and `minWidth` (from Oracle airport360 sample)
```tsx
// GateList.tsx; official Oracle pattern
{
name: 'airline',
label: 'Airline',
binding: 'airline',
type: DataGrid.ColumnType.TEXT_BOX,
stretchFactor: 2,
minWidth: 100,
}
```
### Column Bindings Must Match Data Source Field Names
Column `binding` values must exactly match the field names returned by the server data service. Stale bindings cause blank columns with no error.
```jsx
// Verify bindings match your SuiteQL query aliases.
const columns = [
{ binding: 'sc_name', ... }, // Must match query alias.
{ binding: 'city', ... }, // Not a custrecord_ field if aliased.
{ binding: 'custrecord_ss_...', ...} // Use actual field ID if not aliased.
];
```
## 8. Card Patterns
### KPI Cards
```jsx
<GridPanel columns={3} defaultColumnWidth="1fr" gap={GridPanel.GapSize.MEDIUM}>
<GridPanel.Item>
<Card>
<StackPanel alignment={StackPanel.Alignment.CENTER} outerGap={StackPanel.GapSize.M}>
<StackPanel.Item>
<Heading type={Heading.Type.MEDIUM_HEADING}>{value}</Heading>
</StackPanel.Item>
<StackPanel.Item>
<Text type={Text.Type.WEAK}>{label}</Text>
</StackPanel.Item>
</StackPanel>
</Card>
</GridPanel.Item>
{/* repeat for each KPI */}
</GridPanel>
```
### Portlet Cards (from Oracle airport360 sample)
For dashboard-style cards with titles and structured content, use `Portlet`:
```tsx
<Portlet title={'Closed gates'}>
<StackPanel
alignment={StackPanel.Alignment.CENTER}
justification={StackPanel.Justification.SPACE_BETWEEN}
>
<StackPanel.Item>
<Reminder count={count} description={'Gates are currently closed'} color={Reminder.Color.DANGER} />
</StackPanel.Item>
<StackPanel.Item>
<Button label={'To gate list'} action={() => navigator.push('/gates')} />
</StackPanel.Item>
</StackPanel>
</Portlet>
```
## 9. Navigation Pattern (from Oracle airport360 sample)
```tsx
// Menu.tsx; official Oracle pattern (functional component with useContext)
function Menu() {
const location = useContext(ContextType.ROUTER_LOCATION);
return (
<NavigationDrawer selectedValue={getCurrentNavigationItem(location)}>
<NavigationDrawer.Item
value={NavigationItem.DASHBOARD}
label={'Dashboard'}
route={Route.DASHBOARD}
icon={SystemIcon.HOME}
/>
...
</NavigationDrawer>
);
}
```
Note: With `Router.Hash`, NavigationDrawer items use `route` prop instead of manual `onSelectedValueChanged` handlers.
### ContextType Reference — Available Service Contexts
`useContext(contextTypeString)` gives access to framework services. All 31 constants are on the `ContextType` namespace:
| ContextType Constant | Returns | Common Use |
|----------------------|---------|-----------|
| `ContextType.ROUTER` | `Router` | Active route, programmatic routing |
| `ContextType.ROUTER_NAVIGATION` | `Navigator` | `push()`, `replace()`, `back()` for SPA navigation |
| `ContextType.ROUTER_LOCATION` | `RouterLocation` | Current URL, `matches()` for active-link highlighting |
| `ContextType.ROUTER_ROUTE` | `Route` | Active route definition |
| `ContextType.I18N` | `I18n` | Locale-aware formatting, translation bundles |
| `ContextType.PREFERENCES` | `Preferences` | Per-user preference storage (`get`, `set`) |
| `ContextType.FOCUS_MANAGER` | `FocusManager` | Programmatic focus control |
| `ContextType.STORE` | `Store` | Access the shared Store instance from child components |
| `ContextType.DISPATCH` | `Dispatch` | Access the Store dispatch function directly |
| `ContextType.SCROLL_OBSERVER` | `ScrollObserver` | Subscribe to scroll events |
| `ContextType.DEVICE_METADATA` | `DeviceMetadataService` | Device/viewport capabilities |
| `ContextType.SHORTCUTS` | `Accelerator` | Keyboard shortcut registration |
| `ContextType.MESSAGING` | `PageMessageDispatcher` | Low-level message dispatching |
| `ContextType.PERFORMANCE` | `SimplePerformance` | Performance measurement |
| `ContextType.DISPATCHER` | `PageMessageDispatcher` | Global event dispatcher |
Full list: 31 values. Grep `ContextType` in `core.d.ts` for complete reference.
**Pattern:**
```jsx
// Access I18n for locale-aware formatting.
var i18n = useContext(ContextType.I18N);
var formatted = FormatService.forI18n(i18n).format(myDate, Format.DATE);
// Access user preferences.
var prefs = useContext(ContextType.PREFERENCES);
prefs.set('myapp.pageSize', 50);
var pageSize = prefs.get('myapp.pageSize') || 25;
// Access Store from deep child (alternative to prop-drilling).
var store = useContext(ContextType.STORE);
```
## 10. Root Container Height
The root StackPanel must have a bounded height for ScrollPanel to function.
```jsx
<StackPanel orientation={StackPanel.Orientation.HORIZONTAL} rootStyle={{ height: "100vh" }}>
<StackPanel.Item shrink={0}>{/* nav */}</StackPanel.Item>
<StackPanel.Item grow={1}>
<ScrollPanel orientation={ScrollPanel.Orientation.VERTICAL}>
{/* page content */}
</ScrollPanel>
</StackPanel.Item>
</StackPanel>
```
## 11. Agenda Component
The Agenda component needs a bounded container height to render properly. It **must** have a `ContentPanel` as an ancestor to provide height context; wrapping in an outer `StackPanel` without explicit height causes the month grid to render at 0 height.
```jsx
// CORRECT. ContentPanel as root provides height context.
return (
<ContentPanel outerGap={ContentPanel.GapSize.L}>
<StackPanel.Vertical>
{calendarItems}
</StackPanel.Vertical>
</ContentPanel>
);
// WRONG. Outer StackPanel has no defined height, Agenda grid collapses.
return (
<StackPanel orientation={StackPanel.Orientation.VERTICAL}>
<StackPanel.Item grow={1}>
<Agenda ... /> {/* renders 0-height grid */}
</StackPanel.Item>
</StackPanel>
);
```
```jsx
<Agenda
viewDate={viewDate}
events={events}
rootStyle={{ height: "600px" }}
monthViewConfig={{
startingDay: 1,
eventClickAction: handleEventClick,
dayClickAction: handleDayClick,
daysConfig: function(date) { return {}; }
}}
weekViewConfig={{
eventClickAction: handleEventClick
}}
dayViewConfig={{
eventClickAction: handleEventClick
}}
// NOTE: there is NO agendaViewConfig; see "Agenda View API Gap" below.
/>
```
### Agenda View API Gap (CRITICAL)
The Agenda component supports 4 view types (Month, Week, Day, Agenda) but **only exposes config props for 3 of them**:
| View | Config Prop | `eventClickAction` Support |
|------|-------------|---------------------------|
| Month | `monthViewConfig` | Yes |
| Week | `weekViewConfig` | Yes |
| Day | `dayViewConfig` | Yes |
| **Agenda (list)** | **None — `agendaViewConfig` does NOT exist** | **No** |
The internal `AgendaView` class (in `component.d.ts`) *does* support `eventClickAction` directly, but the parent `Agenda` component provides no way to pass configuration to it. Setting `agendaViewConfig` is **silently ignored**.
**Consequence:** Clicking an event in the Agenda (list) view always opens the built-in **"Edit Event" dialog**; there is no way to override this through props alone.
### UIF RoutedMessage Event System
UIF does **not** use standard DOM events for component interactions. It has its own **RoutedMessage** pipeline:
1. `PageMessageDispatcher` hooks into the DOM at root level and converts native browser events into `RoutedMessage` objects.
2. Clicks become `PointerMessage` objects (extends `RoutedMessage`) with code `MessageCode.CLICK`.
3. Messages route through the component tree via `processMessage()` and `RoutedMessage.Filter` chains.
4. **`document.addEventListener('click', handler, true)` CANNOT intercept UIF component clicks** — UIF's internal routing processes clicks independently of the DOM event system.
5. `e.stopImmediatePropagation()` on native DOM events has **NO effect** on UIF's RoutedMessage handling.
#### UIF Message Interception API
Every `Component` supports message filtering:
- **Constructor option**: `messageFilter` in Options (constructor-only, not dynamically changeable)
- **Instance method**: `component.addMessageFilter(filter)` → returns `{remove: () => void}`
- **Static utility**: `PageMessageDispatcher.addElementMessageFilter(element, filter)`
Filter forms:
```jsx
// Function form: receives ALL message types, must call next() to pass through.
function(next, message, result) { /* ... */ next(); }
// Record form: only fires for matching type, no next needed.
{ [FilterCode.CLICK]: function(message, result) { /* ... */ } }
```
`RoutedMessage.Result` controls propagation:
- `result.handled = true` — marks message as consumed.
- `result.executeDefault = false` — prevents default behavior.
- `result.stopPropagation = true` — stops message from bubbling.
`FilterCode` enum (from `@uif-js/core`): `CLICK`, `CAPTURE_CLICK`, `POINTER_DOWN`, `POINTER_UP`, `KEY_DOWN`, etc.
### Intercepting Built-In UIF Dialogs (MutationObserver Pattern)
When a UIF component opens a built-in dialog that cannot be suppressed through props (like the Agenda's "Edit Event" dialog), use a `MutationObserver` to intercept it at the DOM level:
```jsx
useEffect(function() {
var observer = new MutationObserver(function(mutations) {
for (var m = 0; m < mutations.length; m++) {
var added = mutations[m].addedNodes;
for (var n = 0; n < added.length; n++) {
var node = added[n];
if (node.nodeType !== 1) continue;
// Detect the dialog by scanning for its title text
var allText = node.querySelectorAll ? node.querySelectorAll('*') : [];
var isTargetDialog = false;
for (var t = 0; t < allText.length; t++) {
if ((allText[t].textContent || '').trim() === 'Edit Event') {
isTargetDialog = true;
break;
}
}
if (!isTargetDialog) continue;
// Hide the dialog immediately (MutationObserver runs as
// a microtask BEFORE browser repaint; no visible flash).
node.style.display = 'none';
// Hide the overlay/backdrop (usually the previous sibling).
if (node.previousElementSibling) {
var prev = node.previousElementSibling;
var prevStyle = window.getComputedStyle(prev);
if (prevStyle.position === 'fixed' || prevStyle.zIndex > 100) {
prev.style.display = 'none';
}
}
// Extract data from the dialog (first input = event name).
var nameInput = node.querySelector('input');
var eventName = nameInput ? nameInput.value.trim() : '';
// Click Cancel to properly dismiss from UIF's internal state.
var allButtons = node.querySelectorAll('button, [role="button"]');
for (var b = 0; b < allButtons.length; b++) {
var btnText = (allButtons[b].textContent || '').trim();
if (btnText === 'Cancel' || btnText === '\u00D7') {
allButtons[b].click();
break;
}
}
// Match extracted name against your data and open custom modal.
if (eventName) {
handleCustomAction(eventName);
}
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
return function() { observer.disconnect(); };
}, []);
```
**Why this works:** MutationObserver callbacks run as microtasks *before* the browser repaints, so the dialog is hidden before the user ever sees it. The Cancel button click properly cleans up UIF's internal dialog state.
**Why DOM event interception does NOT work:**
1. `document.addEventListener('click', handler, true)` — UIF's RoutedMessage system processes clicks independently.
2. `e.stopImmediatePropagation()` — No effect on UIF's internal routing.
3. `agendaViewConfig` prop — Does not exist; silently ignored.
### Agenda Pitfalls
- **`readOnly={true}` blocks `eventClickAction`** — The `readOnly` prop propagates from the parent `Agenda` down to each `AgendaDayView` cell, disabling event click handlers. Note that `dayClickAction` on `AgendaMonthView` is unaffected because it operates at the month-view level, not the day-cell level. Remove `readOnly` if you need `eventClickAction` to fire.
- **`daysConfig` `eventsLimit: 0`** — Throws "must be no value or positive number". To suppress events on specific days (for example, weekends), use `events: []` instead of `eventsLimit: 0`.
- **`daysConfig` with `events: []`** — Setting `events: []` in the `daysConfig` callback completely overrides the Agenda's event data for that day cell, hiding legitimate meetings. To dim weekends without hiding events, use only `background` and `showHeader` — do NOT include `events`.
- **`eventClickAction` callback receives `EventData`** — The callback gets the same `EventData` object you passed in (with `name`, `description`, `start`, `end`, `color`). It has no custom fields, so correlate back to your data by matching `name` + `start` date/time.
- **No `agendaViewConfig`** — The Agenda component has no config prop for its Agenda (list) view. Any `agendaViewConfig` prop is silently ignored. Use the MutationObserver pattern above to intercept the built-in Edit Event dialog.
- **`position: fixed; left: -9999px` permanently breaks Agenda layout** — The grid calculates 0 height when rendered off-screen and does NOT re-layout when moved on-screen. Never use off-screen positioning to pre-render the Agenda. Use `if (loading) return <Loader />` and render the Agenda only when ready.
- **Outer StackPanel without height breaks Agenda grid** — `grow={1}` on a StackPanel.Item containing the Agenda requires the parent StackPanel to have a defined height. Without it, the month grid renders at 0 height (toolbar appears but grid is empty). Use `ContentPanel` as the root ancestor.
## 12. Common Pitfalls
| Pitfall | Symptom | Fix |
|---------|---------|-----|
| No `wrap={true}` on horizontal StackPanel | Items squeeze to single characters | Add `wrap={true}` |
| `grow={1}` without `basis` | Items collapse to 0px | Add `basis="200px"` (or appropriate size) |
| `{cond && <element>}` short-circuit | VDom crash — `false` is not a valid node type | Always use `{cond ? <element> : null}`. UIF VDom.Node does not include `boolean` |
| `<Text />` as conditional fallback | Phantom spacing, layout shifts | Use `null` instead |
| No root `height` on container | ScrollPanel doesn't scroll | Add `rootStyle={{ height: "100vh" }}` |
| No `maxViewportHeight` on DataGrid | Grid expands infinitely, pushes content off-screen | Add `maxViewportHeight={400}` |
| Stale column `binding` values | Blank columns, no error shown | Match bindings to actual data field names |
| Missing `shrink={0}` on fixed elements | Navigation/sidebar compresses | Add `shrink={0}` |
| Hard-coded custrecord field IDs in JSX | Breaks when fields renamed in SDF | Use aliased names from SuiteQL query |
| `Loader` for initial page load | No visual structure during load | Use `Skeleton` / `Skeleton.Table` instead |
| Manual nav state management | Complex state for simple routing | Use `Router.Hash` + `NavigationDrawer` `route` prop |
| Nested StackPanels for grid layouts | Fragile, hard to span rows/columns | Use `GridPanel` for dashboard-style layouts |
| Horizontal StackPanel for equal-width columns | Items compress/overflow, need grow/basis/wrap hacks | Use `GridPanel columns={N} defaultColumnWidth="1fr"` |
| Manual heading + button row for page header | Takes layout space, buttons truncate | Use `ApplicationHeader` with `actions` prop |
| Manual heading + description for data sections | Repetitive, inconsistent spacing | Use `Portlet` with `title` and `description` props |
| Modals inside StackPanel layout | Modals consume flex space even when hidden | Render modals outside the main StackPanel (siblings, not children) |
| Flat StackPanel with many siblings | UIF treats all items as competing flex items, causing overflow | Use three-zone pattern: `shrink={0}` header + `grow={1}` ContentPanel |
| `<Text />` fallback inside `<StackPanel.Item>` | "Invalid StackPanel Item" error at runtime | Use `null` fallback AND build items with imperative array (see Section 4b) |
| `null` child in StackPanel children array | "Invalid StackPanel Item" — UIF rejects null entries in children | Use imperative array: `var items = []; if (x) items.push(...); <StackPanel>{items}</StackPanel>` |
| Ternary `{cond ? <Item>... : null}` in StackPanel | Still produces null in children array — UIF rejects it | Use imperative array approach instead of inline ternaries (see Section 4b) |
| `<StackPanel.Item>` with multiple children | "Invalid StackPanel Item" — Item expects exactly one child | Ensure each StackPanel.Item wraps exactly one component |
| Modal as sibling of ContentPanel in StackPanel.Item | StackPanel.Item gets 2 children → "Invalid StackPanel Item" | Include modal in imperative items array (see Section 4c) |
| Modal without `owner` prop | "Property 'owner' must be any of: instance of Da, instance of Hr or Element" + "Cannot detach Component that is currently being updated" | Add `owner={myRef}` to every Modal, where `myRef` is a `useRef` attached to the root component via `ref={myRef}` (see Section 4d) |
| Custom list values don't match CSV data | Silent data mapping failures — records save with wrong/empty values | Compare CSV rating labels exactly against custom list values before deployment |
| Mandatory field with no CSV column | Record creation fails for CSV-imported data | Change field `<ismandatory>` to `F` when CSV doesn't include that column |
| No pre-deploy code review pass | Runtime errors discovered after deployment | Always review XML well-formedness, SuiteQL syntax, and UIF rules before deploying |
| `useState(function() { return val })` | Function stored as state value, not its return | UIF `useState` does NOT support lazy initializer functions (unlike React). Always pass computed value directly: `var v = compute(); useState(v)` |
| `Badge.Size` not working as expected | Forgetting to import Badge or using wrong enum path | `Badge.Size` exists with `DEFAULT` and `SMALL` values. Use `size={Badge.Size.SMALL}` for compact badges |
| Space-separated `classList` string | `InvalidCharacterError` at runtime | UIF `classList` uses `DOMTokenList.add()` internally. Always use a SINGLE class name per value. Merge base styles into each variant class in CSS |
| TEMPLATED column callback throws | All remaining columns in the row go blank | Always wrap `content` callbacks in try/catch. Access data via `args.cell.value` or `args.cell.row.dataItem` |
| DataGrid has grey area on right | Internal viewport background extends beyond columns | **CSS fix required.** Add `classList="road-datagrid"` to every `<DataGrid>`, then in your stylesheet: `.road-datagrid, .road-datagrid > div, .road-datagrid > div > div, .road-datagrid > div > div > div { background-color: transparent !important; }`. Note: `columnStretch={true}`, `rootStyle={{ width: '100%' }}`, `stretchFactor`, and `maxWidth: 9999` do NOT fix this — the DataGrid computes its viewport width from column widths regardless of container sizing. Do NOT use `stretchStrategy={{}}` — it is constructor-only and causes VDom errors |
| 35k+ rows in ArrayDataSource | Browser hangs or crashes | Cap preview grids at ~500 rows (`slice(0, PREVIEW_LIMIT)`) |
| Agenda `readOnly={true}` with `eventClickAction` | Event clicks do nothing | `readOnly` propagates to day cells and blocks event click handlers. Remove `readOnly` to enable `eventClickAction` |
| Agenda `eventsLimit: 0` in `daysConfig` | "Must be no value or positive number" error | Use `events: []` to suppress events on specific days instead |
| Agenda `events: []` in `daysConfig` | Legitimate meetings disappear for that day | `events: []` overrides the Agenda's data for that cell. Use only `background`/`showHeader` to dim days without hiding events |
| `agendaViewConfig` prop on Agenda | Silently ignored — Edit Event dialog still opens | This prop does NOT exist. Only `monthViewConfig`, `weekViewConfig`, `dayViewConfig` are available. Use MutationObserver pattern (Section 11) |
| `document.addEventListener('click', ...)` to intercept UIF clicks | Handler fires but cannot prevent UIF dialog | UIF uses its own RoutedMessage event system independent of DOM events. `stopImmediatePropagation()` has no effect. Use MutationObserver or `addMessageFilter()` (Section 11) |
| `position: fixed; left: -9999px` on Agenda | Grid renders at 0 height permanently | Agenda calculates layout from DOM dimensions. Off-screen rendering gives 0 height that never recovers. Use `if (loading) return <Loader />` pattern |
| Agenda inside StackPanel without height context | Toolbar visible but grid is empty/blank | Agenda requires `ContentPanel` ancestor for height context. Return `<ContentPanel>` directly as root, not a wrapper `<StackPanel>` |
| `new Date()` vs `import { Date } from '@uif-js/core'` | Wrong API — `.getFullYear()` vs `.year` | UIF SPA does NOT replace global `Date`. Only explicit import gives UIF Date with `.year`, `.month` (0-indexed), `.day`, `.firstOfMonth()`, `.lastOfMonth()` |
| `<orderindex>` on `<customvalue>` in SuiteApps | SDF ignores it with warnings | Values ordered by XML document order instead. Don't include `<orderindex>` in custom list XML |
| `rowHeight={N}` on DataGrid | Silently ignored — rows stay default height | The correct prop is `dataRowHeight={N}` (not `rowHeight`). Use `dataRowHeight={96}` for multi-line TEMPLATED cells |
| `customizeRow` setting `args.row.heightType` | "Attempted to assign to readonly property" TypeError | `heightType`, `minHeight`, `maxHeight` on GridRow are read-only at runtime despite type defs showing them as writable. Use `dataRowHeight={N}` fixed height instead. `DataGrid.Row.Height.AUTO` is not usable via customizeRow |
| `stretchStrategy={{}}` on DataGrid | VDom "Writable property not found" errors on every re-render | `stretchStrategy` is constructor-only. Use `columnStretch={true}` instead — functionally equivalent and writable |
| `Text.Alignment` for centering text | TypeError: undefined | `Text.Alignment` does not exist in UIF. Use a horizontal `StackPanel` with `justification={StackPanel.Justification.CENTER}` to center text |
| Card as height-propagating container | Card content doesn't stretch to fill parent | Card does NOT propagate height to children. For vertical SPACE_BETWEEN distribution, replace Card with a decorated StackPanel using `rootStyle={{ height: '100%' }}` |
| `disabled={saving}` on Button | "Writable property disabled not found" on re-render | `disabled` is constructor-only. Use `enabled={!saving}` instead — `enabled` IS a writable property |
| Button `action` handler uses stale state | Async handlers read initial-render values, ignoring later state changes | UIF Button may not update `action` reference on re-renders. Store state in refs (`useRef`), sync on every render (`ref.current = stateValue`), read from refs inside the handler |
| `Image.Size.XXL` or `Image.Color.SUCCESS` | TypeError crash, silently blanks parent container | These enum values do not exist in UIF. Use `<Text>` with a Decorator-based style for status indicators instead |
| `Select` component import from `@uif-js/component` | Resolves to `undefined`, silently fails in JSX | `Select` does not exist in `@uif-js/component`. For dropdowns inside DataGrid, use `DataGrid.ColumnType.DROPDOWN` with `inputMode: DataGrid.InputMode.EDIT_ONLY`. For standalone dropdowns, use `Dropdown` from `@uif-js/component` |
| `StackPanel.VerticalAlignment.CENTER` | TypeError crash on module load — kills entire component | `StackPanel.VerticalAlignment` does not exist. Use `alignment={StackPanel.Alignment.CENTER}` for cross-axis alignment on horizontal StackPanels |
| `useEffect` cleanup inside `setTimeout` callback | Cleanup function silently discarded — event listeners leak on every mount | The return value of a `setTimeout` callback is **ignored** by the runtime. Hoist cleanup references to variables in the outer `useEffect` scope (see Section 14) |
| Raw HTML string with unescaped data | XSS in third-party library popups/tooltips | Always use an `escapeHtml()` helper for ALL data values injected into HTML strings — including numeric IDs in `data-` attributes |
| CSS values from data injected into inline `style` | CSS injection can break layout or exfiltrate data via `url()` | Validate dynamic CSS values against an allowlist regex (for example, `/^#[0-9a-fA-F]{3,6}$/` for hex colors) before inserting into style strings |
| `useEffect` with array/object in dependency list | Effect fires on every render — arrays/objects create new references each time | Derive a primitive dependency: `var ready = !loading && !error; useEffect(fn, [ready])` instead of `useEffect(fn, [loading, error, dataArray])` |
| Stale `navigator` in imperative event handlers | Navigation targets wrong route or throws on unmounted component | Store `navigator` in a `useRef`, sync on every render (`ref.current = navigator`), read from ref inside event handlers. See Section 14 for full pattern |
| External CDN scripts in SPA without SRI | Supply chain risk (OWASP A08:2021); NetSuite CSP may silently block | Add `integrity` and `crossOrigin="anonymous"` to injected script/link elements. Prefer self-hosted assets via FileCabinet when build pipeline supports it |
| Module-level `var` for cache without null-check guard | Cache persists stale data after errors or across users | Always initialize as `null`, check with `!== null` (not truthy), and provide a manual cache-clear path (Refresh button). See Section 15 |
| CHECK_BOX for row selection | Unreliable CELL_UPDATE, invisible without grid `editable` | Use `SelectionColumn` type with `DataGrid.Event.ROW_SELECTION_CHANGED` |
| Constructor-only options on re-render | VDom "Writable property not found" errors | `stretchStrategy`, `editingMode`, `preload`, `autoSize`, `defaultColumnOptions`, `bindingController`, `lockedLevels`, `beforeEditCell`, `stripedRows`, `multiColumnSort`, `allowUnsort` are constructor-only — set once in JSX, never bind to state |
## 13. Component API Quick Reference
### StackPanel
| Prop | Values | Purpose |
|------|--------|---------|
| `orientation` | `HORIZONTAL`, `VERTICAL` (default) | Layout direction |
| `itemGap` | `XS`, `S`, `M`, `L`, `XL` | Gap between items |
| `outerGap` | `XS`, `S`, `M`, `L`, `XL` | Padding around panel |
| `wrap` | `true`, `false` | Allow items to wrap |
| `justification` | `START`, `END`, `CENTER`, `SPACE_BETWEEN` | Main axis alignment |
| `alignment` | `START`, `END`, `CENTER`, `STRETCH` | Cross axis alignment |
| `rootStyle` | CSS object | Inline styles on root element |
Shorthand: `StackPanel.Vertical` = `<StackPanel orientation={VERTICAL}>`, `StackPanel.Horizontal` = `<StackPanel orientation={HORIZONTAL}>`
### StackPanel.Item
| Prop | Values | Purpose |
|------|--------|---------|
| `grow` | number (0, 1, etc.) | Flex grow factor |
| `shrink` | number (0, 1, etc.) | Flex shrink factor |
| `basis` | CSS length string | Flex basis (min width before wrap) |
### DataGrid
| Prop | Values | Purpose |
|------|--------|---------|
| `dataSource` | array or DataSource | Data to display |
| `columns` | array | Column definitions |
| `maxViewportHeight` | number (px) | Max height before internal scroll |
| `highlightRowsOnHover` | boolean | Hover highlight |
| `rowCursor` | boolean | Clickable row cursor |
| `columnStretch` | boolean (`true`) | Stretch columns to fill width |
| `dataRowHeight` | number (px) | Fixed row height for data rows (NOT `rowHeight`) |
| `maxViewportWidth` | number (px) | Max width before horizontal scroll |
| `editable` | boolean | Enables cell editing (required for CHECK_BOX/DROPDOWN) |
| `editingMode` | `CELL`, `ROW` | Cell vs row editing mode (constructor-only) |
| `stripedRows` | boolean | Alternating row stripes |
| `multiColumnSort` | boolean | Multi-column sort support |
| `preload` | `ALL`, `VISIBLE`, `NONE` | Virtualization preload strategy |
| `onSort` | SortCallback | Sort event handler |
| `placeholder` | string or Component | Empty grid placeholder |
### DataGrid Column Definition
| Prop | Values | Purpose |
|------|--------|---------|
| `name` | string | Column identifier |
| `label` | string | Column header text |
| `binding` | string | Data field name to bind |
| `type` | `TEXT_BOX`, `CHECK_BOX`, `DETAIL`, etc. | Column type |
| `width` | number | Fixed pixel width |
| `minWidth` | number | Minimum pixel width |
| `stretchFactor` | number | Relative stretch weight |
| `maxWidth` | number | Maximum pixel width (set 9999 for unrestricted stretch) |
| `horizontalAlignment` | `LEFT`, `CENTER`, `RIGHT`, `STRETCH` | Cell content alignment |
| `verticalAlignment` | `TOP`, `CENTER`, `BOTTOM`, `STRETCH` | Cell vertical alignment |
| `inputMode` | `DEFAULT`, `EDIT_ONLY` | When editable widgets show |
| `visibility` | VisibilityBreakpoint | Responsive column visibility |
| `customizeCell` | callback | Per-cell customization function |
### GridPanel
| Prop | Values | Purpose |
|------|--------|---------|
| `columns` | number | Number of grid columns |
| `defaultColumnWidth` | CSS length (for example, `'1fr'`) | Default column width |
| `gap` | `GridPanel.GapSize.*` | Gap between cells |
| `outerGap` | `GridPanel.GapSize.*` | Outer padding |
### GridPanel.Item
| Prop | Values | Purpose |
|------|--------|---------|
| `columnIndex` | number | Starting column |
| `rowIndex` | number | Starting row |
| `columnSpan` | number | Columns to span |
| `rowSpan` | number | Rows to span |
### ListView
| Method | Purpose |
|--------|---------|
| `ListView.ofStaticData({...})` | Create list from static array |
| `dataProvider` | Function returning data array |
| `layout` | Layout configuration with columns and grid options |
| `availableLayouts` | Which layout modes to enable |
| `searchBoxVisible` | Show/hide search |
### ContentPanel
| Prop | Values | Purpose |
|------|--------|---------|
| `outerGap` | `ContentPanel.GapSize.*` | Padding around content |
### ApplicationHeader
| Prop | Values | Purpose |
|------|--------|---------|
| `icon` | `SystemIcon.*` | Header icon |
| `title` | string | Main title |
| `subtitle` | string | Subtitle text |
| `actions` | `[[{label, action, type}]]` | Action buttons in header |
Action button types: `ApplicationHeader.ActionType.PRIMARY`, `ApplicationHeader.ActionType.SECONDARY`
```jsx
// Move page action buttons INTO ApplicationHeader instead of manual button rows
<ApplicationHeader
title="Roadshow Planner"
actions={[[
{ label: 'New Roadshow', type: ApplicationHeader.ActionType.PRIMARY, action: handleNew },
{ label: 'Add Meeting', type: ApplicationHeader.ActionType.SECONDARY, action: handleAdd }
]]}
/>
```
### Agenda
| Prop | Values | Purpose |
|------|--------|---------|
| `viewDate` | UifDate | Current view date |
| `events` | array | Event objects with `name`, `start`, `end`, `color` |
| `readOnly` | boolean | Prevent editing |
| `rootStyle` | CSS object | Container sizing |
### Global Spacing and Size Enums
These enums are used across many components for consistent spacing and sizing:
**GapSize** (for `itemGap`, `outerGap`, `contentGap`, `gap` props):
| Value | Approximate Size | Use |
|-------|-----------------|-----|
| `GapSize.NONE` | 0px | No gap |
| `GapSize.XS` | ~4px | Tight — compact badges, icons |
| `GapSize.S` | ~8px | Small — compact content |
| `GapSize.M` | ~12px | Medium (default) — most content |
| `GapSize.L` | ~16px | Large — section separators |
| `GapSize.XL` | ~24px | Extra large — page-level margins |
| `GapSize.XXL` | ~32px | Dashboard outer margins |
Import: `import { GapSize } from '@uif-js/component'`
Full enum: `NONE`, `XXXXS`, `XXXS`, `XXS`, `XS`, `S`, `M`, `L`, `XL`, `XXL`, `XXXL`, `XXXXL`, `SPACING1X`–`SPACING12X`, `SMALL`, `MEDIUM`, `LARGE`
**InputSize** (for `TextBox.inputSize`, `Dropdown.size`, `DatePicker.size`):
| Value | Use |
|-------|-----|
| `InputSize.AUTO` | Inherit from container |
| `InputSize.S` | Short/compact inputs |
| `InputSize.M` | Standard inputs |
| `InputSize.L` | Wide inputs |
| `InputSize.XL` | Extra wide |
Import: `import { InputSize } from '@uif-js/component'`
Full enum: `AUTO`, `XXS`, `XS`, `S`, `M`, `L`, `XL`, `XXL`
## 14. Third-Party Library Integration (Leaflet, D3, Chart.js, etc.)
When integrating imperative DOM libraries into a UIF SPA, UIF's virtual DOM owns the render tree. You must create a "DOM escape hatch" where the library takes ownership of a specific element.
### 14a. Container Pattern: `classList` + `querySelector`
UIF does not expose React-style `ref` on `StackPanel.Item`. Use `classList` to tag a container element, then find it with `querySelector` in a `useEffect`:
```jsx
// JSX; tag the container.
<StackPanel.Item classList="my-map-container" rootStyle={{ height: '600px' }}>
{loading ? <Loader label="Loading..." /> : null}
</StackPanel.Item>
```
```jsx
// useEffect; find the container after UIF renders.
useEffect(function() {
var container = document.querySelector('.my-map-container');
if (!container) return;
// Imperative library takes ownership here.
container.innerHTML = ''; // Wipe UIF children (Loader).
var instance = L.map(container, { center: [39.8, -98.5], zoom: 4 });
// ...
return function() { instance.remove(); };
}, [ready]);
```
**Risks and mitigations:**
- Use a unique class name (for example, `my-app-map-container`) to avoid collisions if multiple components share the page.
- UIF may re-render the container's parent; once the library owns the element, avoid state changes that trigger parent re-renders.
- Add a short `setTimeout(fn, 50)` before `querySelector` to ensure UIF has flushed its render to the DOM.
### 14b. `useEffect` Cleanup with `setTimeout` (CRITICAL)
When using `setTimeout` inside `useEffect` to defer DOM access, the cleanup pattern has a subtle but critical bug: **the return value of a `setTimeout` callback is silently discarded**.
```jsx
// BAD: removeEventListener is NEVER called (return inside setTimeout is ignored).
useEffect(function() {
var timer = setTimeout(function() {
container.addEventListener('click', handleClick);
return function() { container.removeEventListener('click', handleClick); }; // ← DISCARDED
}, 50);
return function() { clearTimeout(timer); }; // ← only this runs
}, []);
// GOOD: hoist cleanup reference to outer scope.
useEffect(function() {
var removeClickHandler = null;
var timer = setTimeout(function() {
container.addEventListener('click', handleClick);
removeClickHandler = function() {
container.removeEventListener('click', handleClick);
};
}, 50);
return function() {
clearTimeout(timer);
if (removeClickHandler) removeClickHandler(); // ← NOW runs
if (instanceRef.current) { instanceRef.current.remove(); instanceRef.current = null; }
};
}, []);
```
### 14c. Navigator Ref Pattern for Event Handlers
Event handlers registered on raw DOM elements (for example, click delegation on popup buttons) exist outside UIF's render cycle. They capture the `navigator` from the closure at registration time, which may become stale.
```jsx
var navigator = useContext(ContextType.ROUTER_NAVIGATION);
var navigatorRef = useRef(null);
navigatorRef.current = navigator; // sync on every render
// Inside setTimeout or event handler:
function handleClick(e) {
var btn = e.target.closest('[data-rsid]');
if (btn && navigatorRef.current) {
navigatorRef.current.push('/my-meetings?roadshowId=' + btn.getAttribute('data-rsid'));
}
}
```
### 14d. Event Delegation for Dynamic HTML
When a third-party library generates HTML (for example, Leaflet popups, D3 tooltips), UIF event handlers don't apply. Use event delegation on the container:
```jsx
container.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]');
if (btn) {
// handle action based on data attributes
}
});
```
**Security requirements for raw HTML:**
1. **Always escape data** with an `escapeHtml()` helper — including IDs in `data-` attributes.
2. **Validate CSS values** against an allowlist regex before injecting into `style` attributes.
3. **Never interpolate user data** into `href` or event handler attributes.
```jsx
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&').replace(/</g, '<')
.replace(/>/g, '>').replace(/"/g, '"');
}
var SAFE_HEX = /^#[0-9a-fA-F]{3,6}$/;
var color = STATUS_COLORS[status] || '#999';
if (!SAFE_HEX.test(color)) color = '#999';
```
### 14e. CSS Z-Index Isolation
Third-party libraries (Leaflet uses z-index 400+) can overlay UIF modals and dropdowns. Create a new stacking context on the container:
```css
.my-map-container { position: relative; z-index: 0; }
.my-map-container .leaflet-pane { z-index: 1; }
.my-map-container .leaflet-top,
.my-map-container .leaflet-bottom { z-index: 2; }
```
### 14f. Loading External Scripts
UIF SPAs may need to load external JavaScript at runtime. Use DOM injection with error handling:
```jsx
var _loadPromise = null;
function loadLibrary() {
if (_loadPromise) return _loadPromise;
if (window.L) { _loadPromise = Promise.resolve(window.L); return _loadPromise; }
_loadPromise = new Promise(function(resolve, reject) {
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = 'https://cdn.example.com/lib.css';
document.head.appendChild(link);
var script = document.createElement('script');
script.src = 'https://cdn.example.com/lib.js';
script.onload = function() { resolve(window.LibGlobal); };
script.onerror = function() { reject(new Error('Failed to load library')); };
document.head.appendChild(script);
});
return _loadPromise;
}
```
**Module-level promise** prevents duplicate `<script>` injection across component remounts.
**Build pipeline awareness:** The SPA build (`gulpfile.mjs`) copies non-source files from `src/SuiteApps/` to `src/FileCabinet/SuiteApps/` via `bundleAssets()`. Files ending in `.js`, `.jsx`, `.ts`, `.tsx` are treated as source files and **skipped** by `bundleAssets()`. To self-host vendor scripts in FileCabinet, either modify the `isSourceFile()` filter or use a non-source extension.
**NetSuite CSP:** SPAs run on the NetSuite domain. External CDN scripts may be blocked by Content Security Policy. Self-hosting in FileCabinet avoids CSP issues. When using CDN, add `integrity` (SRI) and `crossOrigin="anonymous"` attributes.
### 14g. `useEffect` Dependency Design
Avoid using arrays or objects as `useEffect` dependencies — they create new references on every render, causing the effect to fire repeatedly and rebuild the library instance.
```jsx
// BAD: fires 3+ times per data load cycle (loading→false, error→null, roadshows→[...]).
useEffect(function() { /* build map */ }, [loading, error, roadshows]);
// GOOD: fires once when transitioning to ready state.
var mapReady = !loading && !error;
useEffect(function() {
if (!mapReady) return;
// roadshows is captured at effect run time from the outer closure
// ... build map with current roadshows value
}, [mapReady]);
```
## 15. Module-Level Data Caching
For SPA pages that fetch data on mount, module-level caching prevents re-fetching when navigating back to a page. This pattern survives component remounts during the SPA session.
```jsx
// Module scope; outside the component function
var _pageCache = null;
function MyPage(props) {
var hasCached = _pageCache !== null;
var [loading, setLoading] = useState(!hasCached);
var [error, setError] = useState(null);
var [data, setData] = useState(hasCached ? _pageCache : []);
useEffect(function() {
if (!_pageCache) {
loadData();
}
}, []);
async function loadData() {
setLoading(true);
setError(null);
try {
var response = await getMyData({});
if (response.success) {
_pageCache = response.data;
setData(response.data);
} else {
setError(safeErrorMessage(response.error));
}
} catch (err) {
setError(safeErrorMessage(err));
} finally {
setLoading(false);
}
}
function handleRefresh() {
_pageCache = null;
loadData();
}
// ... render with loading / error / data states
}
```
**Key rules:**
- Initialize cache as `null`, check with `!== null` (empty arrays `[]` are valid cached data).
- Always provide a Refresh action that sets `_pageCache = null` before reloading.
- Cache stores the raw data from the server response — state initialization reads from cache on remount.
- The `safeErrorMessage()` helper is commonly duplicated per page; extract to `utils/` if used in 3+ pages.
## 16. Form Building Pattern (Field + FieldGroup + Controls)
The standard NetSuite UIF form pattern uses `Field` as a label-control wrapper, `FieldGroup` to group related fields, and individual controls (`TextBox`, `Dropdown`, `CheckBox`, `DatePicker`, `Switch`, `RadioButtonGroup`) as the inputs.
### 16a. Basic Form Field Pattern
`Field` wraps a single control with a label and optional validation display:
```jsx
import { Field, TextBox, Dropdown, CheckBox, DatePicker, Switch } from '@uif-js/component';
import { ArrayDataSource } from '@uif-js/core';
// Text field
<Field label="Customer Name" mandatory={true} orientation={Field.Orientation.HORIZONTAL}>
<TextBox
text={formData.name}
placeholder="Enter customer name"
maxLength={100}
onTextChanged={function(args) {
setFormData({ ...formData, name: args.text });
}}
/>
</Field>
// Dropdown field
<Field label="Status" orientation={Field.Orientation.HORIZONTAL}>
<Dropdown
dataSource={new ArrayDataSource(statusOptions)}
displayMember="label"
valueMember="value"
selectedValue={formData.status}
onSelectionChanged={function(args) {
setFormData({ ...formData, status: args.item ? args.item.value : null });
}}
/>
</Field>
// Date picker field
<Field label="Due Date" orientation={Field.Orientation.HORIZONTAL}>
<DatePicker
date={formData.dueDate}
onDateChanged={function(args) {
setFormData({ ...formData, dueDate: args.date });
}}
/>
</Field>
// Checkbox field
<Field label="Active" orientation={Field.Orientation.HORIZONTAL}>
<CheckBox
value={formData.active}
label="Enable record"
action={function(args) {
setFormData({ ...formData, active: args.value });
}}
/>
</Field>
// Switch (toggle) field
<Field label="Notifications" orientation={Field.Orientation.HORIZONTAL}>
<Switch
value={formData.notifications}
action={function(args) {
setFormData({ ...formData, notifications: args.value });
}}
/>
</Field>
```
### 16b. FieldGroup for Grouped Sections
`FieldGroup` creates a visually grouped panel of fields with an optional title and collapse behavior:
```jsx
import { FieldGroup } from '@uif-js/component';
<FieldGroup title="Contact Information" collapsible={true}>
<Field label="First Name" mandatory={true}>
<TextBox text={formData.firstName} onTextChanged={...} />
</Field>
<Field label="Last Name" mandatory={true}>
<TextBox text={formData.lastName} onTextChanged={...} />
</Field>
<Field label="Email">
<TextBox text={formData.email} type={TextBox.Type.EMAIL} onTextChanged={...} />
</Field>
</FieldGroup>
```
### 16c. Field.Mode for View/Edit Toggle
`Field.Mode.VIEW` renders the value as read-only display text; `Field.Mode.EDIT` renders the input control. This is the standard pattern for record view/edit toggle:
```jsx
var [isEditing, setIsEditing] = useState(false);
var fieldMode = isEditing ? Field.Mode.EDIT : Field.Mode.VIEW;
<Field label="Customer Name" mode={fieldMode}>
<TextBox text={formData.name} onTextChanged={...} />
</Field>
```
### 16d. Field Sizing
Use `Field.Size` to constrain form field widths:
| Size | Use Case |
|------|----------|
| `Field.Size.SMALL` | Short inputs (codes, quantities) |
| `Field.Size.MEDIUM` | Standard fields (names, titles) |
| `Field.Size.LARGE` | Wider fields (addresses, descriptions) |
| `Field.Size.STRETCH` | Full-width (fills container) |
```jsx
<Field label="Item Code" size={Field.Size.SMALL}>
<TextBox text={code} onTextChanged={...} />
</Field>
<Field label="Description" size={Field.Size.STRETCH}>
<TextBox text={desc} onTextChanged={...} />
</Field>
```
### 16e. RadioButtonGroup for Exclusive Choice
```jsx
import { RadioButtonGroup, RadioButton } from '@uif-js/component';
<Field label="Priority" orientation={Field.Orientation.HORIZONTAL}>
<RadioButtonGroup
selectedData={formData.priority}
onSelectionChanged={function(args) {
setFormData({ ...formData, priority: args.data });
}}
>
<RadioButton label="Low" value="low" />
<RadioButton label="Medium" value="medium" />
<RadioButton label="High" value="high" />
</RadioButtonGroup>
</Field>
```
**Note:** Do not use the deprecated `radio` prop on `RadioButton` — use `value` instead.
### 16f. Form Submit Pattern
Collect all form state in a single object and submit via `Ajax.post()` to your SPA server action:
```jsx
var [formData, setFormData] = useState({ name: '', status: null, active: true });
var [saving, setSaving] = useState(false);
async function handleSave() {
setSaving(true);
try {
var response = await Ajax.post(
'/app/site/hosting/scriptlet.nl?script=custspa_my_app&deploy=1&action=saveRecord',
formData,
{ responseType: Ajax.ResponseType.JSON }
);
if (response.success) {
// navigate away or show success
}
} finally {
setSaving(false);
}
}
// Note: Verify ApplicationHeader.ActionType values against @uif-js/component docs.
<ApplicationHeader
title="Edit Record"
actions={[[
{ label: 'Save', type: ApplicationHeader.ActionType.PRIMARY, action: handleSave, enabled: !saving },
{ label: 'Cancel', type: ApplicationHeader.ActionType.SECONDARY, action: handleCancel }
]]}
/>
```
---
## 17. Notification Patterns (GrowlMessage, Banner)
### 17a. Toast Notifications with GrowlPanel + GrowlMessage
`GrowlPanel` is the container; add `GrowlMessage` instances to it programmatically. The `GrowlPanel` should be placed at the page root level (alongside the content in your shell).
```jsx
import { GrowlPanel, GrowlMessage } from '@uif-js/component';
// In your root component or shell:
var growlPanelRef = useRef(null);
// Notify helper:
function notify(type, title, content) {
if (!growlPanelRef.current) return;
var msg = new GrowlMessage({
title: title,
content: content,
type: type, // GrowlMessage.Type: INFO | SUCCESS | WARNING | ERROR
showCloseButton: true,
closeOnClick: true,
});
growlPanelRef.current.add(msg); // method is .add(), not .addMessage()
}
// Usage:
notify(GrowlMessage.Type.SUCCESS, 'Saved', 'Record has been saved.');
notify(GrowlMessage.Type.ERROR, 'Error', 'Failed to save record.');
// Render in JSX (place outside StackPanel content area to avoid layout interference):
<GrowlPanel
ref={function(panel) { growlPanelRef.current = panel; }}
position={GrowlPanel.Position.BOTTOM_RIGHT}
/>
```
**Common mistake:** Placing `GrowlPanel` inside a `<StackPanel.Item>` causes it to scroll with content. Place it at the top-level page JSX or use `rootStyle={{ position: 'fixed' }}`.
### 17b. Persistent Banners with Banner
Use `Banner` for persistent informational alerts that stay visible until dismissed:
```jsx
import { Banner } from '@uif-js/component';
{errorMessage ? (
<Banner
title="Error"
content={errorMessage}
color={Banner.Color.ORANGE}
showControls={true}
closeAction={function() { setErrorMessage(null); }}
/>
) : null}
```
Available colors: `Banner.Color.BLUE` (informational), `Banner.Color.BLUE_DARK` (emphasis), `Banner.Color.GREEN` (success), `Banner.Color.ORANGE` (warning/error).
---
## 18. TabPanel for Multi-Section Pages
`TabPanel` organizes content into tabs — the primary pattern for record detail pages with multiple sections (Details, Notes, History, Related Records).
### 18a. Basic TabPanel Pattern
```jsx
import { TabPanel, Tab } from '@uif-js/component';
import { useState, useRef, useEffect } from '@uif-js/core';
function RecordDetail(props) {
var [activeTab, setActiveTab] = useState('details');
var tabPanelRef = useRef(null);
useEffect(function() {
if (!tabPanelRef.current) return;
// Subscribe to TAB_SELECTED event; there is no onTabChanged prop.
return tabPanelRef.current.on(TabPanel.Event.TAB_SELECTED, function(e) {
setActiveTab(e.value);
});
}, []);
return (
<TabPanel
ref={function(c) { tabPanelRef.current = c; }}
selectedValue={activeTab}
>
<Tab label="Details" value="details">
<DetailsSection record={props.record} />
</Tab>
<Tab label="Notes" value="notes" icon={SystemIcon.ADD}>
<NotesSection id={props.record.id} />
</Tab>
<Tab label="History" value="history">
<HistorySection id={props.record.id} />
</Tab>
</TabPanel>
);
}
```
**Note:** `TabPanel` does not have an `onTabChanged` prop. Listen to tab selection via `tabPanelRef.current.on(TabPanel.Event.TAB_SELECTED, handler)` in a `useEffect`. The cleanup return value from `.on()` unsubscribes when the component unmounts.
### 18b. Lazy-Loading Tab Content
To avoid loading all tab data upfront, only render the active tab's content:
```jsx
<TabPanel ref={...} selectedValue={activeTab}>
<Tab label="Details" value="details">
{activeTab === 'details' ? <DetailsSection /> : null}
</Tab>
<Tab label="History" value="history">
{activeTab === 'history' ? <HistorySection /> : null}
</Tab>
</TabPanel>
```
**Note:** This pattern destroys and recreates the tab's component tree on each tab switch, clearing any local state. Use module-level caching (Section 15) for data that should survive tab switches.
---
## 19. Shared State Management with Store
For SPAs with multiple pages that share state (selected items, user preferences, filter state, loaded data), use the `Store` + `Reducer` pattern instead of prop-drilling through `Router.Routes`.
### 19a. Creating a Store
```jsx
import { Store, Reducer, ImmutableObject } from '@uif-js/core';
// Note: Reducer.create() takes an action handler map. Verify exact API against Oracle sample apps
// as TypeScript types use an options-object pattern. The pattern below is the documented usage.
var appReducer = Reducer.create({
SET_DATA: function(state, action) {
return ImmutableObject.set(state, 'data', action.data);
},
SET_LOADING: function(state, action) {
return ImmutableObject.set(state, 'loading', action.loading);
},
SET_FILTER: function(state, action) {
return ImmutableObject.set(state, 'filter', action.filter);
}
});
// Create store with initial state.
var store = Store.create({
reducer: appReducer,
initial: {
data: [],
loading: false,
filter: ''
}
});
```
### 19b. Providing the Store to the Component Tree
Wrap your root component (or the Router.Routes block) with `Store.Provider`:
```jsx
// In your SPA's root component (run() or initializeSpa()):
function App(props) {
return (
<Store.Provider store={store}>
<StackPanel>
<StackPanel.Item shrink={0}>
<Menu />
</StackPanel.Item>
<StackPanel.Item grow={1}>
<ScrollPanel {...scrollOptions}>
<Router.Routes>
<Router.Route path="/list" exact={true}>
<ListPage />
</Router.Route>
</Router.Routes>
</ScrollPanel>
</StackPanel.Item>
</StackPanel>
</Store.Provider>
);
}
```
### 19c. Reading and Writing Store State
Use `useSelector` to read state slices, `useDispatch` to write:
```jsx
import { useSelector, useDispatch } from '@uif-js/core';
function ListPage(props) {
var data = useSelector(function(state) { return state.data; });
var loading = useSelector(function(state) { return state.loading; });
var dispatch = useDispatch();
useEffect(function() {
if (!loading && data.length === 0) {
dispatch({ type: 'SET_LOADING', loading: true });
loadData().then(function(result) {
dispatch({ type: 'SET_DATA', data: result });
dispatch({ type: 'SET_LOADING', loading: false });
});
}
}, []);
// ...
}
```
You can also access the Store instance directly in deep child components via `useContext(ContextType.STORE)` instead of prop-drilling.
### 19d. When to Use Store vs Local useState
| Pattern | Use When |
|---------|----------|
| `useState` | State is local to one component (form inputs, open/closed toggle) |
| Module-level cache (Section 15) | Data that should survive remount in the same session |
| `Store` | State shared across multiple pages or deeply nested components |
**Tip:** Start with `useState`. Migrate to `Store` only when you find yourself passing the same state/setter as props through 3+ component levels.
### 19e. Combining Multiple Reducers
`Reducer.combine()` takes an array of `WithPath` objects (not a plain object map):
```jsx
var combinedReducer = Reducer.combine([
{ path: 'records', reduce: recordsReducer },
{ path: 'ui', reduce: uiReducer },
{ path: 'filters', reduce: filtersReducer }
]);
var store = Store.create({
reducer: combinedReducer,
initial: {
records: { data: [], loading: false },
ui: { selectedId: null },
filters: { query: '', status: 'all' }
}
});
// Access with namespaced selectors:
var records = useSelector(function(state) { return state.records.data; });
var selectedId = useSelector(function(state) { return state.ui.selectedId; });
```
---
## 20. Async Cancellation Pattern (CancellationToken)
When a component starts an async operation (`Ajax.post()`, `Promise`) and the user navigates away before it completes, the component may have unmounted. Calling `setState` on an unmounted component causes silent state corruption. Use `CancellationTokenSource` to abort in-flight operations during cleanup.
```jsx
import { CancellationTokenSource } from '@uif-js/core';
function DataPage(props) {
var [data, setData] = useState([]);
var [loading, setLoading] = useState(true);
useEffect(function() {
// Create a cancellation source for this effect instance
var source = new CancellationTokenSource();
var token = source.token;
loadData(token);
// Cleanup: cancel when component unmounts or effect re-runs
return function() {
source.cancel();
};
}, []); // empty deps = run once on mount
async function loadData(token) {
try {
var response = await Ajax.post(
'/app/site/hosting/scriptlet.nl?...',
{},
{ responseType: Ajax.ResponseType.JSON }
);
// Check before setting state — component may have unmounted
if (token.cancelled) return;
setData(response.records);
setLoading(false);
} catch (err) {
if (token.cancelled) return;
// handle error
}
}
// ...
}
```
**Rule:** Always check `token.cancelled` before calling any state setter in an async function that was started in a `useEffect`. The check is free (a boolean read) and prevents a class of subtle bugs that are difficult to reproduce.
**When to use:**
- Any `useEffect` that starts a fetch/Ajax call.
- Any `useEffect` that starts a multi-step async operation.
- Any `useEffect` with deps that could change while async work is in progress (re-triggers with new token).
---
## 21. Hierarchical Grids with TreeDataSource
For displaying tree-structured data (bill of materials, account hierarchies, nested categories), use `TreeDataSource` with `DataGrid.ColumnType.TREE`.
```jsx
import { TreeDataSource } from '@uif-js/core';
import { DataGrid } from '@uif-js/component';
// Your data: each item has a children array.
var hierarchicalData = [
{
id: 1, name: 'Assembly A', qty: 1,
children: [
{ id: 2, name: 'Part X', qty: 2, children: [] },
{ id: 3, name: 'Part Y', qty: 1, children: [
{ id: 4, name: 'Sub-Part Z', qty: 3, children: [] }
]}
]
}
];
// Create a TreeDataSource with a childAccessor.
// childAccessor can be a function OR a string property name (for example, 'children').
var treeDs = new TreeDataSource({
data: hierarchicalData,
childAccessor: function(item) { return item.children; }
});
// Use TREE column type for the hierarchy column.
<DataGrid
dataSource={treeDs}
maxViewportHeight={400}
columns={[
{
type: DataGrid.ColumnType.TREE,
name: 'name',
binding: 'name',
label: 'Component',
showTreeLines: true,
minWidth: 200,
stretchFactor: 2
},
{
type: DataGrid.ColumnType.TEXT_BOX,
name: 'qty',
binding: 'qty',
label: 'Qty',
width: 80,
horizontalAlignment: GridColumn.HorizontalAlignment.RIGHT
}
]}
/>
```
**Updating tree data:**
```jsx
// Replace all data (triggers full grid refresh).
treeDs.setData(newHierarchicalData);
// Add a child item.
treeDs.add(newItem, parentItem);
// Remove an item and all its children.
treeDs.remove(item);
```
**Note:** `DataGrid.ColumnType.TREE` and `DataGrid.ColumnType.DETAIL` serve different purposes. `TREE` is for `TreeDataSource` hierarchies. `DETAIL` is for expandable rows in flat `ArrayDataSource` grids.
---
## Sources
- Oracle official SPA samples: [airport360](https://github.com/oracle-samples/netsuite-suitecloud-samples/tree/main/spa-suiteapp-samples/airport360), [basics-routing](https://github.com/oracle-samples/netsuite-suitecloud-samples/tree/main/spa-suiteapp-samples/basics-routing), [basics-state-management](https://github.com/oracle-samples/netsuite-suitecloud-samples/tree/main/spa-suiteapp-samples/basics-state-management)
- [@oracle/netsuite-uif-types](https://www.npmjs.com/package/@oracle/netsuite-uif-types) NPM package
- NetSuite Help Center: Single Page Applications documentation
SHA-256: 271bfedf95a4db35717168c5dd542b85cf89a4605b6e485dba8ce16a16fc03cd