Navigation

Lesson 1 of 5

Navigation Is State, Not Pages

"Show me your flowcharts and conceal your tables, and I shall continue to be mystified." — Fred Brooks, The Mythical Man-Month (1975)

The thing that makes an app feel like an app is not a component React Native ships.

Search the 0.81.4 export list for a navigator and you will find nothing. There is Modal, there is BackHandler, there is Linking — three pieces of raw material — and no stack, no tabs, no drawer, no screen, no header, no back gesture. The single most characteristic behaviour of a mobile application is left entirely to userland.

That absence is the right place to start, because it forces the question this chapter is about. If navigation is not a component, what is it?

The answer, and the reason this Part exists, is that navigation is a data structure. Not a place you are, not a page you loaded, not a history the operating system keeps — a serialisable tree of plain objects that your app owns, changed by pure functions, rendered like any other state. Once you see the tree, deep links, state persistence, the Android back button and every nesting question stop being separate topics and become operations on one value.

The big question

A browser gives you navigation for free: a URL bar, a history stack, a back button, all managed outside your code. React Native gives you none of it, and the ecosystem's answer is to model the whole thing as application state you own. What do you gain by holding the history in your own hands rather than borrowing the platform's — and what becomes your responsibility that was previously nobody's?

The state is a tree, and it is JSON

React Navigation is the library almost everyone uses, and the useful thing about it for learning is that its core is separable. The @react-navigation/routers package has no React in it at all — it is pure functions over plain objects — so it can be run and read on its own, which is what the blocks in this chapter do.

Ask a stack router for its initial state and this is what comes back:

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

Both properties are load-bearing. Because the state is JSON, restoring an app to exactly where the user left it is JSON.parse of something you saved, and a deep link is a function that builds one of these trees rather than a sequence of navigations to replay. And because "where am I" is routes[index], it is a value you can read in a test without rendering anything.

An analogy

A chess position against a record of moves. You can describe a game two ways: the moves that were played, or the arrangement on the board right now. The move list is a history — to know where you are you must replay it, and to get somewhere specific you must find a sequence that arrives there. The position is a complete description that stands on its own; you can write it on a napkin, hand it to someone else, and they can set up the board without knowing a single move that led to it. Navigation state is the position. That is exactly why a deep link works the way it does in this Part: opening a link to a comment on a post does not replay "go to feed, tap post, tap comments" — it constructs the whole stack directly, as a position, and hands it over. A framework that modelled navigation as a history of moves could not do that, which is the difference between a data structure and a log.

React — Try it Yourself
Loading editor…
Preview

Save a deep stack, wander somewhere else, restore, and the whole history comes back — keys, order, focus and all. That is the entire implementation of "put the user back where they were", and it works because the value is a value. A framework that modelled navigation as a sequence of transitions would have to replay them.

A router is (state, action) => state

The state is inert. What changes it is a router, and a router has the shape you would guess if someone told you React Navigation was built by people who liked reducers:

router.getStateForAction(state, action, options)

Given a state and an action, return the next state. No mutation, no side effects, no React. Here is the real stack router driven through a session — I ran these against the package and transcribed the results:

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

That is the whole model. A navigator component subscribes to this state and renders routes[index]; everything else — the header, the animation, the gesture — is presentation layered over a value.

You could have written this

Which means you can. Here is a stack navigator in about forty lines, built from a reducer and the primitives from Part II. It is not react-navigation — there is no animation, no gesture, no accessibility work, no native screen optimisation — but the model underneath is the same one.

React — Try it Yourself
Loading editor…
Preview

The line at the bottom is the point of the whole chapter: the navigation state is right there, printable, and it is the only thing that determines what you see. Tap around and watch the array grow and shrink.

What react-navigation adds on top of this is substantial and worth paying for — platform-correct animations and gestures, native screen containers so popped screens are actually removed from the native tree, accessibility, header configuration, deep linking, state persistence, and the nesting machinery of the next chapters. What it does not add is a different idea.

A little history

Navigation is the part of React Native that has been rewritten the most, and the churn is visible in the archaeology. The framework once shipped a Navigator component in core, then a NavigatorExperimental, then an unrelated NavigationExperimental, and all of them were eventually removed rather than fixed. The community produced several competing libraries in parallel, and the one that consolidated — React Navigation — has itself been through seven major versions, moving from a static configuration object to a dynamic component-and-hook API in which the routed state tree is the single source of truth. The evidence that the semantics are still moving is inside the package I measured: alongside the current NAVIGATE action, StackRouter still handles one called NAVIGATE_DEPRECATED, which implements the older behaviour under a different name so that both can coexist. That is why navigation is the area where old tutorials mislead most, and why reading the router rather than the blog post is worth the ten minutes.

