A hidden tab screen reached only by push is still a singleton that never remounts
A screen registered as a tab-navigator route (even one hidden from the visible tab bar via an option like href:null, used purely to keep it out of the tab UI) is still a tab-navigator singleton under the hood — the framework keeps exactly one persistent instance mounted for the navigator's lifetime, by design, so that switching tabs preserves scroll position and state. Navigating to it again with new parameters re-focuses that same instance and updates its params in place; it does not unmount and remount. Any state seeded with a lazy initializer that reads navigation params once (a bare useState(param ?? default), or a useState lazy-initializer function) captures only the very first navigation's values and never re-derives them on a later visit, so the screen keeps showing its original content indefinitely while the navigation state itself has already moved on.
Resurfaces when
- hiding a route from a tab bar with a visibility/href option so it can still be reached by pushing to it
- seeding a screen's local state from route params with useState(param ?? default) on a screen that can be navigated to more than once
- a screen's own header or title still shows the previous navigation's value after navigating to it again with different params
- a results screen shows the first search's results again although the data-fetching cache key changed
- building a 'search again' or 'start over' flow that re-navigates to an already-visited screen with new inputs
The lesson’s retrieval contract, weighted three times heavier than its body. A lesson that states when it applies doesn’t need a semantic search to find it.
The lesson
A tab navigator's screens are singletons by design: exactly one instance of each tab route stays mounted for the navigator's lifetime, so switching between tabs preserves whatever state and scroll position each one had. This is desirable and expected for genuine tabs. The trap is that registering a screen as a tab-navigator route to reach it via a "hide from the tab bar" option — because doing so was the easiest way to avoid a routing framework's automatic tab-bar entry for every file in a route group — inherits the singleton behavior too, even though the screen is conceptually being pushed to, not switched to, and the person writing it is reasoning about it as if every arrival were a fresh page load.
Under that mistaken assumption, a screen's own state gets seeded once from whatever navigation
parameters arrived first — a lazy useState initializer, or a bare useState(param ?? default)
— and nothing re-runs that derivation on a later, different navigation to the same screen. The
underlying navigation params object itself typically DOES update live on the reused instance
(the router is working correctly), which is what makes this so easy to miss reading the
navigation-params hook alone: the data going in is right, and the screen's own state
subscribed to it is simply never told to look again.
The failure that taught it
A results screen was reached by submitting a two-field search form, which pushed a navigation to it carrying the submitted values as parameters. The screen seeded its query text, its location text, and a resolved location object from those parameters using plain state initializers. The first search worked perfectly. Opening a detail screen and returning, then submitting a second, different search from the same entry point, produced the exact same results as the first search — not almost the same, not slightly wrong, identical.
Two rounds of diagnosis happened before the real cause surfaced. The first assumed a data-fetching problem — a caching layer serving stale results, or a background geocoding step silently failing and leaving old coordinates in place — and shipped a real, defensible fix for a real, separate defect that turned out not to be what was actually happening. When the reported symptom recurred identically after that fix, rather than continuing to guess, the fastest path to the real cause was two direct questions: exactly which button reached this state, and did the screen's own header — driven straight off its local component state, with no fetch involved — show the new search text or the old one. It still showed the old text. That single fact ruled out every theory involving the network request, the cache, or the fetched data, and pointed straight at "this screen's own state was never updated by the second navigation" — which led directly to how the screen was registered in its router and the singleton behavior that implied.
How to apply it
- When a screen is reached exclusively via programmatic navigation and hidden from any visible navigation chrome via a "hide" option rather than removed from that navigator's registration entirely, check what container it is actually registered under. A screen hidden from a tab bar is still a tab screen; a screen hidden from a bottom nav is still whatever singleton/stack semantics that container gives every screen it holds. The visibility option changes what the user sees, not the underlying mount lifecycle.
- Do not assume a screen remounts on every navigation to it just because the navigation call looks like "pushing a new page" from the call site. Verify against the actual container type the screen is registered under, not against how the navigation call reads.
- For a screen that seeds local state from navigation parameters and CAN be navigated to more than once in a session (a search results screen reached from a repeatable search form is a common shape), pair the initial state seed with an effect that re-derives the same state whenever the specific parameter values change — not only on mount. A pure derivation function shared between the initializer and that effect keeps the two from drifting apart.
- When a bug looks like stale fetched data, check whether the SCREEN'S OWN locally-driven display (anything rendered straight from component state, not from a query result) also shows the old value. If a piece of UI with no fetch involved is stale, the bug is in state lifecycle, not in data fetching or caching — this distinction is answerable by inspection or a single targeted question, and it eliminates an entire class of wrong hypotheses immediately.
- After a first fix for a reported symptom doesn't resolve it and the user reproduces the exact same behavior again, prefer asking one or two sharply-targeted questions that distinguish between remaining hypotheses over continuing to read code and theorize. The two questions here (which entry point, does the state-driven header update) each independently ruled out an entire category of cause in one answer.
Where this claim comes from
- Lesson
A hidden tab screen reached only by push is still a singleton that never remounts
- Distillationmechanism stated
Written from the mechanism, not the incident — which is what lets it transfer to code sharing nothing with the original.
- Scar1 occurrence
One recorded failure — weaker evidence, and ranked accordingly rather than presented as settled.
- Evidencenone recorded
No source records recorded — hand-written and migrated lessons predate the pipeline that captures them.
What happened when it was used
- 43
- retrieved
- 4
- acted on
- 4
- held up
- 0
- did not hold up
Applied 4 times. Outcomes move the ranking both ways, which is what makes this improve rather than just grow.