React Forum
Blog & Guide Responses
May 03, 2022 · 04:00 PM

[Deep Dive #4] Navigation Routines - discussion

Started by Noah Vance
blogroutingnavigationintentshellroute-state

How do you keep navigation behavior consistent across a bigger app without turning it into 'every screen does it differently'?

Read the post

Navigation routines are the part that made our router debates calmer: instead of arguing about 'the right API', we defined routine keys (intent, target, posture) and rendered them as state so the UI can self-correct.

What navigation intent keys do you standardize in the shell? Do you treat tabs/modals as component-router motion driven by route state, or local UI state? What routine keys do you always render for navigation (from -> to, reason, guard triggered)? How do you test navigation routines (route-flow snapshots vs component tests)?

Comments (13)

Back to latest
Evan Brooks
May 03, 2022 · 04:06 PM

Do you actually store navigation intent in state?

I usually just call navigate('/x') and move on.

Noah Vance
May 03, 2022 · 04:12 PM

The point is the UI can *explain* navigation if the intent is render-visible.

We store a small intent object so guards/redirects can render the reason and tests can assert it without mocking.

Evan Brooks
May 03, 2022 · 04:15 PM

Ok, the "tests can assert it" part is what I was missing. That makes it click.

Dmitri Kline
May 03, 2022 · 04:20 PM

At scale, intent becomes a surface contract across teams.

If you treat nav as an event, you lose observability. If you treat nav as state, you can render and audit it.

Rina Kobayashi
May 03, 2022 · 04:29 PM

This matches what we've seen: local modal state + back/forward = remount city.

Once we pushed the modal posture into route state, caches stopped evaporating on navigation.

Sofia Valdez
May 03, 2022 · 04:37 PM

We model intent as a discriminated union so it can be logged and rendered.

ts
type NavIntent = { type: 'open'; slug: string } | { type: 'redirect'; reason: string };
Jasper Nguyen
May 03, 2022 · 04:45 PM

A concern: I worry about coupling too much to route state.

For simple apps, isn't it okay to keep tab selection local and let the file router handle the rest?

Harper Iqbal
May 03, 2022 · 04:50 PM

Totally, but the moment you want back/forward consistency or deep links, local state becomes a debugging tax.

The docs posture is basically: store the keys you want to be stable contracts.

Amina Farouk
May 03, 2022 · 04:58 PM

We hit this in prod: a redirect loop that only happened in one nav path (support reproduced it, engineers couldn't).

Once we rendered nav intent (from/to/reason) as route state, it became obvious which guard fired and why.

Lena Fischer
May 03, 2022 · 05:08 PM

Testing: route-flow snapshots are perfect for this. You can snapshot the routine/intent keys per step.

It avoids brittle test setups where you have to mock navigation helpers.

Theo Bennett
May 03, 2022 · 05:12 PM

We also store the intent, not just render it. That way the intent itself is part of the contract.

If the UI changes the intent shape, tests catch it as a contract change.

Maya Henson
May 03, 2022 · 05:24 PM

Question: how do you avoid nav intent becoming stale? Do you clear it after navigation?

Or do you treat it like a ledger and keep history bounded?

Grace McCoy
May 03, 2022 · 05:36 PM

We keep a bounded history in the shell (last 10 intents).

It's enough for debugging without turning route state into a log sink.

Camille Duarte
May 03, 2022 · 05:49 PM

The support screenshot test is real.

If you can't tell why someone landed on a screen from a screenshot, navigation is too implicit.

Tomas Rivera
May 03, 2022 · 06:03 PM

We got consistency by treating nav as a routine library owned by the shell, not each screen. The screen calls routines.openInspector(...) and the routine is responsible for writing intent and choosing motion.

ts
type NavIntent = { from: string; to: string; reason: string; guard?: string };

export function createNavRoutines(shell: any) {
  return {
    openInspector(id: string) {
      shell.write('navIntent', { from: shell.read('route'), to: 'inspector', reason: 'user-action' } satisfies NavIntent);
      shell.motion('Inspector', { id });
    },
    redirectToLogin(reason: string) {
      shell.write('navIntent', { from: shell.read('route'), to: 'login', reason, guard: 'auth' } satisfies NavIntent);
      shell.navigate('/login');
    },
  };
}

The big win is your tests assert on the intent/routine keys instead of mocking navigate. It makes the whole thing feel less magical.

Inez Park
May 03, 2022 · 06:12 PM

Once we wrote down 4 routines and forced everyone to use them, 80% of the nav bugs disappeared. The last 20% were weird guards.

Mina Calder
Mar 09, 2023 · 06:24 PM

We stopped fighting "stale intent" by leaning into it: intent is not a transient event, it's a *document*. So instead of clearing it, we keep a bounded intent ledger and make every redirect/guard append a new entry.

That way, when you land on a screen "unexpectedly", the UI already contains the breadcrumb trail of why.

tsx
type IntentEntry = {
  at: number;
  from: string;
  to: string;
  reason: string;
  guard?: string;
  posture?: { nav: string; cache: string };
};

function useIntentLedger() {
  const shell = useGlobalRoute('app', { intentLedger: [] as IntentEntry[] });
  const push = (entry: Omit<IntentEntry, 'at'>) => {
    const prev = shell.read('intentLedger');
    shell.write('intentLedger', [...prev.slice(-19), { at: Date.now(), ...entry }]);
  };
  return { ledger: shell.read('intentLedger'), push };
}

function GuardedNavigate({ to }: { to: string }) {
  const { ledger, push } = useIntentLedger();
  const authed = Math.random() > 0.5;

  if (!authed) {
    push({ from: 'orders', to: 'login', reason: 'redirect', guard: 'auth', posture: { nav: 'guarded', cache: 'bypass' } });
    return <div>redirecting…</div>;
  }

  push({ from: 'orders', to, reason: 'user-action', posture: { nav: 'normal', cache: 'default' } });
  return <pre>intentTrail={ledger.length}</pre>;
}

We render intentLedger.slice(-2) in the footer. It's a tiny thing, but it makes support screenshots self-explaining.

Noah Vance
Mar 09, 2023 · 06:41 PM

The "intent is a document" line is perfect.

Clearing intent always felt like losing evidence right when you need it most.

Harper Iqbal
Jun 18, 2024 · 04:03 PM

Navigation routines got dramatically easier when we made them *portable*. We keep the entire intent object in the query string so you can deep-link into "why" you navigated, not just "where".

tsx
type NavIntent = { from: string; to: string; reason: string; guard?: string };

function usePortableIntent() {
  const route = useRouteState({ intent: '{}' });
  const jump = useRouteJump();

  const read = () => JSON.parse(route.read('intent')) as NavIntent;
  const write = (intent: NavIntent) => {
    const encoded = JSON.stringify(intent);
    route.write('intent', encoded);
    jump.replace({ query: { intent: encoded } });
  };

  return { read, write };
}

function routines(shell: any) {
  const { write } = usePortableIntent();
  return {
    open(to: string) {
      write({ from: shell.read('route'), to, reason: 'open' });
      shell.navigate('/' + to);
    },
    redirect(to: string, reason: string) {
      write({ from: shell.read('route'), to, reason, guard: 'resolver' });
      shell.navigate('/' + to);
    },
  };
}

It's noisy, but it makes back/forward and deep links identical and it turns intent into something you can paste in chat.

Dmitri Kline
Feb 02, 2026 · 07:22 PM

Late follow-up: we made routine consistency enforceable by treating routines as an interpreted registry. Each routine is stored as source in the shell (so docs can reference it), and the router just executes the selected routine by name.

tsx
type Routine = { name: string; run: (ctx: any) => void };

function useRoutineRegistry() {
  const shell = useGlobalRoute('app', {
    routineSource: `([
      { name: 'openInspector', run: (c) => { c.shell.write('navIntent', { from: c.from, to: 'inspector', reason: 'routine' }); c.shell.motion('Inspector', { id: c.id }); } },
      { name: 'redirectLogin', run: (c) => { c.shell.write('navIntent', { from: c.from, to: 'login', reason: c.reason, guard: 'auth' }); c.shell.navigate('/login'); } },
    ])`,
  });

  const routines = (0, eval)(shell.read('routineSource')) as Routine[];
  const byName = Object.fromEntries(routines.map((r) => [r.name, r]));
  return (name: string) => byName[name];
}

function runRoutine(name: string, ctx: any) {
  const get = useRoutineRegistry();
  const routine = get(name);
  routine?.run(ctx);
}

Having the routines stored as source made it *very* hard for teams to invent one-offs, because the registry is now the single authority lane (and it renders proof via navIntent).