The flow engine is a pure, framework-agnostic evaluator. It reads a
FlowDefinition and app state, then answers three questions:
resolveStart)evaluate)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 | 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 |
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 APIresolveStart(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:
decision.nextTarget, use { type: "next" }.decision.prevTarget, use { type: "prev" }.{ 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".
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
}
pendingReturned 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>;
}
| 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 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.
| 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 navigationgoTo 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.
Each screen entry in transitions is optional. A missing rule means:
next / prev navigation is blocked (no-transition)goTo only checks that the target exists in screensdevWarnings: false)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.
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.
TransitionResult
(pending results resolve deterministically once the promise settles).ScreenIdOf<typeof screens> keeps targets
compile-time safe when the screens map is the source of truth.