ALL WRITING
ENGINEERING

A Navbar That Remembers: Layered State Architecture for a Deep, App-Wide Selector

Most state-management advice sorts state into local, server, and global. The hard problems live in the seams. Here's how I architected a persistent, multi-level navbar selector with three cooperating state layers and explicit reconciliation between them.

June 20, 2026 10 min read
webdevreactarchitecturefrontend

Most state-management advice sorts state into neat bins: local, server, global. The hard problems live in the seams between those bins. The clearest example I've built is a multi-level selector that lives permanently in the navbar — pick a workspace → organization → site → zone → category, and every page in the app reflects that choice across refreshes, navigations, and two completely different device form factors. The naive framing is "put the selection in a global store." The real architecture is three cooperating state layers and a set of deliberate reconciliations between them. That distinction is the whole post.

The problem: why this is genuinely hard

It's hierarchical, so the levels are coupled. A zone only exists within a site, which only exists within an organization. Change a parent and the children may become invalid. Leave them selected anyway and you get an impossible combination the data can't satisfy — every page reading it silently queries for nonsense.

It's app-wide, so everything depends on it but nothing owns it. Dozens of pages react; none is its home. It lives above all of them, which rules out the usual "lift state to a common parent" — the common parent is the entire app.

It's persistent, so it outlives the component tree. Refresh and it must still be there. Navigate and it can't reset.

The selectable options are themselves dynamic and access-scoped. The tree is fetched from the server, differs per workspace, and is filtered down to what the user's permissions actually grant. So "what's selectable" is server state, not a constant — and the persisted selection can point at something that no longer exists or was never visible to this user.

And it's responsive in behavior, not just layout — which I'll get to, because it forced a second interaction model rather than a media query.

Put together, the failure modes write themselves: prop-drilling through a dozen layers, pages reading a stale copy and drifting from the navbar, a refresh that resets everyone to defaults, and the subtle bug where level three points at something that doesn't exist under level one.

The architecture: three layers, reconciled at the seams

Rather than one store, the selection lives in three places, each suited to a different job.

Working state (a local hook). During interaction, the in-progress selection lives in component-local state. Each pick cascades within this layer: choosing an organization recomputes the valid sites and resets the site/zone accordingly. This is fast, local, and never leaks half-finished states to the rest of the app.

Durable state (a persisted store). Once a selection settles, the provider syncs it into a global, persisted store. This is the layer the rest of the app subscribes to. It survives reload via persistence, and — crucially — it carries two representations of each level: the human selection (one node, or an "all" sentinel) and the expanded set of concrete descendant IDs that data queries actually filter on. "All sites under Org A" is stored both as the display value and as the explicit list of site IDs the user is permitted to see.

Option state (the server cache). The hierarchy tree, the category list, and the permission-scoped subtree are fetched and cached separately. This is the universe the other two layers are validated against.

The provider is the reconciler between these layers — not the owner of truth. Truth is owned per-concern: working edits by the hook, durable broadcast by the store, the option universe by the server. Sync from working → durable is guarded by a dedupe key (a composite of the selected IDs) so identical states never trigger redundant writes or render loops.

One more split worth naming: the top of the hierarchy is URL-owned. The workspace lives in the route path; changing it pushes a navigation. The middle levels live in the store. So the full selection is reconstructed from URL + store + freshly-fetched options — three sources, one coherent picture.

Persistence and restoration: reconciliation, not rehydration

This is the part most write-ups get wrong, and the part I got wrong the first time. Restoring persisted state is not "read it back and trust it." A persisted ID can outlive the thing it points to, or belong to a tree the user can no longer see.

So restoration is a three-way reconcile, per level, cascading down:

  1. If the persisted selection for this level still exists in the freshly-fetched, access-scoped options — use it.
  2. Otherwise, if there's exactly one valid child — auto-select it (no point asking the user to choose from a set of one).
  3. Otherwise, fall back to the "all" sentinel.

Each level constrains the next: the reconciled organization determines which sites are candidates, which determines zones. The result is that a refresh restores exactly what the user had when it's still valid, gracefully degrades when it isn't, and never resurrects a dangling reference. Persistence is treated as intent; the live option universe is the authority that intent is checked against.

How every page reacts — without prop-drilling

No page receives the selection as a prop. Pages subscribe directly to the durable store, reading only the slice they need. There's no drilling because there's no chain — the store is ambient.

Two mechanisms turn "selection changed" into "page updated":

