Skip to content

hanios123/react-screen-flow

Repository files navigation

react-screen-flow

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.

Installation

pnpm add @hbarkallah/react-screen-flow

This package expects React to be provided by your app.

Basic Usage

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>
  );
}

Architecture

The library splits flow logic into three layers:

  1. Engine (createFlowEngine) — pure evaluation. Given a FlowDefinition and app state, it resolves the start screen, evaluates guards, and returns TransitionResults. No React, no side effects. Use it in tests, scripts, or custom runtimes.

  2. Controller (createFlowController) — mutable runtime state. Holds the active screen, applies transition results, tracks pending async guards, and exposes next, prev, goTo, and reset. Subscribe with controller.subscribe and read snapshots via controller.getSnapshot().

  3. React hook (useScreenFlow) — bridges the controller to React with useSyncExternalStore. 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.

Core Concepts

Screens

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.

External State

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

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.

Transitions

Each screen can define:

  • next — next screen or a function of state
  • prev — previous screen or a function of state
  • canGoNext — guard for forward navigation
  • canGoPrev — guard for back navigation
  • canGoTo — 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

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.

Example: Branching Signup

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: {},
  },
};

Loading Strategies

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.

Blocked Transitions

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;

Lifecycle Callbacks

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.

React Router

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.

Why Use This?

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.

API Surface

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.

Documentation

Full guides and API reference:

Local development: pnpm docshttp://localhost:5173

Source pages:

Submit the docs URL to Context7 so coding agents can fetch up-to-date API docs.

Contributing

Contributions are welcome — issues, docs fixes, examples, and library improvements.

  1. Fork and clone the repo, then install: pnpm install
  2. Create a branch for your change: git checkout -b fix/my-change
  3. Run checks before opening a PR:
    • pnpm test
    • pnpm build (if you touched the library)
    • pnpm docs:build (if you changed docs under apps/docs)
  4. Open a pull request against main with:
    • A short description of the problem and solution
    • Steps to verify (or note which example app you used: playground, Storybook, react-router-example)
  5. 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).

Local Development

Install dependencies:

pnpm install

Run tests:

pnpm test

Build the library:

pnpm build

Run the playground app:

pnpm dev

Run the React Router example:

pnpm --filter react-router-example dev

Run Storybook examples:

pnpm storybook

Build Storybook:

pnpm build-storybook

Run the documentation site:

pnpm docs

Build and deploy docs to GitHub Pages (public repos, no Actions required):

pnpm docs:deploy

Then 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:preview

Repository Layout

  • packages/react-screen-flow — library (engine, controller, runtime, useScreenFlow)
  • packages/demo — shared onboarding demo (screens, flow, UI, route sync helper)
  • apps/docs — VitePress documentation site
  • apps/storybook — Storybook examples
  • apps/playground — local dev app
  • apps/react-router-example — React Router integration

About

A screen-first workflow engine for React. Define screens, transitions, guards, and loading behaviors in one place.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Packages

 
 
 

Contributors

Languages