07

The Client Renderer & Registries

Case study #2, Alphazed: the client side of the same idea. • The React Native renderer mirrors the backend JSON into models and dispatches every screen through three nested registries — workflow to widget, screen to sections, section to content-bits.

The client is a faithful renderer

Server-driven UI (SDUI) means the server sends the UI as data — typed JSON describing what to draw — and the client is a generic renderer: one program that turns those descriptions into pixels instead of a hand-built screen per page. A registry is the lookup table that maps a type name in the JSON to the component that draws it; a dispatcher is the small piece of code that reads the type, looks it up, and mounts the match. Alphazed is a kids' learning app, and its client is a React Native app (running on web and native from the same code).

The first thing the client does is mirror the server's shapes. A same-origin proxy passes the backend (alqosh) JSON through verbatim, so the client's TypeScript models are near-copies of the server's dump schemas — a WorkflowDef, a ScreenModel, a ContentBit. Because the wire shape and the model shape match, there is no translation layer to drift.

The client owns exactly one thing: how a type looks. It never decides what appears or where the data lives — that stays on the server. Everything below is how that one job is organized.

Three nested registries

A widget here is the component that fills a whole step of a flow (a page); a section is one region inside a screen; a content_bit is one interactive unit inside a section (a question, a mini-game). Alphazed dispatches through three registries, each nested inside the last, and each is just a string-keyed lookup with its own dispatcher.

Reading the levels top to bottom: a workflow is a list of steps, each step names a widget; the workhorse widget multi_section holds a list of sections, each naming a type; a content-bits section holds a list of content_bits, each naming a type. The table below is the whole dispatch surface of the client.

LevelRegistryDispatcherKeys onSize
workflow → step → widgetWidgetRegistryStepHoststep.widgetsmall: onboarding-page, multi_section, arabic-letter
screen → sectionssectionRegistrySectionRenderersection.type~45 types
section → content_bitscontentBitRegistryBitRendererbit.type17 types (`mode: select\game`)

Adding a new kind of surface is small at every level: register one component under a string key, and the server can start sending that key. The registries populate the same way — a plain map from type name to component.

// lib/content-bits/contentBitRegistry.tsx
type BitEntry =
  | { mode: 'select'; Component: React.ComponentType<BitProps> }
  | { mode: 'game';   Component: React.ComponentType<BitProps> };

export const contentBitRegistry: Record<string, BitEntry> = {
  'select-text':  { mode: 'select', Component: SelectTextBit },
  'select-media': { mode: 'select', Component: SelectMediaBit },
  'bubble-pop':   { mode: 'game',   Component: BubblePopBit },
  'click-to-pop': { mode: 'game',   Component: ClickToPopBit },
  // …17 entries total
};
┌─ workflow ──────────────────────────────────────┐
│  step.widget  →  WidgetRegistry   (StepHost)     │
│  ┌─ screen ─────────────────────────────────────┐│
│  │  section.type → sectionRegistry (SectionRen…) ││
│  │  ┌─ section ────────────────────────────────┐ ││
│  │  │  bit.type → contentBitRegistry (BitRen…) │ ││
│  │  └──────────────────────────────────────────┘ ││
│  └───────────────────────────────────────────────┘│
└────────────────────────────────────────────────────┘

The render pipeline: JSON to a widget

Here is the full path a workflow JSON travels from bytes to a live screen. A workflow is a state machine of screens (auth, onboarding, the content spine); the client bundles its JSON per app variant, parses it into a WorkflowDef, then walks it.

parseWorkflowDef validates the structure and produces a WorkflowDef; WorkflowRunner traverses the step graph (start, complete, goBack); a small Zustand store mirrors the current position. StepHost reads the current step, resolves step.widget in the WidgetRegistry, and mounts it. For a multi_section step that widget is MultiSectionWidget, which hands each section to SectionRenderer, which mounts ContentBitsSection, which hands each bit to BitRenderer, which mounts the concrete bit. Each arrow is one registry lookup.

// StepHost.tsx — resolve the step's widget and dispatch its events
const step = useWorkflowStore((s) => s.currentStep);
const Widget = widgetRegistry.resolve(step.widget); // string → component

return (
  <Widget
    key={step.id}          // fresh instance per step (see below)
    step={step}
    props={step.props}
    onEvent={(event) => runner.complete(event)} // event → transitions[event]
  />
);

A second host, ScreenModelHost, funnels into the same section registry for server-built tabs (Home, Progress, Store): it renders a fetched ScreenModel directly rather than driving a graph. Two entry points, one section pipeline.

A fresh widget per step

React's key prop is how it decides whether to reuse a mounted component or throw it away and build a new one: change the key and React remounts from scratch. StepHost sets key={step.id}.

That one line matters. As the runner moves between steps, each step gets a fresh widget instance, so local state — a half-typed field, a partly-selected answer — can never leak from one step into the next. The server describes the flow; the key guarantees each screen starts clean, without any per-step reset code to remember to write.

