react-screen-flow

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.

Route sync is an app integration concern, not part of react-screen-flow. Copy the helper below into your app (or adapt it for TanStack Router, Next.js, etc.).

Route sync helper

import { useEffect, useMemo } from "react";
import type { ScreenIdOf, ScreenMap } from "@hbarkallah/react-screen-flow";

type ScreenFlowPathResolver<State> = string | ((state: State) => string);

function normalizeScreenFlowPath(path: string) {
  if (path === "/") return path;
  return path.replace(/\/+$/, "");
}

function resolveScreenFlowPath<State>(
  resolver: ScreenFlowPathResolver<State>,
  state: State,
) {
  return typeof resolver === "function" ? resolver(state) : resolver;
}

function useScreenFlowRouteSync<State, TScreens extends ScreenMap>({
  screenId,
  state,
  pathname,
  routes,
  navigate,
  enabled = true,
  replace = true,
  navigationState,
  normalizePath = normalizeScreenFlowPath,
}: {
  screenId: ScreenIdOf<TScreens>;
  state: State;
  pathname: string;
  routes: Record<ScreenIdOf<TScreens>, ScreenFlowPathResolver<State>>;
  navigate: (path: string, options?: { replace?: boolean; state?: unknown }) => void;
  enabled?: boolean;
  replace?: boolean;
  navigationState?: unknown;
  normalizePath?: (path: string) => string;
}) {
  const targetPath = useMemo(
    () => resolveScreenFlowPath(routes[screenId], state),
    [routes, screenId, state],
  );

  useEffect(() => {
    if (!enabled) return;

    if (normalizePath(pathname) !== normalizePath(targetPath)) {
      navigate(targetPath, { replace, state: navigationState });
    }
  }, [
    enabled,
    navigate,
    navigationState,
    normalizePath,
    pathname,
    replace,
    targetPath,
  ]);

  return targetPath;
}

Layout

import { Outlet, useLocation, useNavigate } from "react-router";
import { useScreenFlow } from "@hbarkallah/react-screen-flow";

const screens = {
  login: "login",
  dashboard: "dashboard",
  checkout: "checkout",
};

export function FlowLayout({ state }: { state: AppState }) {
  const location = useLocation();
  const navigate = useNavigate();
  const flow = useScreenFlow(onboardingFlow, state, { autoNavigate: true });

  useScreenFlowRouteSync({
    screenId: flow.screenId,
    state,
    pathname: location.pathname,
    routes: {
      login: "/login",
      dashboard: "/",
      checkout: (state) => `/checkout/${state.checkoutId ?? "draft"}`,
    },
    navigate: (path, options) => navigate(path, options),
  });

  return <Outlet />;
}

When normalizePath(pathname) !== normalizePath(targetPath), the helper calls navigate(targetPath). The flow stays the source of truth for which screen is allowed; the router owns the URL bar.

See apps/react-router-example in the repository for a full integration. The demo package in this monorepo also ships a typed version of the helper for the example app.