| description | React runtime — useScreenFlow, applyTransitionResult, loading strategies, and lifecycle callbacks. |
|---|
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.
The 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 |
Overrides engine.resolveStart(state) on the first render. Useful for
bootstrapping from a URL segment or deep link before route sync runs.
When 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 });Opt-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 detailsThe 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 blockeduseScreenFlow 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":
onBeforeTransitionfiresonLeaveruns for the departing screen (if screen changed)setScreen(target)updates React stateonEnterruns for the arriving screen (if screen changed)onTransitionSuccessandonAfterTransition({ status: "success" })fire- Blocked/error state is cleared
On status: "blocked":
onBeforeTransitionfires- If
blockedBehavior === "redirect"andredirectTois set, navigates toredirectTo. Otherwise the screen stays put. - For optimistic loading, rolls back to
previousScreenwhen the async guard eventually blocks. setLastBlockedreceivesmessage ?? guardReason ?? reasononBlockedandonTransitionBlockedfire- If the block carried an
error(thrown/rejected guard),onTransitionErrorfires and status becomes"error". Otherwise status is"blocked". onAfterTransitionfires with the final status
When status: "pending":
onBeforeTransitionfiressetIsTransitioning(true)and status"transitioning"- Loading strategy is applied before awaiting the promise:
| 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.promiseis awaited- Resolved result is handled recursively (success or blocked)
setIsTransitioning(false)infinally
If 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:
- Engine — decide what should happen
- Runtime — apply the decision, run callbacks, handle async loading UX
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.