Expo Router double-tap navigation guard
7 Jun 2026
- Symptom — Fast double-tap on a recipe/meal card opens detail twice (RN only; Next.js
<Link>is fine). - Why allowed —
router.pushis imperative and intentional; each call stacks. Built-in dedupe runs after commit; both taps fire before the first screen mounts (pre-commit race). - Legacy tip — React Navigation
key/getId→ Expo’s deprecateddangerouslySingular. Discouraged (iOS freeze risk). Doesn’t fix the race anyway.
Fix
Guard the tap at the call site. Keep push semantics.
src/hooks/use-guarded-router.ts:
import { useCallback, useMemo, useRef } from "react";
import { useFocusEffect, useRouter } from "expo-router";
export function useGuardedRouter() {
const router = useRouter();
const locked = useRef(false);
useFocusEffect(
useCallback(() => {
locked.current = false;
}, []),
);
const push = useCallback<typeof router.push>(
(...args) => {
if (locked.current) return;
locked.current = true;
router.push(...args);
},
[router],
);
return useMemo(() => ({ ...router, push }), [router, push]);
}
Rollout: swap useRouter() → useGuardedRouter() everywhere push is used. Call sites stay identical.
Lock lifecycle: set on first push; reset when the owning screen regains focus (useFocusEffect). No arbitrary debounce window.
Tests: hook unit test (double push → one nav; unlock on focus); component test (double-press → one nav).
Cursor rule
This is generic Expo/RN guidance, so it belongs in your Cursor user settings (or a global rule), not a repo’s .cursor/rules/. alwaysApply: true with the scope stated in the body — no path glob, since no glob can mean “Expo” (Expo and Next both use .ts/.tsx):
---
description: Expo / React Native — guard imperative navigation (router.push) against fast double-tap screen stacking
alwaysApply: true
---
# Navigation double-tap guard (Expo / React Native)
**Scope: Expo Router / React Native code only.** Does **not** apply to Next.js / web — there, prefer `<Link>` / `router.navigate` (the browser + router dedupe navigations). If you are not in an Expo `expo-router` file, ignore this rule.
RN-only issue: fast double-tap on a card/button fires `router.push` twice and stacks duplicate screens. Expo Router `push` is intentionally unguarded; stack dedupe and `dangerouslySingular` lose to the pre-commit race.
## Rule
In Expo Router code, **do not** call `useRouter().push` directly in user-facing event handlers.
Use a drop-in guarded router hook instead:
```ts
import { useGuardedRouter } from "~/hooks/use-guarded-router";
const router = useGuardedRouter();
router.push("/recipes/123"); // guarded; call sites unchanged
```
- **Guard the tap**, not the stack. Keep `push` semantics when stacking is intentional.
- **Non-push methods** (`back`, `replace`, `dismiss`) stay on raw `useRouter()` when no guard is needed.
## When adding navigation
1. Import `useGuardedRouter`.
2. Assign `const router = useGuardedRouter()`.
3. Leave `router.push(...)` call sites as-is.
## Tests
If you add or change guarded navigation, assert double-press / double-tap triggers **one** `push`.