The content-bit registry: select vs game

The content-bit registry is a discriminated union — every entry carries a mode field of 'select' or 'game', and that tag decides how the host renders its footer. This is the single most load-bearing distinction in the player, because the footer is what tells a child what to do next.

// bitMode is the single source of truth for the host footer
export function bitMode(type: string): 'select' | 'game' | undefined {
  return contentBitRegistry[type]?.mode;
}
  • select bits (a multiple-choice question) show a Check / Continue call-to-action; the host scores the answer and only then advances.
  • game bits (a drag-and-drop, a bubble-pop) are self-completing: the footer is Skip-only, and the widget itself signals when the child is done.

The web client registers 17 bit types. Two are select; the other fifteen are game.

ModeFooterBit types
selectCheck / Continue; is_correct scoredselect-text, select-media
gameSkip-only; self-completingsprite-drag-and-drop, bubble-pop, click-to-pop, word-build, tracing-shape, ct-informational, fragment, lip-sync, pair-matching-text, pair-matching-media, pair-matching-media-flip, puzzle, switching, speak-out-loud, speak-with-overlay

Scoring and branching in useBitMachine

All the run-time logic of a byte — scoring, advancing, branching — lives in one pure hook, useBitMachine, shared by both the content-bits section and the standalone byte screen. A byte is a lesson: an ordered list of content_bits. Keeping the logic in a hook (not scattered in components) means it is testable in isolation and behaves identically everywhere.

  • Scoring. For select, correctness is authoritative from the data: each answer carries is_correct, and a multiple-answer question requires an exact set match. For game, the widget reports its own outcome and the hook counts it as correct only when the status says so.
  • Branching. An answer can point at the next bit to show. The field is next (the backend's next_content_bit_id); when set, the run jumps to that bit, otherwise it falls through to the next bit in order.
interface ContentAnswer {
  id: number;
  is_correct?: boolean;
  next?: number | null; // next_content_bit_id, or null to fall through
}

// useBitMachine chooses the next bit
const chosen = answers.find((a) => a.selected);
const nextIndex = chosen?.next != null
  ? bits.findIndex((b) => b.id === chosen.next) // branch to that bit
  : index + 1;                                   // else next in order

The hook also drives a measurement rail: it emits exactly one outcome per bit (correct, wrong, or skipped), latched once per index, as a clean analytics seam. At the end it derives the score and coins from its own run and shows the results screen — the client computes the tally locally, since there is no results endpoint yet.

Sixty backend types, seventeen web plays

There is a gap worth naming, because it sets up the next day. The backend's ContentBitType enum defines roughly 60 content_bit types — every game and question the authoring tools can produce. The web client's contentBitRegistry registers only 17 of them; the rest have no web implementation.

So a byte authored on the backend can easily contain a bit the web client cannot play. The client must therefore have an answer for unknown types — and so must the server, which ships one backend to many client versions at once. How both sides stay safe when the vocabulary does not line up is the whole subject of Day 08.

┌──────────────────────────────┬────────────┐
│ backend ContentBitType enum  │  ~60 types │
│ web contentBitRegistry       │   17 types │
│ web allowlist (playable)     │  filtered  │
└──────────────────────────────┴────────────┘

Key takeaways

  • The Alphazed client is a generic renderer; a same-origin proxy passes backend JSON verbatim, so client models mirror the server's shapes with no translation layer.
  • Rendering dispatches through three nested registries: WidgetRegistry (via StepHost), sectionRegistry (~45, via SectionRenderer), and contentBitRegistry (17, via BitRenderer).
  • The pipeline is workflow JSON → parseWorkflowDefWorkflowRunnerStepHostMultiSectionWidgetSectionRendererContentBitsSectionBitRenderer → concrete bit — one registry lookup per hop.
  • StepHost mounts each step widget with key={step.id}, so React gives every step a fresh instance and local form state cannot leak between steps.
  • The content-bit registry is a discriminated union on mode: select bits show a Check/Continue CTA and are host-scored; game bits are Skip-only and self-complete.
  • useBitMachine holds all scoring and branching: is_correct is authoritative for select, next (next_content_bit_id) drives answer-branching, and one outcome per bit feeds the analytics rail.
  • The backend defines ~60 content_bit types but the web client plays only 17 — a vocabulary gap that forces the forward-compatibility work of Day 08.

Checklist

  • [ ] I can explain why the client models mirror the server JSON and what the same-origin proxy does.
  • [ ] I can name the three registries, their dispatchers, and what string key each looks up.
  • [ ] I can trace a workflow JSON through the render pipeline from parseWorkflowDef to a concrete bit.
  • [ ] I can explain what key={step.id} guarantees and why it prevents state leaks.
  • [ ] I can state the difference between select and game bit modes and how mode drives the footer.
  • [ ] I can describe how useBitMachine scores answers and how next branches to another bit.
  • [ ] I can explain the ~60-vs-17 type gap and why it motivates forward compatibility.