skip to content

During a React Native Fast Refresh update, why does a useEffect with an empty dependency array run again, and how should it be written?

level: middleimportance: should knowfreq 34%

answer

  1. code changed, not the inputs
  2. dependency lists ignored during refresh
  3. useState and useRef keep values
  4. cleanup runs before the re-run
  5. idempotent effects with real cleanup

basics

~20 s

During Fast Refresh, Hooks with dependencies always update and their dependency lists are ignored, so even a useEffect with an empty array runs its cleanup and then its body again. Effects should clean up fully and be safe to re-run.

solid answer

~40 s

Fast Refresh has to show your edited code, but a dependency array only tracks inputs, so an unchanged `[x]` would hide an edit to `useMemo(() => x * 10, [x])`. So during a Fast Refresh update React treats every Hook with dependencies as changed: `useEffect`, `useMemo` and `useCallback` re-run even with an empty or unchanged array, while `useState` and `useRef` keep their values as long as their arguments and Hook order stay the same. A mount-only effect therefore runs its cleanup and then its body once more. The fix is not to fight it: return a cleanup that removes subscriptions and timers, make the effect idempotent, and trigger one-time side effects such as submitting a step from the user's action, not from a mount effect.

code

tsx · 15 lines
tsx
import { useEffect, useState } from 'react';
import { Keyboard, View } from 'react-native';

export default function StepOne() {
  const [keyboardUp, setKeyboardUp] = useState(false);
  useEffect(() => {
    const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardUp(true));
    const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardUp(false));
    return () => {
      show.remove();
      hide.remove();
    };
  }, []);
  return <View style={{ paddingBottom: keyboardUp ? 0 : 24 }} />;
}

go deeper

for a junior

Recall that saving an edit can run a mount-only useEffect again, while useState values stay.

for a middle

Explain why Fast Refresh ignores dependency lists and which Hooks re-run versus keep their values.

for a senior

Show effects that tolerate re-runs: full cleanup, idempotent setup, and one-time side effects kept out of mount effects.

for a principal

Use dev-loop re-runs as a cheap signal of effect hygiene that also protects production code paths.

## Hooks during an edit When Fast Refresh updates a function component, React re-renders it with the new code. For Hooks this splits into two groups, as the React Native docs describe: | Hook | During a Fast Refresh update | |---|---| | `useState`, `useRef` | keep their previous values, as long as their arguments and the order of Hook calls do not change | | `useEffect`, `useLayoutEffect` | re-run, even if the dependency array did not change | | `useMemo`, `useCallback` | recompute, even if the dependency array did not change | The docs spell out the rule: Hooks **with dependencies** always update during Fast Refresh, and their dependency lists are ignored while it happens. ## Why the dependency list is ignored A dependency array tells React when the **inputs** changed. During Fast Refresh the **code** changed instead. If you edit `useMemo(() => x * 2, [x])` to `useMemo(() => x * 10, [x])`, `x` is the same, so a normal render would reuse the stale result — and your edit would never appear on screen. So Fast Refresh treats every Hook with dependencies as changed. The consequence surprises people: **an effect with an empty dependency array runs again once**. Like any effect re-run, React first runs the previous effect's cleanup, then the effect body. ## What that means for an onboarding form Consider step 1 of a multi-step onboarding flow with a mount-only effect: 1. On first mount, the effect fetches the list of countries for the country picker and subscribes to keyboard events to adjust the layout. 2. You change a label and save. The typed input survives, because `useState` keeps its values. 3. The effect runs again: if the cleanup does not unsubscribe, you now have **two** keyboard listeners; if the fetch has no cancellation, a second request races the first. 4. After a few edits the handler fires several times per keypress — a bug that exists only in the dev loop, but that points at a real weakness. ## Writing effects that survive re-runs The docs' advice is that code resilient to an occasional re-run of `useEffect` is good practice even without Fast Refresh. Concretely: - **Always return a cleanup** that undoes what the effect set up: remove the subscription, clear the timer, ignore a stale response. - **Make the effect idempotent**: running it twice in a row should leave the same result as running it once. - **Do not use a mount-only effect as a one-time guard** for side effects that must never repeat, such as submitting a completed step. Trigger those from the user's action instead. - **Keep derived values in `useMemo` or plain render code**, not in state that an effect writes, so a re-run recomputes instead of stacking updates. ## How this differs from a remount A re-run effect is not a remount: - In a **Fast Refresh update**, state is kept and the effects re-run (cleanup, then setup). - In a **remount** — a class component, changed Hooks, `// @refresh reset`, a render error — the component is torn down and mounted again, so state resets as well. - In a **full reload**, the whole bundle runs again and nothing survives. Knowing which of the three you are in explains most "why did this run twice" questions in the React Native dev loop.

  • Does useMemo also recompute during a React Native Fast Refresh update when its dependencies did not change?
    Yes. Hooks with dependencies, including `useMemo` and `useCallback`, always update during Fast Refresh and their dependency lists are ignored, otherwise an edit to the memoised function would not appear on screen.
  • How is an effect re-run during Fast Refresh different from a remount?
    In a Fast Refresh update the component keeps its `useState` and `useRef` values and only the effects re-run, cleanup first. In a remount, triggered by a class component, changed Hooks, `// @refresh reset` or a render error, the component is torn down and mounted again, so its state resets too.

saying these in an interview costs you the question

  • An empty dependency array guarantees the effect runs only once in development
  • Fast Refresh respects dependency arrays like a normal render
  • useState values are reset every time an effect re-runs
  • Effects re-run without calling the previous cleanup first
  • Duplicate listeners after an edit mean Fast Refresh itself is broken