Command Palette

Search for a command to run...

API‑First UI: Building React Components that Adapt to Changing Data and Schema

API‑First UI: Building React Components that Adapt to Changing Data and Schema

Design schema‑aware React components that survive API changes: OpenAPI/GraphQL contracts, Zod validation, adapters, TanStack Query, error boundaries, feature flags, and CI schema diffs.

6 min read·Architecture

APIs evolve. UIs often break when field names shift, enums grow, or nullability changes. An API‑first approach flips the dependency: your React components adapt to a contract (OpenAPI/GraphQL/JSON Schema), not to ad‑hoc responses. In this guide you’ll build schema‑aware components that tolerate change—using adapters, validation, and type generation—with performance and a11y in mind.

What you’ll get: patterns for schema validation (Zod), adapters that decouple server changes, TanStack Query caching, feature flags, error boundaries, and CI checks that gate incompatible API changes.


Table of Contents

  1. Contracts over assumptions
  2. Generate types from OpenAPI/GraphQL
  3. Validate at the edge of your app (Zod)
  4. Adapters: the anti‑corruption layer
  5. Schema‑aware components
  6. Handling versioning, flags, and deprecations
  7. Data fetching with TanStack Query
  8. Error boundaries & resilient UX
  9. Performance: caching, pagination, and streaming
  10. Testing & CI: schema diffs and contract tests
  11. Reference folders
  12. FAQ

Contracts over assumptions

Prefer machine‑readable contracts and generate types instead of hand‑rolling interfaces.

  • OpenAPI: REST endpoints + schemas.
  • GraphQL: typed schema, introspection, fragments.
  • JSON Schema: common for event payloads and form builders.

Keep contracts in the same repo or versioned separately with a published package.


Generate types from OpenAPI/GraphQL

# OpenAPI → TS types + clients (example)
pnpm add -D openapi-typescript
pnpm openapi-typescript https://api.example.com/openapi.json -o src/types/openapi.d.ts
 
# GraphQL → codegen
pnpm add -D @graphql-codegen/cli
pnpm graphql-codegen init

Use fragments (GraphQL) or components/schemas (OpenAPI) to avoid repeating shapes.


Validate at the edge of your app (Zod)

Generate types for compile‑time, but validate at runtime near the network boundary.

// src/schemas/user.ts
import { z } from "zod";
 
export const UserSchema = z.object({
  id: z.string(),
  name: z.string(),
  email: z.string().email().nullable().default(null),
  plan: z.enum(["free", "pro", "enterprise"]).default("free"),
});
export type User = z.infer<typeof UserSchema>;
// src/api/getUser.ts
export async function getUser(id: string): Promise<User> {
  const res = await fetch(`/api/users/${id}`, { cache: "no-store" });
  const json = await res.json();
  return UserSchema.parse(json); // throws on mismatch
}

Tip: Tolerate additive changes by ignoring extra fields (.passthrough()), but fail fast on breaking changes.


Adapters: the anti‑corruption layer

Keep server quirks out of your UI by mapping to clean domain models.

// src/adapters/userAdapter.ts
type RawUser = {
  id: string;
  full_name?: string;
  name?: string;
  email?: string | null;
  tier?: string;
};
export function toUser(raw: RawUser): User {
  return {
    id: raw.id,
    name: raw.full_name ?? raw.name ?? "Unknown",
    email: raw.email ?? null,
    plan: ((raw.tier === "paid" ? "pro" : raw.tier) as User["plan"]) ?? "free",
  };
}

Adapters give you one place to cope with renames, enum migrations, or nullability flips.


Schema‑aware components

Design components that accept field descriptors or schema fragments and render accordingly.

// src/components/SmartField.tsx
type FieldDescriptor =
  | { kind: "text"; name: string; label: string; required?: boolean }
  | { kind: "select"; name: string; label: string; options: Array<{ label: string; value: string }> }
  | { kind: "number"; name: string; label: string; min?: number; max?: number };
 
