Complete reference for architectural patterns and pitfalls in Flows custom apps.
AI coding agents optimize for code that works now, not patterns that stay consistent. As Flows custom apps grow, implementations drift and become harder to review, test, and change safely. Spec-driven development helps keep requirements and implementation aligned when working with agents.Apply consistent patterns in these four areas to keep your codebase legible as your Flows custom app grows:
Shared client state → Zustand (when state changes often) or React Context (for stable globals).
Component shape → business logic in hooks, components for rendering UI.
This page is a pattern catalog with side-by-side Do/Don’t examples. Read Across all patterns before you simplify boilerplate. For a scan-friendly summary after the patterns, see Quick reference.
These apply regardless of which patterns you follow.
Input validation at trust boundaries. Parse data from the SDK and from forms with Zod. TypeScript types are erased at runtime, so “the types already match” is not a reason to skip this.
Error handling that prevents data loss. Mutations should have onError rollbacks. A silent failure that leaves CDF in a partial state is harder to recover from than a visible error.
CDF SDK only. All CDF calls via useCogniteSdk(). Raw fetch or axios calls bypass CDF authentication controls and lose automatic token refresh, rate-limit retries, and request tracing.
Security. No secrets in client code, no dangerouslySetInnerHTML with unescaped user content.
Accessibility. Meaningful aria labels on interactive elements, keyboard navigability, no onClick on non-interactive elements like <div>.
Anything explicitly requested. If a requirement, ticket, or review comment calls something out, it is not optional.
Use the table below to pick the right state tool. It prevents most over- and under-engineering before you read the patterns.
State type
Tool
Local to one component
useState
Stable global value (the CDF client, current user, feature flags)
React Context
Frequently-updating shared state (selections, filters, wizard drafts)
Zustand
Anything fetched from CDF
React Query — never copy into useState
React Context is the right default for stable globals. The Flows custom app scaffold already provides the CDF client via CogniteSdkProvider, so you thin-wrap its hook. Reach for Zustand when state changes frequently across many components or causes re-render storms.
To reset all state in a component when an entity changes (for example, switching between contacts in an edit form), pass the entity’s ID as key. React treats components with different keys as separate instances and resets their state automatically. No useEffect required.
// No useEffect needed to reset form state when selectedContact changes<EditForm key={selectedContact.id} contact={selectedContact} />
Group code by feature, not by technical type. Instead of top-level hooks/, components/, and services/ folders that mix unrelated concerns, each feature gets its own folder with everything it needs: data, state, schema, and UI.
src/ features/ alert-triage/ ui/ # presentational components specific to alert-triage context/ # context providers specific to alert-triage hooks/ # hooks specific to alert-triage useTriageVM.ts # view-model hook (owns data + commands) useAlerts.ts # query hook for this feature store.ts # Zustand: client state for alert-triage schema.ts # Zod validation schemas for alert-triage index.ts # public API of the feature context/ # Shared context providers hooks/ # Shared hooks stores/ # Shared Zustand store ui/ # Shared UI components ErrorState.tsx # one shared error surface App.tsx # shell + ErrorBoundary + QueryClientProvider
This section covers areas where Flows custom apps can drift as they grow. Where a pattern has a clear counterpart to avoid, a side-by-side example shows both.
A common pattern in growing Flows custom apps is fetching data with useState + useEffect. Developers often add a manual loading flag, a cancelled ref to handle race conditions, and a counter to force refetches.This hand-rolled loop is roughly what React Query does — except without caching, deduplication, background refresh, or retries. React Query ships with every Flows custom app; use it for all CDF reads and writes. See TanStack Query in Flows for how the scaffold wires data fetching to CDF.
function useWellList(startDate: string, endDate: string) { const [rows, setRows] = useState<Row[]>([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState<Error | null>(null); const [fetchKey, setFetchKey] = useState(0); // refetch hack useEffect(() => { let cancelled = false; setIsLoading(true); setError(null); Promise.all([fetchWells(), fetchDeferments()]) .then(([w, d]) => { if (!cancelled) setRows(joinWellData(w, d)); }) .catch((e) => { if (!cancelled) setError(e); }) .finally(() => { if (!cancelled) setIsLoading(false); }); return () => { cancelled = true; }; }, [fetchKey, startDate, endDate]); const refetch = () => setFetchKey((k) => k + 1); // no cache, no dedup, no bg refresh, no retry}
Query keys must be unique and specific to avoid cache collisions — a collision silently serves wrong data and is difficult to debug in production. Always include the resource type and all relevant parameters. Keys should mirror the shape of the underlying API request: a GET /projects?userId=123&status=active call maps to ["projects", userId, status].
useState is intended for local UI state (modals, form inputs, toggles). React Query handles server state and API data — never copy query data into useState, which opts out of background updates and causes the UI cache to drift from the server.
CDF instance properties are loosely typed by nature. Without an explicit parse step, that looseness leaks into the rest of your Flows custom app: every component that needs a value ends up re-asserting the same assumptions with as casts and ad-hoc coercers.Parse the response into a known shape once, at the point where it enters your Flows custom app, and downstream code can trust the type without re-asserting it.
// schema.ts — one schema per view; z.infer is the typeconst WellSchema = z.object({ name: z.string().optional(), uwi: z.string().nullish(), latitudeWGS84: z.coerce.number().default(0), longitudeWGS84: z.coerce.number().default(0),});type Well = z.infer<typeof WellSchema>;function mapNode(node: NodeDefinition): Well { const raw = node.properties?.[VIEW.space]?.[KEY] ?? {}; return WellSchema.parse(raw); // wrong shape → throws here, surfaced as a query error // not silently wrong data}
function getViewProps(node: NodeDefinition) { const bySpace = (node.properties as Record<string, Record<string, unknown>>) ?.[VIEW.space]; return (bySpace?.[KEY] as Record<string, unknown>) ?? {};}function mapNode(node: NodeDefinition): Well { const p = getViewProps(node); return { name: (p.name as string) ?? node.externalId, uwi: p.uwi as string, latitudeWGS84: Number(p.latitudeWGS84) || 0, // if latitudeWGS84 is renamed upstream, this silently // becomes 0 — no error, ever };}
Parse at exactly two seams: SDK → app (responses) and form → SDK (writes). Downstream functions receive a real typed value, not unknown.
It’s tempting to wrap CDF calls in service classes with interfaces for testability, but for a read-mostly UI, the overhead rarely pays off. An interface, a class, a ServicesContext, and a per-hook DepsContext to perform a single list() means touching four files for what should be one hook. A typed query hook is sufficient.
// useAlerts.ts — one obvious place per resourceexport function useAlerts() { const client = useCogniteSdk(); return useQuery({ queryKey: ["alerts"], queryFn: async () => { const res = await client.instances.list(ALERT_SOURCE); return res.items.map((node) => { const raw = node.properties?.[VIEW.space]?.[KEY] ?? {}; return AlertSchema.parse(raw); // consumers receive Alert[] — never unknown }); }, });}
// services/cpv-plan-service.tsexport interface CpvPlanService { listPlans(): Promise<CpvPlan[]>;}export class ApiCpvPlanService implements CpvPlanService { /* ... */ }// contexts/services-context.tsxconst defaultServices = { cpvPlanService: new MockCpvPlanService(), // mock as the default!};export const useServices = () => useContext(ServicesContext);// + a per-hook DepsContext just to inject a mock in tests
A service class earns its place when there’s real logic — pagination, joining, write-shaping — or a genuine need to swap implementations. Start with a typed query hook; promote only when the body actually grows.
All CDF API calls must go through the SDK via useCogniteSdk(). Never make raw fetch or axios requests to CDF endpoint URLs. The SDK handles token refresh, retries on rate-limit errors (429s), typed error responses, and consistent request tracing — none of which you get from a plain fetch.
React Context and Zustand solve different problems. Context re-renders every consumer when its value changes, which suits values that rarely change. Zustand re-renders only components that subscribe to the changed slice, which suits values that change frequently.Use Context for app-shell configuration that is set once and read everywhere, for example, the CDF client, current user, and feature flags. Use Zustand for state that updates during user interaction, such as selections, filters, and wizard drafts, or that many unrelated components read.
// store.ts — colocated with the feature// Use Zustand for state that changes often (filters, selection)export const useTriageStore = create<TriageState>((set) => ({ selectedId: null, filters: defaultFilters, select: (id) => set({ selectedId: id }), setFilters: (f) => set((s) => ({ filters: { ...s.filters, ...f } })),}));// Components subscribe only to the slice they needfunction Row({ id }: { id: string }) { const selected = useTriageStore((s) => s.selectedId === id); const select = useTriageStore((s) => s.select); // re-renders only when this row's selected state changes}
// State threaded through every level as props<Page selected={sel} onSelect={setSel} filters={f} setFilters={setF} /> <Toolbar filters={f} setFilters={setF} /> <Table selected={sel} onSelect={setSel} filters={f} /> <Row selected={sel} onSelect={setSel} />// Or a hand-rolled Context per concernconst Ctx = createContext(null);function Provider({ children }) { const [sel, setSel] = useState<string | null>(null); return <Ctx.Provider value={{ sel, setSel }}>{children}</Ctx.Provider>;}// Every consumer re-renders when any value in the context changes
Start with useState for local state. Lift to Context when a stable value needs to be available across the tree. Reach for Zustand when that value starts changing frequently, when you’re prop-drilling beyond 3 levels, or when Context re-renders become noticeable.
When components grow past a few hundred lines, they are usually doing too much: fetching their own data, computing derived values, and rendering in one place. That makes them harder to test in isolation and easier to break with unrelated edits. Extract data and logic into a view-model hook instead, and let the component focus on rendering.
// hook owns data & commands — testable in isolationfunction usePipelineRun() { const run = useMutation({ mutationFn: runPipeline }); return { run: run.mutate, isPending: run.isPending, error: run.error };}// component is a thin binding to the design systemfunction PipelinePanel() { const { run, isPending, error } = usePipelineRun(); return ( <Card> <Button onClick={() => run()} disabled={isPending}> {isPending ? "Running…" : "Run"} </Button> {error && <Alert variant="destructive">{error.message}</Alert>} </Card> );}
This extends the view-model pattern above. When agents or developers add features quickly, business logic accumulates in components: a useQuery call here, a useEffect fetch there, an inline command handler that should live in a hook. Over time the component becomes the source of truth for data fetching and commands that belong in hooks. Keep components responsible for rendering and local UI state only; everything else belongs in a hook instead.
function FormUI() { // business logic lives in the hook const { data, isLoading, addData } = useFormBusinessLogic(); // UI state stays in the component const [modalOpen, setModalOpen] = useState(false); // simple UI events are fine const handleChange = (e: ChangeEvent<HTMLInputElement>) => { // ... }; return <div>...</div>;}
function FormUI() { // BAD: data fetching in the component const { data } = useQuery({ queryKey: ["form"], queryFn: fetchFormData }); const { mutate } = useMutation({ mutationFn: submitForm }); // BAD: business command defined in the component body useEffect(() => { api.fetch().then(setState); }, []); const addData = useCallback(() => { api.addData(); }, []); return <div>...</div>;}
Modal open/close, hover, and resize observers are UI state. Keep them in the component.
When a value can be computed from existing props or state, the temptation is to store a copy in useState and keep it in sync with useEffect. This is unnecessary: React re-runs the component on every relevant state change anyway, and an inline const is always up to date. The useEffect version causes two renders: one with the stale value, one after the Effect fires, and adds a copy that can drift from its source.
// Always up to date — no Effect neededconst filtered = items.filter((i) => i.active);// For genuinely expensive transforms, memoizeconst sorted = useMemo(() => items.slice().sort(byDate), [items]);
// Extra state that can drift from its sourceconst [filtered, setFiltered] = useState([]);useEffect(() => { setFiltered(items.filter((i) => i.active));}, [items]);// Causes two renders: one with the stale value, one after the Effect fires
The You Might Not Need an Effect React article covers the full range of cases where useEffect is the wrong tool: derived state, event handlers, parent notification, and more.
As features grow, hooks often return unrelated values together: query data, derived fields, and callbacks in one object. A component that uses only one of those values still re-renders when any other value changes. Split hooks by responsibility, not by feature area.
// Split into focused hooks — each component subscribes to what it needsconst useUserStats = () => { // ... return { userStats };};const useChartData = () => { // ... return { chartData };};function UserStatsComponent() { const { userStats } = useUserStats(); // only re-renders when userStats changes return <div>{userStats.total}</div>;}
const useDashboardLogic = () => { // lots of unrelated logic return { userStats, chartData, recentActivity, settings };};function UserStatsComponent() { // re-renders when chartData or recentActivity change, // even though this component only uses userStats const { userStats } = useDashboardLogic(); return <div>{userStats.total}</div>;}
Signal to split: multiple components call the same hook but destructure completely different values.
Returning the full query or mutation object from a hook is tempting, but it leaks React Query internals to every caller. If you later change how data is fetched, such as swapping a query for a subscription or adding a transformation, you must update every component that destructures the raw object. Expose only what callers need: data, a loading flag, and named command functions.
Hooks that call useQuery, useMutation, routing hooks, or SDK clients are impure. Hard-coded imports make them difficult to test without vi.mock. That workaround is path-coupled and not type-checked. It can also break silently when you rename or move a file.There are two ways to make dependencies injectable. Choose based on the unit’s depth in the tree, and be consistent within a unit:
For shallow units, add a deps prop with real implementations as the default. Tests pass a typed substitute directly, no mocking needed.
For containers and nested trees, put dependencies in a .context.tsx file with real defaults so production code needs no Provider. Tests inject via <Context.Provider value={fakeDeps}>.
// Discouraged for first-party modules: path-coupled, not type-checked.// Renaming use-tasks.ts breaks this test without a type error.import { useTasks } from "../services/use-tasks";vi.mock("../services/use-tasks", () => ({ useTasks: () => ({ isLoading: false, tasks: [] }),}));
vi.mock remains appropriate for third-party and external libraries. For first-party code, prefer a typed seam. If you find yourself prop-drilling deps through intermediate components, switch to Context DI instead.
Inline style={{}} objects bypass the aura/no-overriding-styles ESLint guardrail and guarantee visual drift from the Aura design system. They also signal that the component is doing too much: they often appear in oversized components that also mix in data fetching and business logic.If you need to customize the appearance or styling, follow this order of priority:
Use the component props and variants available in the design system.
The aura/no-overriding-styles ESLint rule enforces this automatically. Don’t disable or suppress it. Browse Aura primitives for layout and feedback components — for example Card and Badge — instead of styled HTML elements.
Do not scatter view IDs, space names, and property keys as string literals across your codebase. When you port a Flows custom app to another CDF project, or rename a space or view, you must find and replace every occurrence by hand. Keep all identifiers in config/model.ts and import from there instead. For batching and concurrency when you read and write instances, use the dm-limits-and-best-practices builder skill (About builder skills).
// config/model.ts — one place for all CDF view identifiersexport const MODEL = { space: "my-solution-space", views: { well: { externalId: "Well", version: "1" }, }, properties: { well: { name: "name", uwi: "uwi", lat: "latitudeWGS84" }, },} as const;// Changing the space name means editing one line here.// All usages across the codebase update automatically.function useWells() { const client = useCogniteSdk(); return useQuery({ queryKey: ["wells"], queryFn: () => client.instances.list({ sources: [{ source: { type: "view", ...MODEL.views.well, space: MODEL.space } }], }), });}
// useWells.tsfunction useWells() { const client = useCogniteSdk(); return useQuery({ queryKey: ["wells"], queryFn: () => client.instances.list({ sources: [{ source: { type: "view", externalId: "Well", version: "1", space: "my-solution-space" } }], }), });}// useWellFilters.ts — same literals, different fileconst filter = { equals: { property: ["my-solution-space", "Well/1", "uwi"], value: uwi } };// WellDetail.tsx — and againconst lat = node.properties?.["my-solution-space"]?.["Well/1"]?.["latitudeWGS84"];// Rename the space or bump the view version and you'll miss at least one.
Without a defined error strategy, each component handles failures differently. Some store error text in state and render a red <pre>. Others fail silently with an empty screen. An uncaught render error can crash the whole iframe. Use two layers for most cases.
// App.tsx — one boundary at the shell catches render crashes<ErrorBoundary fallback={<AppError />}> <QueryClientProvider client={queryClient}> <App /> </QueryClientProvider></ErrorBoundary>// Per view — use the query's own error stateconst { data, error, isError, refetch } = usePlans();if (isError) return <ErrorState onRetry={refetch} error={error} />;// <ErrorState /> is one shared component used everywhere
// Per-component string + red pretry { const data = await svc.load(); setData(data);} catch (e) { setError(e instanceof Error ? e.message : String(e));}{error && <pre style={{ color: "red" }}>{error}</pre>}// A render crash takes down the whole iframe.// Retry = reload the page.