Sharing Layout State Across Nested Routes

After reading this you will be able to hand state from a persistent layout to whichever child route is currently rendered, choosing deliberately between context, the URL, and a store — and knowing which of the three each piece of state actually belongs in.

← Back to Nested & Layout Routing

Prerequisites

Core Concept

A layout that survives navigation accumulates useful state: the account it fetched, the workspace the user picked, the collapsed/expanded state of its tree, a search term scoped to the whole area. Children need some of it. But an outlet is deliberately opaque — the layout does not know which child is rendered and cannot pass props to it.

Three mechanisms fill that gap, and choosing wrongly is the source of most of the pain in nested applications.

Context — the layout provides a value; any descendant reads it. This is the right default for state that is genuinely scoped to the area, is not shareable, and should reset when the user leaves. React Router exposes it as useOutletContext, Vue as provide/inject, Angular as a route-scoped service, SvelteKit as setContext in the layout.

The URL — the layout writes it, the children read it back from the router. This is correct for anything a user would bookmark or share: a selected workspace, an active filter, a chosen date range. It costs a URL write and gains reload-survival, shareability and back-button support for free.

A store — module-level state outside the component tree. Correct when the state genuinely outlives the layout, is needed by unrelated parts of the application, or must survive a full navigation away and back. Reaching for it first is the mistake: a store makes every consumer implicitly global, and state that should have died with the layout instead leaks into the next area the user visits.

The test is a pair of questions. Would a colleague need this to see what I see? If yes, it belongs in the URL. Does it still matter after the user leaves this area? If yes, it belongs in a store. Otherwise, context.

Choosing where layout state should live A decision diagram. If the state must be shareable or survive a reload it belongs in the URL. If it must outlive the layout it belongs in a store. Otherwise it belongs in context provided by the layout. layout state a child needs it Would a colleague need it to see this? the shareability test yes no → the URL workspace, filter, date range, selected id → context fetched account, sidebar tree, area-scoped handlers → a store only if it must outlive the layout entirely …and survives leaving the area
Two questions resolve nearly every case: shareable state goes in the URL, area-scoped state in context, and only genuinely global state in a store.

Implementation

Start with context, because it is the case the outlet makes awkward. The layout provides a value alongside the outlet; children read it without the layout knowing they exist.

// TypeScript 5.x — a typed context channel carried through the outlet

interface AreaContext {
  account: { id: string; name: string; plan: "free" | "pro" };
  /** Layout-owned UI state that should NOT survive leaving the area. */
  sidebarExpanded: Set<string>;
  toggleSection(id: string): void;
}

const contextStack: AreaContext[] = [];

/** Called by the layout, wrapping its outlet call. */
export function provideAreaContext<T>(value: AreaContext, render: () => T): T {
  contextStack.push(value);
  try {
    return render();
  } finally {
    contextStack.pop(); // pop on the way out so siblings never see a stale value
  }
}

/** Called by any descendant. Throws rather than returning undefined, so a
    component rendered outside the layout fails loudly instead of silently. */
export function useAreaContext(): AreaContext {
  const value = contextStack.at(-1);
  if (!value) throw new Error("useAreaContext must be used inside the area layout");
  return value;
}

In a real framework the same thing is one line each side:

// React Router 6.22 — the built-in outlet context channel
export function WorkspaceLayout() {
  const account = useLoaderData() as Account;
  const [expanded, setExpanded] = useState<Set<string>>(new Set());

  // The value is memoised so children do not re-render on every layout render.
  const context = useMemo(
    () => ({ account, expanded, toggle: (id: string) => setExpanded(next(id)) }),
    [account, expanded],
  );

  return (
    <div className="workspace">
      <Sidebar expanded={expanded} />
      <Outlet context={context} />
    </div>
  );
}

// Any descendant route:
export function InvoicesPanel() {
  const { account } = useOutletContext<WorkspaceContext>();
  return <h1>Invoices for {account.name}</h1>;
}

For shareable state, the layout writes to the URL and every level reads it back. Nobody passes it down, which means nobody can be out of sync:

// TypeScript 5.x — the layout owns the write, the children own their reads

export function WorkspaceSwitcher() {
  const select = (workspaceId: string) => {
    const params = new URLSearchParams(location.search);
    params.set("ws", workspaceId);
    // replaceState: switching workspace refines the current view rather than
    // being a destination the back button should step through.
    history.replaceState(history.state, "", `${location.pathname}?${params}`);
    notifyRouter();
  };
  return renderSwitcher(select);
}