export function SmartField({ field }: { field: FieldDescriptor }) {
  switch (field.kind) {
    case "text":
      return <label className="block">
        <span>{field.label}</span>
        <input name={field.name} required={field.required} className="mt-1 w-full rounded border px-3 py-2" />
      </label>;
    case "number":
      return <label className="block">
        <span>{field.label}</span>
        <input type="number" name={field.name} min={field.min} max={field.max} className="mt-1 w-full rounded border px-3 py-2" />
      </label>;
    case "select":
      return <label className="block">
        <span>{field.label}</span>
        <select name={field.name} className="mt-1 w-full rounded border px-3 py-2">
          {field.options.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}
        </select>
      </label>;
  }
}

Forms can be driven from JSON Schema/Zod with light mapping; unknown fields fall back to a generic renderer.


Handling versioning, flags, and deprecations

  • Version headers: send Accept: application/vnd.example.v2+json or API version in URL.
  • Feature flags: gate new fields/flows; components read flags to enable progressive enhancement.
  • Deprecations: log runtime warnings (dev), document removals, provide codemods where applicable.

Data fetching with TanStack Query

pnpm add @tanstack/react-query zod
// src/api/users.ts
import { useQuery } from "@tanstack/react-query";
import { UserSchema, type User } from "../schemas/user";
 
export function useUser(id: string) {
  return useQuery<User>({
    queryKey: ["user", id],
    queryFn: async () =>
      UserSchema.parse(await (await fetch(`/api/users/${id}`)).json()),
    staleTime: 60_000,
    retry: (failures, error) => failures < 2,
  });
}

Expose stable query keys and tags so parts of the UI can revalidate selectively.


Error boundaries & resilient UX

  • Boundary per route/feature to avoid blank screens.
  • Typed errors from Zod (show “data out of date” vs “network down”).
  • Fallback partials: show cached/past data with a warning when fresh parse fails.
// app/users/[id]/error.tsx (Next.js)
"use client";
export default function Error({
  error,
  reset,
}: {
  error: Error;
  reset: () => void;
}) {
  return (
    <div className="space-y-2">
      <h2 className="text-lg font-semibold">We couldn't show this user</h2>
      <pre className="text-sm opacity-70">{error.message}</pre>
      <button onClick={reset} className="rounded border px-3 py-2">
        Retry
      </button>
    </div>
  );
}

Performance: caching, pagination, and streaming

  • Favor cursor pagination and infinite queries over massive pages.
  • Use select in Query to map/denormalize data before it hits components.
  • Stream slow sections with <Suspense> and loading.tsx.
  • Memoize adapters; avoid re‑parsing unchanged data.

Testing & CI: schema diffs and contract tests

  • Run schema diff on PRs (OpenAPI diff or GraphQL Inspector). Block breaking changes unless versioned.
  • Contract tests: mock API with JSON matching the schema; run Zod parse + component render.
  • Snapshot adapters with representative payloads (old/new versions).

Reference folders

src/
  api/              # fetchers + query hooks
  adapters/         # Raw → Domain
  components/       # schema-aware UI
  schemas/          # zod + generated TS types
  pages|app/        # Next.js routes
tests/
  contracts/        # schema diff + contract tests

FAQ

Do I need both codegen and Zod?
Yes—types for compile‑time, Zod for runtime. Codegen catches mistakes in your code; Zod catches mistakes in API responses.

What if the backend doesn’t publish OpenAPI/GraphQL?
Start with hand‑authored Zod/JSON Schema and add adapters. Push for contracts later.

Isn’t validation slow?
Validate once per response at the boundary, then cache. For huge lists, validate per item type with array refinements.


Next steps

  • Generate types from your API.
  • Add Zod parsing at the fetch boundary.
  • Introduce adapters and a schema‑driven form component.
  • Add schema diffs to CI and ship with confidence.

Read more like this

API‑First UI: Building React Components that Adapt to Changing Data and Schema | Ruixen UI