The runtime bridges the pure flow engine to React. It owns screen state,
applies TransitionResults, runs lifecycle callbacks, and exposes the
useScreenFlow hook that most apps use directly.
useScreenFlowThe primary React integration. Creates an engine, holds the active screen in state, and returns navigation helpers plus derived affordances.
import { useScreenFlow } from "@hbarkallah/react-screen-flow";
function FlowView({ state }: { state: AppState }) {
const flow = useScreenFlow(flowDefinition, state, {
initialScreen: "login", // optional override on mount
autoNavigate: true, // sync screen when state changes
enableTransitionDetails: true
});
const Screen = flow.ScreenComponent;
return (
<main>
{flow.isTransitioning ? <Spinner /> : null}
<Screen />
<button onClick={() => void flow.goNext()} disabled={!flow.canGoNext}>
Next
</button>
</main>
);
}
| Property | Type | Description |
|---|---|---|
screenId |
ScreenId |
Active screen |
ScreenComponent |
ComponentType |
Component for the active screen |
goNext |
() => Promise<void> |
Navigate along the next edge |
goPrev |
() => Promise<void> |
Navigate along the prev edge |
goTo |
(target) => Promise<void> |
Direct navigation |
reset |
() => void |
Clear transition state and return to resolveStart(state) |
canGoNext |
boolean |
From engine.evaluate |
canGoPrev |
boolean |
From engine.evaluate |
nextTarget |
ScreenId \| null |
Resolved next target |
prevTarget |
ScreenId \| null |
Resolved prev target |
loadingStrategy |
LoadingStrategy |
Resolved from definition |
isTransitioning |
boolean |
Async guard in flight |
isBlocking |
boolean |
isTransitioning && loadingStrategy === "block-ui" |
lastBlocked |
string \| null |
Human-readable block message |
transition |
ScreenFlowTransitionDetails \| null |
Structured details when enableTransitionDetails is true |
initialScreenOverrides engine.resolveStart(state) on the first render. Useful for
bootstrapping from a URL segment or deep link before route sync runs.
autoNavigateWhen true, watches the state argument. On change, calls
engine.syncToState(currentScreen, state) and applies the result if the target
differs. Keeps the visible screen aligned with auth, profile, or permission
state without manual goTo calls.
// User logs in → state.user becomes truthy → flow moves to dashboard
useScreenFlow(flow, state, { autoNavigate: true });
enableTransitionDetailsOpt-in structured transition state for production UI, debugging, and analytics.
When disabled, transition is null and the hook keeps a smaller surface area.
const flow = useScreenFlow(definition, state, {
enableTransitionDetails: true,
});
flow.transition?.status; // "idle" | "transitioning" | "blocked" | "error"
flow.transition?.blocked; // full BlockedTransition object
flow.transition?.error; // async failure details
applyTransitionResultThe core runtime function. Takes a TransitionResult from the engine and
mutates React state through injected handlers. Also fires lifecycle callbacks
on the FlowDefinition.
import { applyTransitionResult } from "@hbarkallah/react-screen-flow";
const moved = await applyTransitionResult(
engine.transition("login", { type: "next" }, state),
{
definition: flowDefinition,
state,
currentScreen: "login",
handlers: {
setScreen,
setLastBlocked,
setIsTransitioning,
setTransitionStatus, // optional
setBlockedTransition, // optional
setTransitionError, // optional
},
},
);
// Returns true if navigation succeeded, false if blocked
useScreenFlow calls this internally. Export it when building a custom runtime
(e.g. Zustand store, React Native navigator, or test harness) that still wants
the same callback and loading-strategy behavior.
sequenceDiagram
participant Caller
participant Apply as applyTransitionResult
participant Def as FlowDefinition
participant Handlers
Caller->>Apply: TransitionResult
Apply->>Def: onBeforeTransition
alt success
Apply->>Def: onLeave(from)
Apply->>Handlers: setScreen(to)
Apply->>Def: onEnter(to)
Apply->>Def: onTransitionSuccess
Apply->>Def: onAfterTransition(status: success)
Apply-->>Caller: true
else blocked
Apply->>Handlers: setLastBlocked, optional redirect
Apply->>Def: onBlocked / onTransitionBlocked
Apply->>Def: onAfterTransition(status: blocked | error)
Apply-->>Caller: false
else pending
Apply->>Handlers: setIsTransitioning(true)
Note over Apply: Apply loading strategy
Apply->>Apply: await promise
Apply->>Apply: Re-enter with resolved result
Apply->>Handlers: setIsTransitioning(false)
end
On status: "success":
onBeforeTransition firesonLeave runs for the departing screen (if screen changed)setScreen(target) updates React stateonEnter runs for the arriving screen (if screen changed)onTransitionSuccess and onAfterTransition({ status: "success" }) fireOn status: "blocked":
onBeforeTransition firesblockedBehavior === "redirect" and redirectTo is set, navigates to
redirectTo. Otherwise the screen stays put.previousScreen when the async guard
eventually blocks.setLastBlocked receives message ?? guardReason ?? reasononBlocked and onTransitionBlocked fireerror (thrown/rejected guard), onTransitionError
fires and status becomes "error". Otherwise status is "blocked".onAfterTransition fires with the final statusWhen status: "pending":
onBeforeTransition firessetIsTransitioning(true) and status "transitioning"| Strategy | While pending | On success | On block |
|---|---|---|---|
deferred |
Stay on current screen | Move to target | Stay on current screen |
optimistic |
Move to target immediately | Stay on target | Roll back to previous screen |
block-ui |
Stay on current screen; isBlocking is true |
Move to target | Stay on current screen |
result.promise is awaitedsetIsTransitioning(false) in finallyIf the promise rejection is not caught by the engine (unexpected throw in
finally path), onTransitionError fires and the error is re-thrown.
Configured on FlowDefinition.loadingStrategy. Defaults to "deferred".
import {
resolveFlowLoadingStrategy,
resolveLoadingStrategy,
DEFAULT_LOADING_STRATEGY,
} from "@hbarkallah/react-screen-flow";
resolveFlowLoadingStrategy(definition); // reads definition.loadingStrategy
resolveLoadingStrategy(undefined); // "deferred"
DEFAULT_LOADING_STRATEGY; // "deferred"
The loading strategy only affects async guards. Sync guards resolve
instantly and never set isTransitioning.
All callbacks live on FlowDefinition and are invoked by applyTransitionResult.
| Callback | When |
|---|---|
onBeforeTransition |
Before any result is applied (success, blocked, or pending) |
onTransitionSuccess |
After a successful screen change |
onTransitionBlocked |
After a blocked transition (guard failure) |
onBlocked |
Alias-style hook; also called on blocked transitions |
onTransitionError |
Guard threw or async guard rejected |
onAfterTransition |
Terminal callback with status: "success" \| "blocked" \| "error" |
onEnter / onLeave on individual TransitionRules fire around successful
screen changes only.
const flow: FlowDefinition<State, typeof screens> = {
// ...
blockedBehavior: "redirect",
onBeforeTransition: ({ from, to, state }) => {
console.log(`attempting ${from} → ${to}`);
},
onTransitionSuccess: ({ from, to }) => {
analytics.track("flow_step", { from, to });
},
onTransitionBlocked: (result) => {
toast.error(result.message ?? "Navigation blocked");
},
onAfterTransition: ({ from, to, status }) => {
logger.info({ from, to, status });
},
};
For router-backed flows, keep URLs aligned with the active screen in your app. See the React Router guide for a copy-paste route sync helper.
You can bypass useScreenFlow and wire the engine to any state container:
import { createFlowEngine, applyTransitionResult } from "@hbarkallah/react-screen-flow";
const engine = createFlowEngine(definition);
async function navigate(intent: NavigationIntent) {
const result = engine.transition(currentScreen, intent, appState);
await applyTransitionResult(result, {
definition,
state: appState,
currentScreen,
handlers: {
setScreen: (screen) => store.setState({ screen }),
setLastBlocked: (msg) => store.setState({ lastBlocked: msg }),
setIsTransitioning: (v) => store.setState({ isTransitioning: v }),
},
});
}
Keep the split:
applyTransitionResult is async and can be tested with mock handlers:
import { describe, expect, it, vi } from "vitest";
import { applyTransitionResult } from "@hbarkallah/react-screen-flow";
it("applies a successful transition", async () => {
const setScreen = vi.fn();
const onSuccess = vi.fn();
const ok = await applyTransitionResult(
{ status: "success", screen: "dashboard", from: "login", to: "dashboard" },
{
definition: { ...flow, onTransitionSuccess: onSuccess },
state: appState,
currentScreen: "login",
handlers: {
setScreen,
setLastBlocked: vi.fn(),
setIsTransitioning: vi.fn(),
},
},
);
expect(ok).toBe(true);
expect(setScreen).toHaveBeenCalledWith("dashboard");
expect(onSuccess).toHaveBeenCalled();
});
See src/runtime/applyTransitionResult.test.ts and
src/useScreenFlow.test.ts for full coverage of loading strategies, redirects,
and transition details.