A small, typed workflow engine for React apps that move through named screens: onboarding, checkout, signup, profile completion, permission gates, and router-backed flows.
react-screen-flow keeps flow rules in one place. Your app owns the real state;
the library decides which screen is active, whether navigation is allowed, and
how async guards feel in the UI.
pnpm add @hbarkallah/react-screen-flowThis package expects React to be provided by your app.
import { useScreenFlow, type FlowDefinition } from "@hbarkallah/react-screen-flow";
type AppState = {
user: { name: string } | null;
profileCompleted: boolean;
};
function LoginScreen() {
return <h2>Login</h2>;
}
function DashboardScreen() {
return <h2>Dashboard</h2>;
}
function CheckoutScreen() {
return <h2>Checkout</h2>;
}
const screens = {
login: LoginScreen,
dashboard: DashboardScreen,
checkout: CheckoutScreen,
};
const flow: FlowDefinition<AppState, typeof screens> = {
screens,
loadingStrategy: "deferred",
resolveStart: (state) => {
if (!state.user) return "login";
if (!state.profileCompleted) return "dashboard";
return "checkout";
},
transitions: {
login: {
next: "dashboard",
canGoNext: (state) => Boolean(state.user),
},
dashboard: {
prev: "login",
next: "checkout",
canGoNext: (state) =>
state.profileCompleted
? true
: {
allowed: false,
message: "Complete your profile first.",
},
},
checkout: {
prev: "dashboard",
},
},
};
export function FlowView({ state }: { state: AppState }) {
const flowState = useScreenFlow(flow, state, {
autoNavigate: true,
});
const ScreenComponent = flowState.ScreenComponent;
return (
<main>
{flowState.isTransitioning ? <p>Checking...</p> : null}
<ScreenComponent />
{flowState.lastBlocked ? <p>{flowState.lastBlocked}</p> : null}
<button onClick={() => void flowState.goPrev()} disabled={!flowState.canGoPrev}>
Back
</button>
<button onClick={() => void flowState.goNext()} disabled={!flowState.canGoNext}>
Next
</button>
</main>
);
}The library splits flow logic into three layers:
-
Engine (
createFlowEngine) — pure evaluation. Given aFlowDefinitionand app state, it resolves the start screen, evaluates guards, and returnsTransitionResults. No React, no side effects. Use it in tests, scripts, or custom runtimes. -
Controller (
createFlowController) — mutable runtime state. Holds the active screen, applies transition results, tracks pending async guards, and exposesnext,prev,goTo, andreset. Subscribe withcontroller.subscribeand read snapshots viacontroller.getSnapshot(). -
React hook (
useScreenFlow) — bridges the controller to React withuseSyncExternalStore. Most apps only need this layer.
import { createFlowController } from "@hbarkallah/react-screen-flow";
const controller = createFlowController({
definition: flow,
state: appState,
autoNavigate: true,
});
const unsubscribe = controller.subscribe(() => {
const snapshot = controller.getSnapshot();
console.log(snapshot.screenId, snapshot.canGoNext);
});
await controller.next();
controller.setState(nextAppState);useScreenFlow creates a controller internally and returns the same navigation
surface plus ScreenComponent for rendering.
Screens are a typed map of screen IDs to React components or string IDs. Keys
become the valid screen IDs for the whole flow, so invalid targets like
"settings" are caught at compile time.
Pass React.lazy components the same way — wrap <ScreenComponent /> in Suspense in
your app.
The library does not own app state. Pass state into the hook:
useScreenFlow(flowDefinition, state);That works with React state, Zustand, Redux, server data, React Router loaders, or any other source.
resolveStart(state) tells the flow where the user belongs based on current
state. Useful for auth and profile flows where users refresh or deep link.
With autoNavigate: true, the hook re-runs this when external state changes.
Each screen can define:
next— next screen or a function of stateprev— previous screen or a function of statecanGoNext— guard for forward navigationcanGoPrev— guard for back navigationcanGoTo— guard for direct navigation
next and prev can be functions, so branching stays in the flow definition
instead of button handlers. All returned screen IDs are checked against the
screens map.
Guards read state your app already provides and return a boolean or a decision object:
canGoNext: (state) => Boolean(state.user);
canGoNext: () => ({
allowed: false,
message: "Session expired.",
redirectTo: "login",
code: "SESSION_EXPIRED",
});Fetch requests, login calls, payment validation, and state updates belong in your app actions, loaders, effects, or store logic — not inside flow declarations.
type SignupState = {
user: { name: string } | null;
selectedPlan: "free" | "pro" | "enterprise" | null;
needsTeamSetup: boolean;
paymentMethodReady: boolean;
};
const signupFlow: FlowDefinition<SignupState, typeof screens> = {
screens,
resolveStart: (state) => {
if (!state.user) return "login";
if (!state.selectedPlan) return "choosePlan";
if (state.needsTeamSetup) return "teamSetup";
if (state.selectedPlan !== "free" && !state.paymentMethodReady) return "billing";
return "done";
},
transitions: {
login: { next: "choosePlan" },
choosePlan: {
next: (state) => {
if (state.needsTeamSetup) return "teamSetup";
if (state.selectedPlan === "free") return "done";
return "billing";
},
canGoNext: (state) =>
state.selectedPlan
? true
: { allowed: false, message: "Choose a plan first." },
},
teamSetup: {
next: (state) => (state.selectedPlan === "free" ? "done" : "billing"),
prev: "choosePlan",
},
billing: {
next: "done",
prev: (state) => (state.needsTeamSetup ? "teamSetup" : "choosePlan"),
canGoNext: (state) =>
state.paymentMethodReady
? true
: { allowed: false, message: "Add a payment method first." },
},
done: {},
},
};Async guards return a pending transition. loadingStrategy controls what the
UI does while the guard resolves.
| Strategy | Behavior | Best For |
|---|---|---|
deferred |
Stay on the current screen until the guard succeeds. | Most forms and validation flows. |
optimistic |
Move to the target immediately, then roll back if blocked. | Fast-feeling flows where failure is rare. |
block-ui |
Stay on the current screen and expose isBlocking. |
Critical checks where the user should wait. |
useScreenFlow exposes isTransitioning, isBlocking, and loadingStrategy.
When a guard blocks navigation, the hook exposes lastBlocked and triggers
callbacks if configured.
const flow: FlowDefinition<State, typeof screens> = {
blockedBehavior: "redirect",
onTransitionBlocked: (result) => {
console.log(result.message);
},
};blockedBehavior: "redirect" uses redirectTo from the guard when present.
Otherwise the user stays on the current screen.
For structured details (codes, metadata, error info), opt in:
const flow = useScreenFlow(definition, state, {
enableTransitionDetails: true,
});
flow.transition?.status; // "idle" | "transitioning" | "blocked" | "error"
flow.transition?.blocked?.code;
flow.transition?.blocked?.message;const flow = {
onBeforeTransition: ({ from, to, state }) => {},
onTransitionSuccess: ({ from, to, state }) => {},
onTransitionBlocked: (blocked) => {},
onTransitionError: (error, context) => {},
onAfterTransition: ({ from, to, state, status }) => {},
};Useful for analytics, logging, or app-level side effects.
Use the flow as the decision layer and let the router own URLs. Screen IDs in the flow can be string route names while React Router lazy-loads route modules.
import { Outlet } from "react-router";
import { useScreenFlow } from "@hbarkallah/react-screen-flow";
export function FlowLayout({ state }: { state: AppState }) {
const flow = useScreenFlow(onboardingFlow, state, { autoNavigate: true });
return <Outlet />;
}Route sync is an app concern — copy the helper from
apps/docs/guide/react-router.md or see apps/react-router-example for a full
integration.
react-screen-flow is smaller than a state machine and more focused than a
router. It fits when you need typed screen flows with guards, async checks, and
loading behavior, without the library owning app state or URL routing.
| Tool | Best At | Where react-screen-flow Fits |
|---|---|---|
| React Router / TanStack Router | URLs, nested layouts, route loaders. | Router for URLs; this library for which screen is allowed. |
| XState / Zag / Robot | Full state machines, events, parallel states. | Screens plus guards, not a full statechart. |
| react-use-wizard / use-wizard | Linear next/back wizard state. | Branching, async guards, redirects, and refresh recovery. |
| Form libraries | Multi-step form validation. | Form state stays in the form; pass readiness into the flow. |
| Redux / Zustand / Jotai | App-wide state. | Store holds facts; flow reads state and returns decisions. |
import {
createFlowEngine,
createFlowController,
useScreenFlow,
applyTransitionResult,
resolveLoadingStrategy,
resolveFlowLoadingStrategy,
} from "@hbarkallah/react-screen-flow";
import type {
FlowDefinition,
FlowEngine,
FlowController,
FlowControllerSnapshot,
LoadingStrategy,
TransitionResult,
GuardDecision,
ScreenIdOf,
} from "@hbarkallah/react-screen-flow";Most React apps should use useScreenFlow. createFlowController is the
runtime layer underneath the hook — use it when you need flow state outside React
or want to wire your own store. createFlowEngine evaluates flows without
mutable screen state. For router-backed flows, add a small route sync helper in
your app — see apps/docs/guide/react-router.md.
For deeper internals, see the documentation section or run
pnpm docs locally.
Full guides and API reference:
- Docs site: https://hanios123.github.io/react-screen-flow/
- AI index (
llms.txt): https://hanios123.github.io/react-screen-flow/llms.txt - Full docs for LLMs: https://hanios123.github.io/react-screen-flow/llms-full.txt
Local development: pnpm docs → http://localhost:5173
Source pages:
Submit the docs URL to Context7 so coding agents can fetch up-to-date API docs.
Contributions are welcome — issues, docs fixes, examples, and library improvements.
- Fork and clone the repo, then install:
pnpm install - Create a branch for your change:
git checkout -b fix/my-change - Run checks before opening a PR:
pnpm testpnpm build(if you touched the library)pnpm docs:build(if you changed docs underapps/docs)
- Open a pull request against
mainwith:- A short description of the problem and solution
- Steps to verify (or note which example app you used: playground, Storybook, react-router-example)
- Keep scope focused — one logical change per PR when possible
Report bugs or ask questions via GitHub Issues.
By contributing, you agree that your submissions are licensed under the MIT
License (see package.json).
Install dependencies:
pnpm installRun tests:
pnpm testBuild the library:
pnpm buildRun the playground app:
pnpm devRun the React Router example:
pnpm --filter react-router-example devRun Storybook examples:
pnpm storybookBuild Storybook:
pnpm build-storybookRun the documentation site:
pnpm docsBuild and deploy docs to GitHub Pages (public repos, no Actions required):
pnpm docs:deployThen in the repo Settings → Pages, set Source to Deploy from a branch, branch gh-pages, folder / (root).
Preview the built site locally:
pnpm docs:build
pnpm docs:previewpackages/react-screen-flow— library (engine,controller,runtime,useScreenFlow)packages/demo— shared onboarding demo (screens, flow, UI, route sync helper)apps/docs— VitePress documentation siteapps/storybook— Storybook examplesapps/playground— local dev appapps/react-router-example— React Router integration