What navigate actually does

The rule most people carry is that navigate goes to a screen — pushing it if it is not there, and going back to it if it is. That was true once. Measured against the current router, it is not.

The NAVIGATE case resolves a target route in a specific order, and only then decides whether to reuse or push:

  1. If the name is not in routeNames, return null and let someone else handle it.
  2. If the screen declares a getId, find the last route with the same name and the same id.
  3. Otherwise, if the currently focused route has that name, reuse it.
  4. Otherwise, if the action carries pop: true, find the last route with that name and pop back to it.
  5. If nothing matched, push a new route.

And when a route is matched without pop, it is not popped back to — it is moved:

routes = state.routes.filter(r => r.key !== route.key);
routes.push({ ...route, path, params });

The matched route is removed from where it was and appended to the end. Nothing below it is discarded.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.
From my notebook

I wrote "navigate goes back to the screen if it is already in the stack" into a draft of this chapter, because that is what I have believed since I first used the library. Then I installed @react-navigation/routers and drove it: from [Home, Feed, Profile], navigate('Feed') gave me [Home, Feed, Profile, Feed]. It pushed. I did not trust two samples, so I read the NAVIGATE case, and the rule turned out to be narrower than the folklore — only the currently focused route is reused by name, and going back to an earlier one is opt-in through getId or pop: true. The confirmation that this is a genuine change rather than my misreading is sitting in the same file: there is a separate NAVIGATE_DEPRECATED action that still implements the older semantics. Something I have half-known for years was wrong for the current major version, and the thing that caught it was twenty lines of Node rather than any amount of re-reading documentation.

null means "not mine"

One detail in every block above deserves promoting, because the rest of this Part depends on it.

A router does not throw when it cannot handle an action. It returns null — for goBack() at the root of a stack, for a navigate to a name it does not know, for any action that is not its business. null is not an error; it is a refusal, and a refusal is how an action moves outward.

When a navigator's router declines, the action is offered to the parent navigator, and then to its parent. A tab navigator containing a stack that cannot go back any further gives the tab navigator a chance to switch tabs instead. The whole tree is a chain of handlers, each of which either produces a new state or says "not mine".

That single convention is what makes nesting work at all, and it is the mechanism the Android back button uses — a hardware press dispatched as goBack() into the innermost navigator, travelling outward until something handles it or the app exits. Both of those get a chapter of their own, and both are this one rule applied.

In the real world

The bug this chapter prevents is a screen that appears twice in the stack, and it is almost always a list row that navigates to a detail screen. The user taps a row, the detail pushes, they tap a related item on it, and another detail pushes — which is correct and desirable. Then a notification handler or a badly placed useEffect calls navigate to the same detail screen while it is already focused, and because rule 3 only reuses the focused route, the behaviour depends on exactly where the user happens to be, so the bug reproduces for some testers and not others. The durable fix is not a guard at the call site; it is getId on the screen, which tells the router that two routes with the same identifier are the same screen and should be reused rather than stacked. The second thing worth doing early is persisting the navigation state, because it is JSON and costs almost nothing — and a bug report that includes the navigation tree is a bug you can reproduce instead of guess at.

Common Mistakes

  1. Looking for a navigator in React Native. Core ships Modal, BackHandler and Linking, and no navigation at all.
  2. Thinking of screens as pages. They are entries in an array; the focused one is routes[index].
  3. Assuming navigate returns to an existing screen. Only the focused route is reused by name; anything else pushes.
  4. Expecting a matched route to be popped back to. Without pop: true it is moved to the top and the stack keeps its depth.
  5. Guarding against duplicates at the call site. getId on the screen is the mechanism the router provides.
  6. Treating null from a router as an error. It means "not mine", and it is how actions reach the parent navigator.
  7. Reading old tutorials for navigate semantics. They changed; NAVIGATE_DEPRECATED in the source is the proof.
  8. Keeping a second copy of "which screen am I on" in your own state. It is already routes[index], and two copies will disagree.
  9. Forgetting the state is serialisable. Persistence, restoration and useful bug reports are a JSON.stringify away.
  10. Confusing a route's key with its name. The name is the screen; the key is this particular visit to it.

Interview Corner

