Skip to main content
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:
  • Server stateTanStack React Query.
  • Validated typesZod at the SDK boundary.
  • Shared client stateZustand (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.

Across all patterns

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.
For certification scoring on design and accessibility, see Flows custom apps quality guidelines.

State: choosing the right tool

Use the table below to pick the right state tool. It prevents most over- and under-engineering before you read the patterns.
State typeTool
Local to one componentuseState
Stable global value (the CDF client, current user, feature flags)React Context
Frequently-updating shared state (selections, filters, wizard drafts)Zustand
Anything fetched from CDFReact 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

Patterns

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.

Use React Query for all server state

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) {
  return useQuery({
    queryKey: ["wells", startDate, endDate],
    queryFn: async () => {
      const [wells, deferments] = await Promise.all([
        fetchWells(),
        fetchDeferments(),
      ]);
      return joinWellData(wells, deferments);
    },
    staleTime: 60_000,
  });
  // caching, dedup, retries, background refetch: included
}
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.

Parse at the SDK boundary with Zod

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 type
const 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
}
Parse at exactly two seams: SDK → app (responses) and form → SDK (writes). Downstream functions receive a real typed value, not unknown.

Keep data access thin

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 resource
export 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
      });
    },
  });
}
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.

Choose the right home for client state

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 need
function 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
}
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.

Separate data from display

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 isolation
function usePipelineRun() {
  const run = useMutation({ mutationFn: runPipeline });
  return { run: run.mutate, isPending: run.isPending, error: run.error };
}

// component is a thin binding to the design system
function 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>
  );
}

No business logic in UI components

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>;
}
Modal open/close, hover, and resize observers are UI state. Keep them in the component.

Compute derived values during rendering

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 needed
const filtered = items.filter((i) => i.active);

// For genuinely expensive transforms, memoize
const sorted = useMemo(() => items.slice().sort(byDate), [items]);
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.

Keep hooks single-purpose

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 needs
const useUserStats = () => {
  // ...
  return { userStats };
};

const useChartData = () => {
  // ...
  return { chartData };
};

function UserStatsComponent() {
  const { userStats } = useUserStats();
  // only re-renders when userStats changes
  return <div>{userStats.total}</div>;
}
Signal to split: multiple components call the same hook but destructure completely different values.

Expose a narrow interface from hooks

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.
function useFormBusinessLogic() {
  const query = useQuery({ queryKey: ["form"], queryFn: fetchFormData });
  const mutation = useMutation({ mutationFn: submitForm });

  // wrap mutations in named command functions
  const addData = (data: DataType) => mutation.mutate(data);

  // derive cheap values inline
  const itemCount = query.data?.length ?? 0;

  // memoize only when profiling shows a real cost
  const sortedItems = useMemo(
    () => query.data?.slice().sort(byDate),
    [query.data]
  );

  return {
    data: query.data,
    isLoading: query.isLoading,
    addData,
    itemCount,
    sortedItems,
  };
}

Make impure dependencies injectable

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}>.
// tasks.tsx
import { useTasks as useTasksImpl } from "../services/use-tasks";
import type { Task } from "../types";

type TasksDeps = {
  useTasks: () => { isLoading: boolean; tasks: Task[] };
};
const defaultDeps: TasksDeps = { useTasks: useTasksImpl };

export const Tasks = ({ deps = defaultDeps }: { deps?: TasksDeps }) => {
  const { isLoading, tasks } = deps.useTasks();
  if (isLoading) return <Spinner />;
  return <ul>{tasks.map((t) => <TaskItem key={t.id} task={t} />)}</ul>;
};

// tasks.spec.tsx — no vi.mock needed
const deps: ComponentProps<typeof Tasks>["deps"] = {
  useTasks: vi.fn(() => ({ isLoading: false, tasks: [{ id: "1", text: "a" }] })),
};
render(<Tasks deps={deps} />);
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.

Use Aura instead of inline styles

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:
  1. Use the component props and variants available in the design system.
  2. Use Tailwind utility classes for additional styling.
  3. If neither option is sufficient, raise a support ticket with the Aura team.
For details on supported customization options, see the Aura customization documentation.
function StatusCard({ status }: { status: "ok" | "error" }) {
  return (
    <Card>
      <Badge variant={status === "ok" ? "success" : "destructive"}>
        {status}
      </Badge>
    </Card>
  );
}
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.

Centralize your data model config

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 identifiers
export 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 } }],
      }),
  });
}

Handle errors in two layers

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 state
const { data, error, isError, refetch } = usePlans();
if (isError) return <ErrorState onRetry={refetch} error={error} />;

// <ErrorState /> is one shared component used everywhere

Quick reference

The following table summarizes the patterns on this page.
Stop doingStart doing
Fetching with useState + useEffect + fetchKeyAll CDF reads and writes behind useQuery/useMutation in a dedicated hook
Chained as casts at every call siteOne Zod schema per view; parse() at both seams; type via z.infer
Interface + class + two context layers for a single list()Typed use* hook with useQuery inline
Prop-drilling shared state beyond 3 levelsZustand for frequently-updating state; Context for stable globals
Components that fetch, compute, and renderView-model hook + thin Aura-built component; useQuery/useMutation only in hooks, never in .tsx files
Hooks that return full query/mutation objectsNarrow return: { data, isLoading, addData }
One hook returning everything for a domainFocused single-purpose hooks per concern
vi.mock on first-party modules to bypass missing seamsTyped DI (Default-Props or Context)
Inline style={{}} objectsAura/Tailwind primitives enforced by aura/no-overriding-styles
View IDs and space names scattered as string literalsCentral config/model.ts
Per-component error strings / red <pre>One <ErrorBoundary> at the shell + shared <ErrorState onRetry />
Last modified on June 16, 2026