skip to content

In React Navigation 7 bottom tabs, why does a mount useEffect show a stale cart on returning to the tab, and what fixes it?

level: middleimportance: must knowfreq 66%

answer

  1. visited tabs stay mounted
  2. focus runs, blur cleans up
  3. wrap the effect in useCallback
  4. no second dependency argument
  5. not fired on app foreground

basics

~20 s

Visited tabs stay mounted, so a useEffect with empty dependencies runs once and never on return. useFocusEffect runs its callback each time the screen gains focus and its cleanup on blur, so the cart refreshes on every return.

solid answer

~40 s

In React Navigation 7, a bottom tab renders on its first visit and then stays mounted, so switching back to the Cart tab does not re-mount it and a `useEffect(() => load(), [])` never runs again; a cart changed from a product screen shows a stale count. `useFocusEffect` from `@react-navigation/native` runs its callback when the screen is focused, including on first render if it is already focused, and again on every later focus; the function it returns runs on blur and on unmount. The callback must be memoised with `useCallback`, because the hook re-runs whenever the callback's identity changes, and passing a dependency array as a second argument only logs an error. Focus is a navigation event: bringing the app back from the background does not fire it, so that case needs app-state handling.

code

tsx · 31 lines
tsx
import { useCallback, useState } from 'react';
import { useFocusEffect } from '@react-navigation/native';
import { FlatList, Text } from 'react-native';

type CartItem = { id: string; title: string };

declare function fetchCart(): Promise<CartItem[]>;

export function CartScreen() {
  const [items, setItems] = useState<CartItem[]>([]);

  useFocusEffect(
    useCallback(() => {
      let active = true;
      fetchCart().then((next) => {
        if (active) setItems(next);
      });
      return () => {
        active = false;
      };
    }, [])
  );

  return (
    <FlatList
      data={items}
      keyExtractor={(item) => item.id}
      renderItem={({ item }) => <Text>{item.title}</Text>}
    />
  );
}

go deeper

for a junior

Recall that tabs stay mounted after the first visit, so reloading on return needs useFocusEffect with a useCallback-wrapped callback.

for a middle

Explain the hook's rules: run on focus and on mount when focused, cleanup on blur and unmount, and why the callback's identity drives re-runs.

for a senior

Show how you avoid request loops and late responses, combine focus refetching with foreground handling, and keep hidden tabs from holding live subscriptions.

for a principal

Decide where freshness logic lives, in screen focus effects or in a shared data layer with its own refetch policy, so every screen does not reinvent it.

## The stale cart problem In a shop app built with **React Navigation 7** bottom tabs (`Shop`, `Cart`, `Profile`), the `Cart` tab shows the cart contents and its tab icon carries a count through the `tabBarBadge` option. A natural first version loads the cart in an effect: ```tsx useEffect(() => { loadCart(); }, []); ``` It works the first time the user opens the tab. Then the user switches to `Shop`, adds two items, and comes back to `Cart`: the list and the count are unchanged. The reason is the navigator's lifecycle, not the effect: - the JavaScript bottom tab navigator renders a tab on its first visit (`lazy` defaults to `true`); - after that the tab **stays mounted** while the user switches tabs; - an effect with empty dependencies runs after mount and never again, because no remount happens. ## What useFocusEffect does `useFocusEffect` is React Navigation's focus-scoped counterpart to `useEffect`. Reading its implementation gives the exact rules: 1. On mount, and whenever the callback changes, it runs the callback **if the screen is focused at that moment**. 2. It subscribes to the screen's **`focus`** event and runs the callback on each later focus, guarding against a double run on the initial focus. 3. It subscribes to **`blur`** and runs the returned **cleanup** when the screen loses focus. 4. On unmount it runs any pending cleanup and removes both listeners. For the cart, that means the data is reloaded every time the tab becomes visible, and anything started for the visible screen, such as a live price subscription, is stopped when the user leaves the tab. ```tsx import { useCallback, useState } from 'react'; import { useFocusEffect } from '@react-navigation/native'; function useCartCount(fetchCartCount: () => Promise<number>) { const [count, setCount] = useState(0); useFocusEffect( useCallback(() => { let active = true; fetchCartCount().then((n) => { if (active) setCount(n); }); return () => { active = false; }; }, [fetchCartCount]) ); return count; } ``` ## The rules that trip people up - **Memoise the callback.** The hook's internal effect depends on the callback's identity. An inline arrow function is new on every render, so while the screen is focused the cleanup and the callback run after every render, which with a fetch that sets state becomes a loop of requests. `useCallback` with the right dependencies fixes it. - **One argument only.** `useFocusEffect(cb, [deps])` is a mistake; the hook logs an error explaining that dependencies belong in `useCallback`. - **No async callback.** Returning a promise is reported as an error in development; start an async function inside the callback instead, and use the cleanup to ignore a late result. - **Cleanup runs on blur, not only on unmount.** Anything that should keep running while the tab is hidden must not live here. ## Choosing the right tool | Need | Tool | |---|---| | Run once when the screen first appears | `useEffect` with empty dependencies | | Run each time the screen becomes visible, stop when it hides | `useFocusEffect` with `useCallback` | | Render differently while visible | `useIsFocused` | | Low-level control inside an existing effect | `navigation.addListener('focus', cb)`, returning the unsubscribe function | ## Where the badge count should live The badge is drawn by the tab bar from the `Cart` screen's `tabBarBadge` option, and it must be right while the user is on **another** tab, because that is when they look at it. A count held in `CartScreen`'s local state cannot do that: the screen may never have been visited, and it is not focused while the user shops. The usual split is: - the count lives in a shared store or query cache that every tab can read; - the `Cart` screen's options read the count from that shared source; - a focus effect on whichever tab the user returns to triggers a refetch, so the badge corrects itself when cart contents changed on another device. ## What focus does not cover **Focus** is a navigation concept: it changes when navigation actions change which route is focused. It does not fire when the whole app returns from the background, when a system dialog closes or when a push notification is tapped while the screen is already focused. A cart that must be fresh after the app comes back to the foreground needs app-state handling in addition to the focus effect. Likewise, if the cart lives in a shared store or query cache, the badge can be driven by that store, and the focus effect only triggers a refetch.

  • What goes wrong if you pass an inline arrow function to useFocusEffect without useCallback?
    The hook re-runs its internal effect whenever the callback identity changes. An inline function is new on every render, so while the screen is focused each render triggers the cleanup and the callback again. If the callback fetches and sets state, every response causes a render and another fetch, a request loop.
  • The user leaves the app on the Cart tab, adds items on the website, and returns to the app; does useFocusEffect refresh the cart?
    No. The Cart screen never lost navigation focus; the app only went to the background and came back, which fires no focus or blur event. That case needs a separate app foreground listener, or a data layer that refetches on foreground, alongside the focus effect.

saying these in an interview costs you the question

  • useFocusEffect takes a dependency array as its second argument.
  • Switching tabs remounts the tab, so a mount useEffect reloads the cart.
  • useFocusEffect cleanup runs only when the screen unmounts.
  • useFocusEffect fires when the app returns from the background.
  • An inline callback in useFocusEffect is harmless because it runs only on focus.