react-screen-flow

Flow Engine

The flow engine is a pure, framework-agnostic evaluator. It reads a FlowDefinition and app state, then answers three questions:

  1. Where should the user start? (resolveStart)
  2. What navigation is currently allowed? (evaluate)
  3. What happens if we try to move? (transition, syncToState)

The engine never touches React state, the DOM, or side effects. That separation lets you unit-test flow rules without rendering components, and reuse the same logic in Node scripts, Storybook, or custom runtimes.

Module layout

Module Role
createFlowEngine.ts Public factory; wires engine methods together
evaluateTransition.ts evaluateScreen — read-only navigation affordances
evaluateGuard.ts Normalizes guard return values into GuardDecision
resolveTarget.ts Resolves static or state-derived transition targets
isValidScreen.ts Checks a screen ID against the screens map

Creating an engine

import { createFlowEngine } from "@hbarkallah/react-screen-flow";

const engine = createFlowEngine(flowDefinition);

createFlowEngine is memoized inside useScreenFlow, but you can call it directly when you need engine access outside React — for example in tests, server-side validation, or a custom state manager.

FlowEngine API

resolveStart(state)

Returns the screen the user belongs on given current app state. Called on mount and by reset() in the runtime.

If resolveStart returns an ID that is not in screens, the engine falls back to the first key in screens and logs a dev warning (unless devWarnings: false). If the flow has no screens at all, it throws.

const screen = engine.resolveStart({ user: null }); // "login"

evaluate(screen, state)

Returns a ScreenDecision describing what navigation is allowed from the current screen without performing a transition. Use this to drive button disabled states, breadcrumbs, or progress indicators.

const decision = engine.evaluate("dashboard", state);

decision.canGoNext;   // boolean
decision.canGoPrev;   // boolean
decision.nextTarget;  // ScreenId | null
decision.prevTarget;  // ScreenId | null
decision.screen;      // current screen echoed back

evaluate only runs synchronous guard checks. Async guards are treated as allowed during evaluation so the UI stays responsive; the real async check runs when transition is called.

transition(from, intent, state)

Evaluates a navigation intent and returns a TransitionResult. Does not mutate anything — the runtime applies the result.

// Forward along the `next` edge
engine.transition("login", { type: "next" }, state);

// Back along the `prev` edge
engine.transition("dashboard", { type: "prev" }, state);

// Direct jump to a specific screen
engine.transition("login", { type: "goTo", screen: "checkout" }, state);

syncToState(from, state)

Reconciles the current screen with resolveStart(state). Used by autoNavigate when external state changes (auth, profile completion, etc.).

The engine picks the cheapest valid intent:

  1. If the target equals decision.nextTarget, use { type: "next" }.
  2. Else if it equals decision.prevTarget, use { type: "prev" }.
  3. Otherwise use { type: "goTo", screen: target }.

This means state-driven navigation still runs guards. A user on profile cannot jump to dashboard via syncToState if canGoNext blocks it.

const result = engine.syncToState("login", { user: { id: "1" } });
// Runs canGoNext guard, then returns success or blocked
Intent Resolves target from Runs guard
{ type: "next" } transitions[from].next canGoNext
{ type: "prev" } transitions[from].prev canGoPrev
{ type: "goTo", screen } intent screen canGoTo(state, target)

Target resolution accepts a static screen ID or a (state) => screenId function. If the resolved target is undefined/null, the transition is blocked with reason "no-transition".

Transition results

Every transition and syncToState call returns one of three statuses:

success

{
  status: "success";
  screen: "dashboard"; // destination
  from: "login";
  to: "dashboard";
}

blocked

{
  status: "blocked";
  reason: "guard-failed";
  from: "login";
  to: "dashboard";
  message?: "Complete your profile first.";
  code?: "PROFILE_INCOMPLETE";
  redirectTo?: "profile";
  metadata?: { ... };
  error?: unknown; // present for guard-threw / async-guard-rejected
}

pending

Returned when a guard returns a Promise. The runtime awaits result.promise and applies the resolved result.

{
  status: "pending";
  from: "login";
  to: "dashboard";
  promise: Promise<TransitionResult>;
}

Block reasons

Reason When
guard-failed Guard returned false or { allowed: false }
no-transition No next/prev target defined for the direction
invalid-target Resolved target is not in screens
guard-threw Guard function threw synchronously
async-guard-rejected Async guard promise rejected

Guards

Guards are functions on TransitionRule that read app state and decide whether navigation is allowed.

type GuardResult =
  | boolean
  | {
      allowed: boolean;
      reason?: string;
      code?: string;
      message?: string;
      redirectTo?: ScreenId;
      metadata?: Record<string, unknown>;
    };

Guards may return a Promise<GuardResult> for async checks. The engine normalizes booleans and decision objects through normalizeGuardResult.

Sync vs async evaluation

Call site Guard helper Async behavior
evaluate (UI affordances) evaluateGuardSync Treats Promise as allowed
transition (actual move) evaluateGuardAsync Returns pending result
goTo transitions evaluateGuardTo Returns pending result

Missing guards default to { allowed: true }.

canGoTo for direct navigation

goTo transitions use canGoTo(state, target) instead of canGoNext. When a screen has no transition rule at all, goTo still validates the target screen exists but skips directional guards.

Transition rules

Each screen entry in transitions is optional. A missing rule means:

transitions: {
  login: {
    next: "dashboard",
    prev: undefined,
    canGoNext: (state) => Boolean(state.user),
    canGoPrev: () => false,
    canGoTo: (state, target) => target !== "billing" || state.isAdmin,
    onEnter: (state) => analytics.track("entered_login"),
    onLeave: (state) => analytics.track("left_login"),
  },
}

onEnter and onLeave are not called by the engine. The runtime invokes them inside applyTransitionResult after a successful transition.

Testing the engine

The engine is designed for direct unit tests — no React required.

import { describe, expect, it } from "vitest";
import { createFlowEngine } from "@hbarkallah/react-screen-flow";

describe("checkout flow", () => {
  const engine = createFlowEngine(checkoutFlow);

  it("blocks next without payment method", () => {
    const result = engine.transition(
      "billing",
      { type: "next" },
      { paymentMethodReady: false },
    );

    expect(result.status).toBe("blocked");
    if (result.status === "blocked") {
      expect(result.message).toBe("Add a payment method first.");
    }
  });

  it("exposes affordances for the current screen", () => {
    const decision = engine.evaluate("billing", {
      paymentMethodReady: true,
    });

    expect(decision.canGoNext).toBe(true);
    expect(decision.nextTarget).toBe("done");
  });
});

See src/engine/createFlowEngine.test.ts for exhaustive examples including async guards, syncToState, branching targets, and invalid screen handling.

Design principles

  1. Pure evaluation — same inputs always produce the same TransitionResult (pending results resolve deterministically once the promise settles).
  2. App state is external — the engine reads state; it never writes it.
  3. Guards are decisions, not side effects — network calls and mutations belong in your app layer. Guards should read state your app already updated.
  4. Typed screen IDsScreenIdOf<typeof screens> keeps targets compile-time safe when the screens map is the source of truth.