react-screen-flow

Runtime

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.

useScreenFlow

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

Return value

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

Options

initialScreen

Overrides engine.resolveStart(state) on the first render. Useful for bootstrapping from a URL segment or deep link before route sync runs.

autoNavigate

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

enableTransitionDetails

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 details

applyTransitionResult

The 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.

Application flow

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

Success path

On status: "success":

  1. onBeforeTransition fires
  2. onLeave runs for the departing screen (if screen changed)
  3. setScreen(target) updates React state
  4. onEnter runs for the arriving screen (if screen changed)
  5. onTransitionSuccess and onAfterTransition({ status: "success" }) fire
  6. Blocked/error state is cleared

Blocked path

On status: "blocked":

  1. onBeforeTransition fires
  2. If blockedBehavior === "redirect" and redirectTo is set, navigates to redirectTo. Otherwise the screen stays put.
  3. For optimistic loading, rolls back to previousScreen when the async guard eventually blocks.
  4. setLastBlocked receives message ?? guardReason ?? reason
  5. onBlocked and onTransitionBlocked fire
  6. If the block carried an error (thrown/rejected guard), onTransitionError fires and status becomes "error". Otherwise status is "blocked".
  7. onAfterTransition fires with the final status

Pending path (async guards)

When status: "pending":

  1. onBeforeTransition fires
  2. setIsTransitioning(true) and status "transitioning"
  3. 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
  1. result.promise is awaited
  2. Resolved result is handled recursively (success or blocked)
  3. setIsTransitioning(false) in finally

If the promise rejection is not caught by the engine (unexpected throw in finally path), onTransitionError fires and the error is re-thrown.

Loading strategies

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.

Lifecycle callbacks

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.

Custom runtimes

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:

Testing the runtime

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.