/** Any component at any depth reads the same value from one source. */
export function useWorkspaceId(): string | null {
  return new URLSearchParams(location.search).get("ws");
}

And the loader-based approach, which sidesteps the question entirely for server-fetched data: give each level its own loader and let the framework cache by route, rather than fetching in the layout and threading the result down.

// TypeScript 5.x — parallel loaders per level beat passing data through context
export const workspaceRoute = {
  path: "/workspace/:id",
  loader: ({ params }: { params: { id: string } }) => fetchAccount(params.id),
  children: [
    {
      path: "invoices",
      // Runs in PARALLEL with the layout's loader, not after it.
      loader: ({ params }: { params: { id: string } }) => fetchInvoices(params.id),
    },
  ],
};
Context flows down the tree; URL state is read independently at every level Two propagation models. Context is provided by the layout and consumed by descendants, so the value can only reach components inside that layout. URL state is written once and read directly by any level, including components outside the layout. Context — scoped to the subtree layout provides child reads grandchild reads dies with the layout — which is usually what you want URL — read independently anywhere ?ws=acme layout panel header survives reload, sharing and back — no threading required Nothing is passed down in the right-hand model, so nothing can drift out of sync.
Context reaches only the layout's descendants; URL state reaches anything that can read the router, including components outside the tree.

Verification

// @playwright/test v1.44
import { test, expect } from "@playwright/test";

test("layout state survives sibling navigation but not leaving the area", async ({ page }) => {
  await page.goto("/workspace/acme/invoices");

  await page.getByRole("button", { name: "Expand Archive" }).click();
  await expect(page.locator("[data-section='archive'][data-expanded='true']")).toBeVisible();

  // Sibling navigation: the layout persists, so the expansion persists.
  await page.getByRole("link", { name: "Members" }).click();
  await expect(page.locator("[data-section='archive'][data-expanded='true']")).toBeVisible();

  // Leaving the area: context state should be gone, not resurrected from a store.
  await page.getByRole("link", { name: "Account" }).click();
  await page.getByRole("link", { name: "Workspace" }).click();
  await expect(page.locator("[data-section='archive'][data-expanded='true']")).toHaveCount(0);
});

test("shareable state round-trips through the URL", async ({ page, context }) => {
  await page.goto("/workspace/acme/invoices?ws=acme");
  const shared = await context.newPage();
  await shared.goto(page.url());              // simulate pasting the link
  await expect(shared.locator("[data-ws='acme']")).toBeVisible();
});

Gotchas

  • Context that is not memoised re-renders every child on every layout render. In a layout that holds a search box, that means a full subtree re-render per keystroke.
  • A store makes area state global by accident. The expanded sidebar of workspace A reappears in workspace B, and nobody can find where it was set.
  • Reading URL state from a stale closure gives yesterday’s value. Read it at call time from the router, not once at mount.
  • Fetching in the layout and passing data down serialises requests. Give each level its own loader so they run in parallel; passing results down is what creates the waterfall.
  • Context cannot cross a portal in some frameworks. A modal rendered outside the layout’s DOM subtree may still be inside its React tree — verify rather than assume.
  • Throwing from the context hook is a feature. Returning undefined for a component used outside the layout turns a clear error into a mysterious blank.
Five pieces of layout state and where each belongs Five examples classified by whether they must survive a reload and whether they should appear in a shared link. Shareable state goes in the URL, session-scoped state in context, and personal drafts in session storage only. Where a piece of layout state should live survives a reload? visible in a shared link? selected workspace yes — put it in the URL yes active filter set yes — put it in the URL yes fetched account record no — context is enough no sidebar expanded nodes no — context is enough no draft message text yes — sessionStorage never; it is personal
The two columns decide the answer: anything shareable goes in the URL, anything personal never does.

FAQ

Why not put everything in a store? Because scope is information. State in a layout’s context is documented as belonging to that area; the same value in a store is documented as belonging to everything, and it will eventually be read from somewhere that has no business knowing it.

Can a child write back to layout state? Yes — provide a callback in the context value. Keep the state itself owned by the layout so there is one writer and one source of truth.

Does the URL approach not clutter the address bar? Only if you put non-shareable things in it. A workspace id or an active filter is exactly what a shared link should carry; a sidebar’s expanded state is not.

What about state that must survive a full reload but is not shareable? sessionStorage, keyed by the area, and rehydrated in the layout’s loader. It is the one case where neither context nor the URL fits.

How does this interact with server components? Loader-per-level is the safest pattern: it works identically whether the level renders on the server or the client, whereas context does not cross the boundary.