Interview Corner — the same idea at three levels. Answer yours; recognise the one above it.
Warm-up
Does React Native include navigation?
Model answer — No. Core ships Modal, BackHandler and Linking, and nothing that stacks screens, draws a header, or animates a transition. Navigation is a library decision, and in practice that means React Navigation, which is the community standard, or React Native Navigation, which uses native navigation containers instead. The framework has actually shipped navigators before — there was a Navigator in core years ago — and they were removed rather than maintained, which is a deliberate position: navigation is opinionated enough that the framework would rather not have the opinion.
Intermediate
What does React Navigation's state actually look like, and why does the shape matter?
Model answer — It is a plain object with a type saying which router owns it, a routeNames array of the configured screens, a routes array holding the actual history, and an index into that array saying which is focused. Each route has a name and a generated key, so two visits to the same screen are distinguishable. Nested navigators appear as a state on the route that contains them, so the whole app's navigation is one tree. The shape matters because it is entirely JSON: you can persist it and restore the user exactly where they were, you can attach it to a bug report and reproduce a state rather than guess at it, and a deep link becomes a function that constructs a tree rather than a sequence of navigations to replay. And because "where am I" is an array index rather than a platform query, it is readable synchronously and testable without rendering.
Advanced
How does an action reach the right navigator in a nested tree?
Model answer — By being refused outward. A router is a pure function getStateForAction(state, action, options) that returns either a new state or null, and null means "not mine" rather than "error". An action is offered to the innermost navigator first; if its router declines — a goBack at the root of a stack, a navigate to a name it does not have in routeNames — the action is offered to the parent, and so on outward until something handles it or nothing does. That single convention is the whole nesting mechanism: a stack inside a tab that cannot pop any further gives the tab navigator the chance to act instead. It is also how the Android hardware back button works, dispatched as a goBack into the innermost navigator and travelling outward, with the app exiting only if the outermost navigator also declines. Because every step is a pure function, the entire resolution is testable without a renderer.
Company lens — how the same topic gets asked
micro1Practical: sketch a stack navigator's state and show what each action does to it.
GooglePushes on the model: pure routers, refusal as delegation, and why the state being serialisable matters.
MetaIts own history — expect why navigation was removed from core and what the trade was.
AmazonOperational: state persistence, restoring a session, and reproducing a reported navigation bug.

Hands-on

Hands-on

Build the router. Implement getInitialState so a list of screen names produces the real shape, focused on the first; getStateForAction handling NAVIGATE, PUSH, GO_BACK and POP_TO_TOP, returning null — not throwing — for anything it cannot handle; the NAVIGATE resolution in the real order, where only the focused route is reused by name, a matched route without pop is moved to the top rather than popped back to, and pop: true truncates; and focusedRoute, which answers "where am I" from the value alone. The checks below run either way — a stub scores zero, so a passing run means the behaviour is real.

JavaScript — Try it Yourself
Loading editor…
Console
Runs automatically. Edit the code to see it change.

Summary

  • React Native ships no navigation. Core has Modal, BackHandler and Linking; stacks, tabs and headers are all userland.
  • Navigation is a data structure, not a place — a tree of plain objects your app owns and renders like any other state.
  • The state is { type, key, index, routeNames, routes }, and the focused screen is routes[index], an array lookup.
  • It is entirely JSON, which is what makes persistence, restoration, deep links and reproducible bug reports possible.
  • A router is (state, action) => state | null — a pure function, runnable and testable with no React at all.
  • null means "not mine", and that refusal is how an action travels outward to a parent navigator.
  • navigate only reuses the focused route by name; anything else pushes, which contradicts most existing tutorials.
  • A matched route without pop: true is moved to the top, not popped back to, so the stack keeps its depth.
  • getId is the supported way to say two routes are the same screen, and it is better than guarding at the call site.
  • A route's key identifies a visit; its name identifies the screen — which is how the same screen appears twice.

In the next chapter, The Stack, we take the data structure seriously as a user-facing thing: what a push costs when the screen underneath stays mounted, why the back gesture is a platform contract rather than a nicety, and what a header is once you know it is drawn from the same state.

Editorial reflection

The thing I find genuinely elegant here is the refusal. Most systems that route something signal failure by throwing, and a thrown error is a dead end — it interrupts, it needs catching, and it carries no useful notion of "maybe someone else wants this". React Navigation's routers return null instead, and that one choice turns what could have been an error path into the entire composition mechanism. Nesting is not a feature built on top of routers; it is what falls out of routers being allowed to decline. The Android back button is not special handling; it is a goBack dispatched inward and refused outward until something takes it. I keep noticing this pattern in the parts of this volume I admire most — LeafYogaNode in the layout chapter, the event-count guard on text input — where a hard problem is not solved by more machinery but dissolved by choosing a representation in which it does not arise. The other lesson is more chastening. I had a wrong belief about navigate that survived years of daily use, because the behaviour I expected and the behaviour I got agreed in the common case. It took twenty lines of Node to find out, and I had not run them until this week.

Q&A · 0

Enrol to ask questions and join the discussion.

No questions yet — be the first to ask.