Overview
A multi-screen flow, such as a sign-up or onboarding sequence, is the kind of thing every app has and almost nobody enjoys changing. For years the shape of that flow lived in code: a chain of "go to the next screen" calls compiled into the binary, where changing the order or inserting a step meant a code change, a review, a build, and a store release. This post walks through a different shape, where the flow is data instead of code.
Context
A flow is a path through screens: show roles, then avatars, then a few questions, then a notifications prompt, then exit. The traditional way to build that is to wire the screens together in the app itself, so the order and the branching are baked into the compiled program.
The approach here inverts that. A flow is a graph: a set of steps (nodes) connected by transitions (edges). That graph is authored as JSON, validated against a shared schema, and handed to the client app at runtime. The client never hardcodes the order. It receives the graph and walks it. In this article, "the backend service" means a Python/Flask API that authors and validates the graph, and "the client app" means a React Native and Expo app that walks it and renders each screen. The two examples below are illustrative, not tied to any specific product.
The flow, visualized
Before any code, it helps to see what a flow actually is: a small number of steps, each one a screen, joined by labeled transitions that say "when this happens, go there." Here is a four-step onboarding flow as a mockup, where each box is one screen the user sees in turn.
The same flow, drawn as a graph, makes the transitions explicit. Each edge is labeled with the event that triggers it, and the flow ends at a terminal node.
A flow, then, is just a graph of steps plus the transitions between them. Everything else in this post is about authoring that graph as data and walking it on the client.
[ Roles ] [ Avatars ] [ Questions ] [ Notifications ] +---------+ +---------+ +-----------+ +--------------+ | pick | -> | choose | -> | answer a | -> | allow push | -> exit | a role | | avatar | | few Qs | | reminders | +---------+ +---------+ +-----------+ +--------------+ complete complete complete complete / skip
Before and after
The single biggest change here is where the flow's shape lives. In the old model it lives in the app's compiled code; in the new model it lives in a JSON document the app downloads. That one move is what turns a release-gated change into a data edit.
BEFORE: navigation lives in the app
app code: push(Roles) -> push(Avatars) -> push(Questions)
reorder or insert a screen
|
edit app code -> review -> build -> store release
|
days to weeks before users see it
AFTER: the flow is JSON the app downloads
workflow JSON: { id, startStepId, steps[] }
reorder or insert a step
|
edit JSON -> validate in CI -> ship config
|
live on next init, no app release
In the after model the app binary stays the same. The client app asks for the flow on startup, receives { id, startStepId, steps[] }, and walks it. Changing the order or inserting a step is an edit to that JSON, checked by schema validation in CI, and it reaches users on their next init request instead of through the app store.
Scenario: insert an extra question for one cohort
Concretely, suppose product wants to add one extra question to onboarding, but only for new users in a specific cohort, to measure whether it hurts completion. The two models handle this very differently.
In the before model, the question screen has to be wired into the navigation code, the change ships to every platform at once, and cohort targeting means yet more conditional code. Realistically that is a one-to-two-week round trip through review, build, and release, with no easy way to limit who sees it.
In the after model, you add one step to the workflow JSON and put a gate flag on it. The client runner walks the graph and skips the gated step when the flag resolves false. Targeting is just the flag's audience rules, the change is instant on next init, and turning the experiment off is flipping the flag.
BEFORE AFTER
------ -----
edit nav code add 1 step to workflow JSON
ship to all platforms put gate.flag on it
~1-2 weeks runner walks + skips by flag
targeting = more code A/B + targeting via flag, instant
A workflow is a graph of steps
Now to the data itself. A workflow is plain JSON with a top-level shape: an id, a startStepId naming where the flow begins, and a flat list of steps. Each step is a node in a directed graph, and its transitions map names the edges out of it.
A normal step looks like this:
{
"id": "roles",
"screen": "roles_list",
"transitions": { "complete": "avatars", "skip": "avatars" },
"type": "screen"
}
The screen field is a logical name the client maps to a widget; it is not a route. The transitions map is the entire control-flow story: it pairs an event the screen can emit with the id of the next step to go to. When the roles_list screen emits complete (or skip), the runner advances to the avatars step. A reserved terminal marker ("END") as a transition target ends the flow instead of naming another step. Because the graph is flat and the edges are named, the whole flow is inspectable and diffable as data.
Walking the graph on the client
The client side is deliberately thin. A small runtime, the runner, holds the current step id and exposes one main operation: given an event, look up transitions[event] and either advance to the named step or terminate the flow when the target is the terminal marker.
function advance(step: Step, event: string): string | null {
const next = step.transitions[event];
if (next === undefined) throw new Error(`no transition for ${event}`);
return next === "END" ? null : next; // null means the flow is done
}
Rendering is just as thin. A host component resolves each step's widget through a registry, mapping the step's screen name to a React component, and keys the rendered widget by the current step id so each step gets fresh state. The widget's onEvent callback feeds straight back into the runner's advance call. Malformed graphs never reach this point: the parser rejects unknown ids, dangling transitions, or unsupported step types at parse time, so a bad edit fails fast rather than rendering a dead end.
A screen that is itself JSON: multi_section
Some steps are not one screen but a small stack of typed sub-screens shown together: a permission prompt, a short form, a confirmation. Rather than minting a new screen name for every combination, a multi_section step embeds a map of typed sections in its config. Each entry declares its own type and content, so the screen's composition is data, not code:
{
"id": "notifications",
"screen": "multi_section",
"transitions": { "complete": "END", "skip": "END" },
"config": {
"sections": {
"permission": {
"type": "notifications_permission",
"title": { "text": "Stay in the loop" },
"subtitle": { "text": "Allow notifications for daily lessons + reminders." }
}
}
},
"type": "screen"
}
The matching widget reads props.sections (a map) plus an optional order, renders each sub-section through a section renderer, and owns the combined form state and validation. It only emits its event when every section is valid:
function MultiSectionWidget({ props, onEvent }: WidgetProps) {
const { sections, order = Object.keys(sections) } = props;
const errors = validateValues(sections, values, order);
const isValid = Object.keys(errors).length === 0;
const ctx = { values, setValue, errors, isValid,
emit: (event) => { if (isValid) onEvent(event, { ...values }); } };
return order.map((key) =>
sections[key]
? <SectionRenderer key={key} sectionKey={key} section={sections[key]} ctx={ctx} />
: null);
}
So there are two levels of JSON-as-flow. The workflow graph decides which screen comes next; the multi_section screen decides, again from JSON, which sub-sections compose it and in what order. Adding a section is a new key in the sections map, not a new screen and not a client release, as long as its type is already a registered section renderer.
A/B testing a whole flow with one flag
Because transitions and gating are data, you can A/B test the shape of the flow itself: a long onboarding arm versus a short one. The extra steps in the long arm carry a gate flag in their config, for example { "gate": { "flag": "long_onboarding_v1" } }. On the client, the runner consults an injected gate resolver, a function that turns a flag name into a boolean. When a step's flag resolves false, the runner auto-skips it and follows the next transition instead. Flip the flag server-side and the same client binary walks the long arm or the short arm, with no release and no resubmission. The experiment lives entirely in flag state plus the workflow JSON.
Design principles at work
This shape is not just convenient; it lines up with a handful of long-standing design principles, each doing real work here.
- Single Responsibility (SRP). Each step widget owns exactly one screen's concern, and the runner's only job is to walk the graph. Neither knows about the other's internals.
- Don't Repeat Yourself (DRY). One runner and one widget registry replace bespoke navigation code written per flow, and a single workflow schema describes every graph.
- Inversion of Control / Dependency Injection (IoC/DI). The runner hardcodes no steps. It is driven entirely by the injected workflow JSON and an injected gate resolver, so behavior is configured from the outside.
- PubSub / event-driven. Steps and sections
emitevents rather than calling navigation directly. The runner subscribes to those events and advances the graph in response. - Model-View-Controller (MVC). The workflow JSON and section maps are the model, the widgets and sections are the view, and the runner plus host act as the controller that connects them.
What product and engineering get
The dividing line is simple: anything expressible as edges, ordering, gates, or copy is a data change validated in CI; the only thing that costs a release is a brand-new interaction.
- Reorder and insert without shipping. Editing transitions or adding a step that reuses a registered screen or section type changes the flow with a JSON edit alone.
- Experiment safely. A/B arms are flag-gated steps; the runner skips gated-off steps automatically, so one flag flip changes the flow for a cohort with no binary change.
- Fail fast, not in production. Schema validation on the backend plus parse-time validation on the client mean malformed flows are rejected at load, never rendered as a broken or dead-end experience.
- One runtime, many flows. Every flow passes through the same runner and registry, so there is a single, tested code path to maintain instead of one navigation stack per flow.