Route-derived relevance. A "menu type" is computed from the current path, classifying each page by the deepest level it cares about — some pages are workspace-scoped, some site-scoped, some need the full chain. The selector reads this and renders only the relevant depth. The page, by virtue of where it is, declares how much of the hierarchy applies. This is context-aware UI on an axis entirely separate from screen size.

ID-keyed data fetching. Because the store holds the expanded concrete IDs, pages fold those IDs into their query keys. Change the selection → the keys change → the data refetches automatically. State change and data change become the same event, which is exactly what keeps the navbar and the page content from ever disagreeing.

Consistency without drift

Drift is prevented structurally, in a few specific ways. The cascade is owned by the working-state layer, so invalid parent/child combinations are unrepresentable rather than merely discouraged. The working → durable sync is deduplicated by a composite key, so the store never churns on no-op updates. Selections are committed atomically — when one action changes several levels, subscribers see one consistent "before" and one consistent "after," never the half-updated middle. And an orthogonal "lens" that re-bases the whole tree (switching the entire hierarchy to a different rooting) resets all levels in a single transition, so the lens and the selection can never contradict.

There's an honest tradeoff here worth surfacing: parts of this converge by updating state during render (guarded against loops) rather than in effects, to avoid a flash of intermediate UI. It's an advanced pattern that buys flicker-free convergence at the cost of needing careful loop guards — exactly the kind of thing you adopt deliberately, not casually.

The responsive dimension: two interaction models, not one layout

Desktop and mobile are separate component trees, chosen by breakpoint, because the interaction models genuinely differ. Desktop renders inline and applies immediately — there's room to show the full chain, so each pick flows straight through the working layer to the store. Mobile opens a bottom sheet and stages a draft, committing to the store only on Save and discarding on Cancel.

That's not cosmetic: on a phone a user taps through several levels to drill down, and if each tap hit the global store, the whole app would refetch against half-chosen states. Staging and committing once means the app sees a single atomic change. Mobile also collapses the deep chain into a "most specific selection" summary with detail behind a popover, and each tree fetches its own data conditionally on the active breakpoint, so only one pipeline is ever live. The breakpoint isn't choosing a layout — it's choosing an entire interaction-and-data model.

How I'd evolve this

The system works, but the more durable question is what it becomes under scale — deeper hierarchies, more consumers, stricter correctness. Each direction is the same move: take an invariant the architecture currently trusts and make it explicit and provable.

Make the hierarchy's shape data, not code. The levels and their cascade, reconciliation, and auto-select rules are written out per level today. The more general design describes the hierarchy as an ordered chain and derives every level-relative rule from that description. Adding a level becomes a data change, not a hunt through every place that hard-codes "reset site and zone." When a rule is uniform across levels, it should be expressed once as a property of the structure.

Promote reconciliation to a verified contract. Restoration-as-reconciliation is the system's most correctness-critical logic and the easiest to regress silently. The stronger version expresses it as an explicit policy with tests asserting its invariants — that no reconciled selection ever references a node outside the access-scoped universe, that the cascade always terminates in a valid combination. Security- and correctness-sensitive logic should be something you prove, not something you hope still holds after the next refactor.

Unify the layers behind one declared ownership map. Three state homes plus URL is powerful but implicit; the rules for which layer owns what live in developers' heads. Making ownership explicit — this concern is URL-owned, this is durable, this is ephemeral, and here are the sanctioned sync directions — turns an oral tradition into a checkable boundary, so a new consumer can't quietly read or write the wrong layer.

Collapse the two interaction models into one commit policy. Immediate-apply and stage-then-commit are the same operation with the buffer length set to zero or one. Expressing both as a single "draft → commit" abstraction, where desktop auto-commits, makes a third form factor a configuration rather than a rewrite. When changes apply is a policy, not something each platform should reinvent.

Treat the persisted shape as a versioned contract. Persisted state is a serialized snapshot that can outlive the code that wrote it; a restructured hierarchy can rehydrate yesterday's shape into today's reconciler. Versioning the payload and migrating on load turns "stale storage breaks the app after a deploy" from a lurking incident into a defined, testable transition. State that crosses a session boundary deserves the same schema discipline as a database.

The throughline of all of these is the same principle the original design rests on: don't chase a single source of truth — own each concern in the layer that fits it, make the reconciliations between layers explicit and verified, and let correctness be a property of the architecture rather than a discipline asked of every page, every device, and every future level.