← All posts

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 allowedrouter.push is 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 deprecated dangerouslySingular. 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`.