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