5 min read
Navigation
Type-safe navigation, deep links that survive a cold start, and screen params that never carry objects they shouldn't.
Principles
Params carry identifiers, not entities
Pass a `userId`, not a user object. Params get serialized into deep links and restored state; an object that was fresh when pushed is stale when restored, and it silently bloats the navigation state.
The param list is the source of truth
Declare one `RootStackParamList` and let TypeScript reject every wrong `navigate` call at compile time. Untyped navigation is the most common source of runtime crashes in a large RN app.
Design deep links for a cold start
A link opened from a killed app has no navigation history. If your screen assumes a previous route existed — for a back button, or for data it expected to be prefetched — it will crash for exactly the users arriving from a marketing campaign.
Keep navigation out of the domain layer
A hook that both fetches data and calls `navigation.navigate` cannot be tested or reused. Return a result; let the screen decide where to go.
Patterns
What goes wrong, what to do instead, and why the difference matters.
Typed params
Avoid
// No param list — every navigate call is a guess
navigation.navigate("ProductDetail", { product });
// In ProductDetail:
const { product } = route.params as any;An `as any` cast at the boundary where the compiler could have helped most. Renaming the route breaks nothing at build time and everything at runtime.
Prefer
export type RootStackParamList = {
Home: undefined;
ProductDetail: { productId: string };
};
declare global {
namespace ReactNavigation {
interface RootParamList extends RootStackParamList {}
}
}
// Wrong route name or wrong params is now a compile error.
navigation.navigate("ProductDetail", { productId: product.id });The global augmentation types every `useNavigation()` call in the app without per-call generics.
Cold-start-safe screens
Avoid
function ProductDetail({ route }) {
const { product } = route.params;
// Deep link into a killed app: params were serialized,
// `product` is a stale snapshot or missing entirely.
return <Text>{product.name}</Text>;
}Works when navigated from the list, crashes when opened from a push notification.
Prefer
function ProductDetail({ route }: Props) {
const { productId } = route.params;
const { data, isPending, error } = useProduct(productId);
if (isPending) return <ProductDetailSkeleton />;
if (error) return <ErrorState onRetry={refetch} />;
return <Text>{data.name}</Text>;
}The screen fetches from an id, so it behaves identically whether it was pushed or cold-started from a link.
Navigation stays in the UI layer
Avoid
export function useSubmitOrder() {
const navigation = useNavigation();
return useMutation({
mutationFn: postOrder,
onSuccess: (order) => navigation.navigate("Receipt", { id: order.id }),
});
}The hook now depends on a navigator. It cannot be unit tested or reused from a different screen.
Prefer
export function useSubmitOrder() {
return useMutation({ mutationFn: postOrder });
}
// In the screen — the only place that knows where to go next:
const { mutate } = useSubmitOrder();
mutate(draft, {
onSuccess: (order) => navigation.navigate("Receipt", { id: order.id }),
});The hook returns a result. The screen owns the routing decision.
Review checklist
What I look for when reviewing a pull request that touches this area.
- A single param list is declared and globally augmented.
- No screen param carries a full entity — ids only.
- Every deep-linkable screen renders correctly from a cold start.
- Deep-linked screens handle their own loading and error states.
- No hook outside the UI layer imports `useNavigation`.
- Back behaviour is verified on Android hardware back, not just the header button.
Take the skill
Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.
.claude/skills/react-native-navigation/SKILL.mdDrop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.
---
name: react-native-navigation
description: >-
Type-safe React Navigation patterns: param list design, deep linking that
survives cold start, and keeping navigation out of domain hooks. Use when
adding screens, routes, or deep links.
argument-hint: "[screen name]"
license: MIT
metadata:
author: John Felix Lim
version: "1.0.0"
---
# Navigation
Apply when adding a screen, changing route params, or wiring a deep link. Treat any screen reachable by URL as if it will always be cold-started — because for some users it always is.
## Rules
1. **Params carry identifiers, not entities** — Pass a `userId`, not a user object. Params get serialized into deep links and restored state; an object that was fresh when pushed is stale when restored, and it silently bloats the navigation state.
2. **The param list is the source of truth** — Declare one `RootStackParamList` and let TypeScript reject every wrong `navigate` call at compile time. Untyped navigation is the most common source of runtime crashes in a large RN app.
3. **Design deep links for a cold start** — A link opened from a killed app has no navigation history. If your screen assumes a previous route existed — for a back button, or for data it expected to be prefetched — it will crash for exactly the users arriving from a marketing campaign.
4. **Keep navigation out of the domain layer** — A hook that both fetches data and calls `navigation.navigate` cannot be tested or reused. Return a result; let the screen decide where to go.
## Patterns
### Typed params
**Avoid** — An `as any` cast at the boundary where the compiler could have helped most. Renaming the route breaks nothing at build time and everything at runtime.
```tsx
// No param list — every navigate call is a guess
navigation.navigate("ProductDetail", { product });
// In ProductDetail:
const { product } = route.params as any;
```
**Prefer** — The global augmentation types every `useNavigation()` call in the app without per-call generics.
```tsx
export type RootStackParamList = {
Home: undefined;
ProductDetail: { productId: string };
};
declare global {
namespace ReactNavigation {
interface RootParamList extends RootStackParamList {}
}
}
// Wrong route name or wrong params is now a compile error.
navigation.navigate("ProductDetail", { productId: product.id });
```
### Cold-start-safe screens
**Avoid** — Works when navigated from the list, crashes when opened from a push notification.
```tsx
function ProductDetail({ route }) {
const { product } = route.params;
// Deep link into a killed app: params were serialized,
// `product` is a stale snapshot or missing entirely.
return <Text>{product.name}</Text>;
}
```
**Prefer** — The screen fetches from an id, so it behaves identically whether it was pushed or cold-started from a link.
```tsx
function ProductDetail({ route }: Props) {
const { productId } = route.params;
const { data, isPending, error } = useProduct(productId);
if (isPending) return <ProductDetailSkeleton />;
if (error) return <ErrorState onRetry={refetch} />;
return <Text>{data.name}</Text>;
}
```
### Navigation stays in the UI layer
**Avoid** — The hook now depends on a navigator. It cannot be unit tested or reused from a different screen.
```ts
export function useSubmitOrder() {
const navigation = useNavigation();
return useMutation({
mutationFn: postOrder,
onSuccess: (order) => navigation.navigate("Receipt", { id: order.id }),
});
}
```
**Prefer** — The hook returns a result. The screen owns the routing decision.
```ts
export function useSubmitOrder() {
return useMutation({ mutationFn: postOrder });
}
// In the screen — the only place that knows where to go next:
const { mutate } = useSubmitOrder();
mutate(draft, {
onSuccess: (order) => navigation.navigate("Receipt", { id: order.id }),
});
```
## Review checklist
- [ ] A single param list is declared and globally augmented.
- [ ] No screen param carries a full entity — ids only.
- [ ] Every deep-linkable screen renders correctly from a cold start.
- [ ] Deep-linked screens handle their own loading and error states.
- [ ] No hook outside the UI layer imports `useNavigation`.
- [ ] Back behaviour is verified on Android hardware back, not just the header button.
---
From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim