DEV Community

Cover image for React Native Hooks in Depth — Examples, Scenarios & Production Patterns
Amit Kumar
Amit Kumar

Posted on

React Native Hooks in Depth — Examples, Scenarios & Production Patterns

Introduction

You open a screen, fetch a list, tap a favorite, rotate the phone, go to background, come back — and suddenly you have a memory leak, a double API call, or a button that feels laggy.

In modern React Native, almost all of that logic lives in hooks.

What are hooks?

Hooks are functions that let function components use state, side effects, refs, context, and memoization — without class components. Same React hooks work in React Native; the difference is where you use them (screens, lists, native modules, AppState, dimensions, safe areas).

Why this matters in React Native

  • Screens mount/unmount more often than web pages (navigation stacks)
  • Lists re-render aggressively (FlatList)
  • Device events (keyboard, AppState, orientation) create real side effects
  • Wrong useEffect / missing cleanup = leaks, duplicate network calls, jank

What this article covers

  1. Mental model & Rules of Hooks
  2. What hook to use and where (decision guide)
  3. Core hooks with scenario-based examples
  4. React Native–specific hooks
  5. Navigation hooks
  6. Custom hooks you will actually reuse
  7. Common pitfalls + checklist

Table of Contents

  1. Mental model
  2. Rules of Hooks
  3. What hook to use and where
  4. useState
  5. useEffect
  6. useLayoutEffect
  7. useRef
  8. useMemo & useCallback
  9. useReducer
  10. useContext
  11. RN platform hooks
  12. Navigation hooks
  13. Custom hooks
  14. Full screen example
  15. Pitfalls
  16. Checklist

Body

1. Mental model: what a hook really does

Hook family Job RN example
State Remember UI data across renders Form input, modal open
Effect Sync with outside world API fetch, AppState, listeners
Ref Mutable value / native node Scroll position, TextInput focus
Context Share data without prop drilling Theme, auth user
Memoization Skip expensive work / stable fns List renderItem, heavy filters
Reducer Complex / multi-step state Checkout wizard, filters

Golden rule

Hooks run top-to-bottom on every render. Effects run after paint. Cleanup runs before next effect or on unmount.

Render vs commit (RN mental model)

  1. Render — your function runs; hooks return current values
  2. Commit — React applies updates to native views
  3. useLayoutEffect — runs after commit, before the user paints
  4. useEffect — runs after paint — safe for network, AppState, logging

2. Rules of Hooks (non-negotiable)

  1. Only call hooks at the top level (not inside if, loops, or nested functions)
  2. Only call hooks from React function components or custom hooks
  3. Custom hooks must start with use
  4. Dependency arrays must be honest — include values you read from the closure
// BAD — conditional hook (order can change between renders)
if (isLoggedIn) {
  useEffect(() => {}, []);
}

// GOOD — condition inside the effect
useEffect(() => {
  if (!isLoggedIn) return;
  // ...
}, [isLoggedIn]);
Enter fullscreen mode Exit fullscreen mode

Strict Mode note (React 18+ / modern RN): In development, React may mount → unmount → remount and run effects twice to surface missing cleanups. If you see double API calls only in dev, check cleanup first — don’t “fix” it by disabling Strict Mode.


3. What hook to use and where

Use this section as a decision map. Pick the situation on the left → use the hook on the right.

3.1 One-glance decision table

You need to… Use Where it usually lives
Show / change UI data (input, toggle, modal, loading flag) useState Screen, component, custom hook
Many related updates / wizard / state machine useReducer Complex screens, form flows
Run something after render (API, listener, timer) useEffect Screens, providers, custom hooks
Fix layout/scroll before user sees a flash useLayoutEffect Lists, measuring views, chat scroll
Keep a value without re-render (timer id, flag) useRef Any component / custom hook
Call imperative native API (.focus(), scroll) useRef + ref={} Inputs, FlatList, video players
Share auth / theme / locale across many screens useContext (+ Provider) App.js / root → read in screens
Cache expensive derived data useMemo Filters, sorted lists, heavy calc
Keep a stable function for list / memo child useCallback FlatList handlers, memoized rows
Reload when screen is focused (tabs) useFocusEffect Tab screens, stack screens that stay mounted
Navigate or read route params useNavigation / useRoute Screens inside a navigator
Pad for notch / home indicator useSafeAreaInsets Custom headers, full-screen modals
React to rotate / width breakpoints useWindowDimensions Responsive layouts, grids
Follow system light/dark useColorScheme Theme tokens, screen backgrounds
Reuse the same logic in 2+ places Custom hook (useX) src/hooks/

3.2 Decision flow (ask in order)

Does the UI need to update when this value changes?
  YES → useState (or useReducer if transitions are complex)
  NO  → Is it a DOM/native node or “latest value” box?
          YES → useRef
          NO  → Is it shared across many screens?
                  YES → useContext (or external store)
                  NO  → Derive it: const x = ... or useMemo if expensive

Is this talking to the outside world (API, AppState, Keyboard)?
  YES → useEffect (cleanup!) 
        Need it on every focus in a tab? → useFocusEffect
        Need zero visual flash? → useLayoutEffect

Is a child / FlatList re-rendering too much because props change identity?
  YES → useCallback for functions, useMemo for objects/arrays
  NO  → don’t add memo hooks yet
Enter fullscreen mode Exit fullscreen mode

3.3 Where each hook belongs in an RN app

Layer Typical hooks Avoid here
Root (App.js) useState/useReducer for bootstrapping, Context Providers Heavy fetch for every screen
Providers (Auth, Theme) useState, useMemo (value), useEffect (hydrate token) Navigation hooks (not inside nav yet)
Navigators Rarely — keep thin Business useEffect fetch
Screens useState, useEffect / useFocusEffect, nav hooks, safe area, dimensions Deep prop drilling — lift to context/hook
List rows / pure UI Prefer props only; maybe useState for local press/animation Fetching, AppState, navigation side effects
src/hooks/ Any composition of the above JSX (hooks return data/fns, not UI)

3.4 Situation → hook (scenario cheat sheet)

Scenario Hook(s) Why
TextInput value useState UI must re-render as user types
Modal open/close useState or useToggle Simple boolean UI
Checkout steps + address + payment useReducer Multiple coordinated transitions
Fetch on first mount only useEffect([]) One-shot sync with server
Fetch every time user opens a tab useFocusEffect Tab screen often stays mounted
Search while typing useState + useDebouncedValue + useEffect Debounce then fetch
Pause video in background useEffect + AppState (or useAppState) Subscribe/cleanup native event
Focus password field useRef Imperative .focus(), no re-render
“Latest props” inside a long-lived listener useRef + useEffect Avoid re-binding listener every render
Filter 5k products useMemo Expensive derive; don’t store duplicate state
FlatList renderItem / onPress useCallback Stable identity → fewer cell updates
Auth user in Profile + Home + Settings useContext / useAuth Avoid prop drilling through navigators
Custom header under notch useSafeAreaInsets Device-correct padding
2 columns phone / 3 tablet useWindowDimensions Responds to rotation
Dark mode colors useColorScheme Follows system appearance
Prevent leave with dirty form useNavigation + useEffect (beforeRemove) Navigation lifecycle
Same fetch logic on 3 screens useFetch / React Query Share logic; Query if you need cache
Cache + retry + focus refetch TanStack Query / SWR Don’t hand-roll this in useEffect
Cancel in-flight request on leave AbortController in effect cleanup Cleaner than isMounted flags

3.5 What not to use (common mis-picks)

Tempting choice Better choice Why
useState for items.length Derive: items.length Don’t sync derived data
useEffect to update state from props Compute during render Avoid extra render loops
useEffect for tab refetch useFocusEffect Mount ≠ focus
useMemo on every variable Use only when measured Noise, little gain
useCallback on every function Use for list/memo children Same reason
useRef for something shown in UI useState Ref changes don’t re-render
Context for high-frequency values (scroll X) useRef / Reanimated shared value Context re-renders all consumers
Fetch inside list row Fetch in screen / query hook N rows = N requests

3.6 Mini map — “I am building X”

Login screen

  • Fields → useState
  • Submit loading/error → useState
  • Focus next input → useRef
  • On success navigate → useNavigation

Feed / Explore

  • Data/loading → useState or useFetch
  • Debounced search → custom useDebouncedValue
  • Refetch on focus → useFocusEffect
  • Grid columns → useWindowDimensions
  • Row press → useCallback + useNavigation

Chat

  • Messages → useState / useReducer
  • Scroll to end without flash → useLayoutEffect + useRef
  • Socket subscribe → useEffect + cleanup
  • App background → useAppState

Profile / Settings

  • User → useAuth (useContext)
  • Local form draft → useState
  • Unsaved guard → useNavigation listener
  • Safe header → useSafeAreaInsets

Root app shell

  • Providers → Auth/Theme with useState + useMemo
  • Bootstrap token → useEffect once
  • Not: per-screen list fetching

4. useState — UI memory

What it is

useState stores a value that survives re-renders and triggers a re-render when updated.

const [value, setValue] = useState(initialValue);
// Lazy init — function runs once on mount (good for expensive setup)
const [value, setValue] = useState(() => expensiveCreate());
Enter fullscreen mode Exit fullscreen mode

Use when: the user (or UI) must see the change.

Don’t use when: the value is derived (items.length) or only needed imperatively (useRef).

Scenario: Favorite toggle on a product card

Problem: User taps heart; icon should flip instantly and stay in sync with server.

import React, {useState} from 'react';
import {Pressable, Text} from 'react-native';

function FavoriteButton({productId, initiallyFavorite}) {
  const [isFavorite, setIsFavorite] = useState(initiallyFavorite);
  const [loading, setLoading] = useState(false);

  const onToggle = async () => {
    if (loading) return;

    const next = !isFavorite;
    setIsFavorite(next); // optimistic
    setLoading(true);

    try {
      await api.setFavorite(productId, next);
    } catch {
      setIsFavorite(!next); // rollback
    } finally {
      setLoading(false);
    }
  };

  return (
    <Pressable onPress={onToggle} disabled={loading}>
      <Text>{isFavorite ? '' : ''}</Text>
    </Pressable>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why this pattern

  • Optimistic update feels native-fast
  • Rollback keeps UI honest when the network fails
  • Functional updates avoid stale state on rapid taps

Functional updates (avoid stale state)

// BAD if multiple updates queue quickly
setCount(count + 1);

// GOOD — always based on latest queued value
setCount(prev => prev + 1);
Enter fullscreen mode Exit fullscreen mode

Scenario: Counter with rapid taps

import React, {useState} from 'react';
import {Pressable, Text, View} from 'react-native';

function CartQty() {
  const [qty, setQty] = useState(1);

  return (
    <View style={{flexDirection: 'row', alignItems: 'center', gap: 12}}>
      <Pressable onPress={() => setQty(q => Math.max(1, q - 1))}>
        <Text>-</Text>
      </Pressable>
      <Text>{qty}</Text>
      <Pressable onPress={() => setQty(q => q + 1)}>
        <Text>+</Text>
      </Pressable>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

5. useEffect — side effects & cleanup

What it is

useEffect runs code after render to sync with something outside React: APIs, timers, event emitters, native listeners.

Dependency patterns

Deps When it runs
[] Mount once (+ cleanup on unmount)
[a, b] Mount + whenever a or b changes
omitted Every render (almost always a bug)

Scenario: Fetch list when screen opens

import React, {useEffect, useState} from 'react';
import {ActivityIndicator, FlatList, Text} from 'react-native';

function ExploreScreen() {
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let alive = true;

    (async () => {
      try {
        setLoading(true);
        const data = await api.getExplore();
        if (alive) setItems(data);
      } catch (e) {
        if (alive) setError(e.message);
      } finally {
        if (alive) setLoading(false);
      }
    })();

    return () => {
      alive = false; // prevent setState after unmount / navigation away
    };
  }, []);

  if (loading) return <ActivityIndicator />;
  if (error) return <Text>{error}</Text>;

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

Why alive (or AbortController)?

User can leave the screen before the request finishes. Updating state after unmount wastes work and hides missing cleanups (especially under Strict Mode). Prefer aborting the request when you can:

useEffect(() => {
  const controller = new AbortController();

  (async () => {
    try {
      const data = await api.getExplore({signal: controller.signal});
      setItems(data);
    } catch (e) {
      if (e.name === 'AbortError') return;
      setError(e.message);
    }
  })();

  return () => controller.abort();
}, []);
Enter fullscreen mode Exit fullscreen mode

Scenario: Pause video when app goes to background

import {useEffect} from 'react';
import {AppState} from 'react-native';

function usePauseOnBackground(videoRef) {
  useEffect(() => {
    const sub = AppState.addEventListener('change', state => {
      if (state !== 'active') {
        videoRef.current?.pause?.();
      }
    });

    return () => sub.remove();
  }, [videoRef]);
}
Enter fullscreen mode Exit fullscreen mode

Cleanup is mandatory for:

  • AppState / Keyboard listeners
  • setInterval / setTimeout
  • WebSocket / EventEmitter subscriptions
  • Navigation listeners

Prefer useWindowDimensions over manual Dimensions.addEventListener for size changes.

Scenario: Debounced search

import React, {useEffect, useState} from 'react';
import {FlatList, Text, TextInput} from 'react-native';

function useDebouncedValue(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);

  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);

  return debounced;
}

function SearchScreen() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const debounced = useDebouncedValue(query);

  useEffect(() => {
    if (!debounced.trim()) {
      setResults([]);
      return;
    }

    let alive = true;
    api.search(debounced).then(data => {
      if (alive) setResults(data);
    });

    return () => {
      alive = false;
    };
  }, [debounced]);

  return (
    <>
      <TextInput value={query} onChangeText={setQuery} placeholder="Search" />
      <FlatList
        data={results}
        keyExtractor={item => String(item.id)}
        renderItem={({item}) => <Text>{item.title}</Text>}
      />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

useEffect vs data libraries

Approach Best for
useEffect + useState Simple one-off fetch, learning, tiny screens
React Query / TanStack Query / SWR Cache, retry, focus refetch, deduping, pagination
Custom useFetch Shared loading/error shape across a few screens

Rule of thumb: If you are inventing cache keys, stale-while-revalidate, or focus refetch by hand — use a query library.


6. useLayoutEffect — before paint (use carefully)

Runs synchronously after DOM/native updates, before paint. In RN, use it when you must measure/layout before the user sees a flash.

Scenario: Scroll to a message without flicker

import {useLayoutEffect, useRef} from 'react';
import {FlatList} from 'react-native';

function Chat({messages}) {
  const listRef = useRef(null);

  useLayoutEffect(() => {
    if (messages.length === 0) return;
    listRef.current?.scrollToEnd({animated: false});
  }, [messages.length]);

  return <FlatList ref={listRef} data={messages} /* ... */ />;
}
Enter fullscreen mode Exit fullscreen mode

Prefer useEffect for network/AppState. Prefer useLayoutEffect for measurement / scroll position that would otherwise flash.


7. useRef — mutable box & native handles

What it is

useRef holds a mutable .current that does not trigger re-render when changed.

Scenario A: Focus the next input

import React, {useRef} from 'react';
import {TextInput} from 'react-native';

function LoginForm() {
  const passwordRef = useRef(null);

  return (
    <>
      <TextInput
        returnKeyType="next"
        blurOnSubmit={false}
        onSubmitEditing={() => passwordRef.current?.focus()}
      />
      <TextInput ref={passwordRef} secureTextEntry />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Scenario B: Store latest callback without re-subscribing

import {useCallback, useLayoutEffect, useRef} from 'react';

function useEvent(handler) {
  const ref = useRef(handler);
  useLayoutEffect(() => {
    ref.current = handler;
  });
  return useCallback((...args) => ref.current(...args), []);
}
Enter fullscreen mode Exit fullscreen mode

Useful when an effect should always see the latest props/state but you don’t want to re-bind listeners every render. (Same idea as React’s experimental useEffectEvent.)

Scenario C: Ignore first render (skip initial effect)

import {useEffect, useRef} from 'react';

function useDidUpdate(effect, deps) {
  const mounted = useRef(false);

  useEffect(() => {
    if (!mounted.current) {
      mounted.current = true;
      return;
    }
    return effect();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, deps);
}
Enter fullscreen mode Exit fullscreen mode

Use sparingly — usually you want the first run too, or you can gate with if (!ready) return inside a normal effect.


8. useMemo & useCallback — performance tools (not defaults)

What they are

  • useMemo(() => value, deps) — cache an expensive computed value
  • useCallback(fn, deps) — cache a function identity

Do not wrap everything. Use when:

  • Computation is heavy, or
  • A child / FlatList depends on referential equality

Scenario: Filter a large list

function FavoritesScreen({products, query}) {
  const filtered = useMemo(() => {
    const q = query.trim().toLowerCase();
    return products.filter(p => p.title.toLowerCase().includes(q));
  }, [products, query]);

  return <FlatList data={filtered} /* ... */ />;
}
Enter fullscreen mode Exit fullscreen mode

Scenario: Stable renderItem / handlers for list rows

useCallback alone is not enough if the row still receives a new inline function every time. Pair with React.memo and pass a stable handler + id.

import React, {memo, useCallback} from 'react';
import {FlatList, Pressable, Text} from 'react-native';

const ProductRow = memo(function ProductRow({item, onOpen}) {
  return (
    <Pressable onPress={() => onOpen(item.id)}>
      <Text>{item.title}</Text>
    </Pressable>
  );
});

function ProductList({products, onOpen}) {
  const renderItem = useCallback(
    ({item}) => <ProductRow item={item} onOpen={onOpen} />,
    [onOpen],
  );

  const keyExtractor = useCallback(item => String(item.id), []);

  return (
    <FlatList
      data={products}
      renderItem={renderItem}
      keyExtractor={keyExtractor}
      initialNumToRender={10}
      windowSize={7}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

Interview line

useCallback does not make the function faster — it keeps the same function reference so React.memo children / list cells can skip re-renders. Without memo, useCallback often changes nothing.


9. useReducer — when state has multiple transitions

Scenario: Checkout / form wizard

const initial = {step: 0, address: null, payment: null, submitting: false};

function reducer(state, action) {
  switch (action.type) {
    case 'NEXT':
      return {...state, step: state.step + 1};
    case 'SET_ADDRESS':
      return {...state, address: action.payload};
    case 'SET_PAYMENT':
      return {...state, payment: action.payload};
    case 'SUBMIT_START':
      return {...state, submitting: true};
    case 'SUBMIT_DONE':
      return {...state, submitting: false};
    default:
      return state;
  }
}

function CheckoutScreen() {
  const [state, dispatch] = useReducer(reducer, initial);

  const submit = async () => {
    dispatch({type: 'SUBMIT_START'});
    try {
      await api.checkout(state);
      dispatch({type: 'SUBMIT_DONE'});
    } catch {
      dispatch({type: 'SUBMIT_DONE'});
    }
  };

  // render steps from state.step
}
Enter fullscreen mode Exit fullscreen mode

When to prefer useReducer over many useStates

  • Next state depends on previous in complex ways
  • Multiple fields update together
  • You want named actions (easier to test/log)

10. useContext — share without prop drilling

Scenario: Auth across the app

Stabilize actions with useCallback, then memoize the context value so consumers don’t re-render when nothing meaningful changed.

import React, {
  createContext,
  useCallback,
  useContext,
  useMemo,
  useState,
} from 'react';
import {Pressable, Text} from 'react-native';

const AuthContext = createContext(null);

export function AuthProvider({children}) {
  const [user, setUser] = useState(null);

  const login = useCallback(async creds => {
    setUser(await api.login(creds));
  }, []);

  const logout = useCallback(() => setUser(null), []);

  const value = useMemo(
    () => ({user, login, logout}),
    [user, login, logout],
  );

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuth must be used within AuthProvider');
  return ctx;
}

function ProfileScreen() {
  const {user, logout} = useAuth();
  return (
    <Pressable onPress={logout}>
      <Text>Log out {user?.name}</Text>
    </Pressable>
  );
}
Enter fullscreen mode Exit fullscreen mode

RN tips

  • Put providers above NavigationContainer so every screen can read them
  • Split contexts (Auth vs Theme) — a theme toggle shouldn’t re-render auth-heavy trees
  • Don’t put high-frequency values (scroll offset) in context — use refs / Reanimated

11. React Native platform hooks

These are the hooks you won’t see in plain React DOM apps as often.

useWindowDimensions — responsive layout

import {useWindowDimensions, View} from 'react-native';

function Hero() {
  const {width, height} = useWindowDimensions();
  const isTablet = width >= 768;

  return (
    <View style={{height: height * 0.4, padding: isTablet ? 32 : 16}}>
      {/* ... */}
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

Scenario: Phone vs tablet padding / columns without listening to Dimensions manually.

useColorScheme — light / dark

import {useColorScheme} from 'react-native';

function ThemedScreen() {
  const scheme = useColorScheme(); // 'light' | 'dark' | null
  const bg = scheme === 'dark' ? '#111' : '#fff';
  return <View style={{flex: 1, backgroundColor: bg}} />;
}
Enter fullscreen mode Exit fullscreen mode

useSafeAreaInsets — notches & home indicator

import {useSafeAreaInsets} from 'react-native-safe-area-context';

function AppHeader() {
  const insets = useSafeAreaInsets();
  return (
    <View style={{paddingTop: insets.top, height: 56 + insets.top}}>
      <Text>Home</Text>
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

Scenario: Custom header under a notch — padding with insets.top instead of hardcoding 44.

Keyboard-aware pattern (custom hook)

import {useEffect, useState} from 'react';
import {Keyboard, Platform} from 'react-native';

function useKeyboardHeight() {
  const [height, setHeight] = useState(0);

  useEffect(() => {
    const showEvt =
      Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
    const hideEvt =
      Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';

    const showSub = Keyboard.addListener(showEvt, e => {
      setHeight(e.endCoordinates.height);
    });
    const hideSub = Keyboard.addListener(hideEvt, () => setHeight(0));

    return () => {
      showSub.remove();
      hideSub.remove();
    };
  }, []);

  return height;
}
Enter fullscreen mode Exit fullscreen mode

12. Navigation hooks (React Navigation)

Scenario: Open details + read params

import {useNavigation, useRoute, useFocusEffect} from '@react-navigation/native';
import {useCallback, useState} from 'react';

function HomeScreen() {
  const navigation = useNavigation();

  return (
    <Pressable
      onPress={() =>
        navigation.navigate('Details', {id: 42, title: 'React Native'})
      }>
      <Text>Open details</Text>
    </Pressable>
  );
}

function DetailsScreen() {
  const route = useRoute();
  const {id, title} = route.params;

  return <Text>{title} (#{id})</Text>;
}
Enter fullscreen mode Exit fullscreen mode

Scenario: Refetch every time screen gains focus

useEffect([]) runs on mount only. In a tab navigator, the screen may stay mounted — use focus.

function FavoritesScreen() {
  const [items, setItems] = useState([]);

  useFocusEffect(
    useCallback(() => {
      let alive = true;
      api.getFavorites().then(data => {
        if (alive) setItems(data);
      });
      return () => {
        alive = false;
      };
    }, []),
  );

  return <FlatList data={items} /* ... */ />;
}
Enter fullscreen mode Exit fullscreen mode

Scenario: Prevent leaving with unsaved changes

import React, {useEffect, useState} from 'react';
import {Alert, TextInput} from 'react-native';
import {useNavigation} from '@react-navigation/native';

function EditProfileScreen() {
  const navigation = useNavigation();
  const [name, setName] = useState('');
  const [dirty, setDirty] = useState(false);

  useEffect(() => {
    const unsub = navigation.addListener('beforeRemove', e => {
      if (!dirty) return;
      e.preventDefault();
      Alert.alert('Discard changes?', '', [
        {text: 'Stay', style: 'cancel'},
        {
          text: 'Discard',
          style: 'destructive',
          onPress: () => navigation.dispatch(e.data.action),
        },
      ]);
    });
    return unsub;
  }, [navigation, dirty]);

  return (
    <TextInput
      value={name}
      onChangeText={text => {
        setName(text);
        setDirty(true);
      }}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode

13. Custom hooks — the real power move

A custom hook = reusable stateful logic with a use name.

useFetch — loading / error / data

Teaching version with AbortController. For production apps with cache/retry, prefer TanStack Query.

import {useEffect, useState} from 'react';

function useFetch(url) {
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    if (!url) return;

    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(url, {signal: controller.signal})
      .then(r => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return r.json();
      })
      .then(json => setData(json))
      .catch(e => {
        if (e.name === 'AbortError') return;
        setError(e);
      })
      .finally(() => {
        if (!controller.signal.aborted) setLoading(false);
      });

    return () => controller.abort();
  }, [url]);

  return {data, error, loading};
}
Enter fullscreen mode Exit fullscreen mode

useToggle

import {useCallback, useState} from 'react';

function useToggle(initial = false) {
  const [on, setOn] = useState(initial);
  const toggle = useCallback(() => setOn(v => !v), []);
  const setTrue = useCallback(() => setOn(true), []);
  const setFalse = useCallback(() => setOn(false), []);
  return {on, toggle, setTrue, setFalse};
}

// Modal open/close
const modal = useToggle();
// modal.on, modal.toggle(), modal.setFalse()
Enter fullscreen mode Exit fullscreen mode

useAppState

import {useEffect, useState} from 'react';
import {AppState} from 'react-native';

function useAppState() {
  const [state, setState] = useState(AppState.currentState);

  useEffect(() => {
    const sub = AppState.addEventListener('change', setState);
    return () => sub.remove();
  }, []);

  return state; // 'active' | 'background' | 'inactive'
}
Enter fullscreen mode Exit fullscreen mode

Scenario: Refresh token only while app is active

function useSessionRefresh() {
  const appState = useAppState();

  useEffect(() => {
    if (appState !== 'active') return;
    const id = setInterval(() => {
      api.refreshToken();
    }, 60_000);
    return () => clearInterval(id);
  }, [appState]);
}
Enter fullscreen mode Exit fullscreen mode

Prefer abort/alive over useIsMounted

A global isMounted flag is an older pattern. Prefer:

  • AbortController for fetch
  • Local let alive = true inside the effect that owns the async work

That keeps cancellation next to the subscription — easier to reason about under Strict Mode.


14. Putting it together — screen scenario

Feature: Explore screen with search, pull-to-refresh, responsive grid, and safe area.

import React, {useCallback, useState} from 'react';
import {
  ActivityIndicator,
  FlatList,
  Text,
  TextInput,
  useWindowDimensions,
  View,
} from 'react-native';
import {useFocusEffect, useNavigation} from '@react-navigation/native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';

function ExploreScreen() {
  const navigation = useNavigation();
  const insets = useSafeAreaInsets();
  const {width} = useWindowDimensions();
  const numColumns = width >= 768 ? 3 : 2;

  const [query, setQuery] = useState('');
  const debounced = useDebouncedValue(query);
  const [items, setItems] = useState([]);
  const [loading, setLoading] = useState(true);
  const [refreshing, setRefreshing] = useState(false);
  const [error, setError] = useState(null);

  const load = useCallback(async (q, {signal} = {}) => {
    const data = await api.getExplore(q, {signal});
    setItems(data);
  }, []);

  useFocusEffect(
    useCallback(() => {
      const controller = new AbortController();
      setLoading(true);
      setError(null);

      load(debounced, {signal: controller.signal})
        .catch(e => {
          if (e.name !== 'AbortError') setError(e.message);
        })
        .finally(() => {
          if (!controller.signal.aborted) setLoading(false);
        });

      return () => controller.abort();
    }, [load, debounced]),
  );

  const onRefresh = useCallback(async () => {
    setRefreshing(true);
    try {
      await load(debounced);
    } catch (e) {
      setError(e.message);
    } finally {
      setRefreshing(false);
    }
  }, [load, debounced]);

  const onOpen = useCallback(
    id => navigation.navigate('ExploreDetails', {id}),
    [navigation],
  );

  const renderItem = useCallback(
    ({item}) => <ProductCard item={item} onOpen={onOpen} />,
    [onOpen],
  );

  return (
    <View style={{flex: 1, paddingTop: insets.top}}>
      <TextInput value={query} onChangeText={setQuery} placeholder="Search" />
      {loading && !refreshing ? <ActivityIndicator /> : null}
      {error ? <Text>{error}</Text> : null}
      <FlatList
        data={items}
        numColumns={numColumns}
        key={numColumns}
        refreshing={refreshing}
        onRefresh={onRefresh}
        renderItem={renderItem}
        keyExtractor={item => String(item.id)}
      />
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

Hooks used and why

Hook Role
useNavigation Open details
useSafeAreaInsets Notch padding
useWindowDimensions Responsive columns
useDebouncedValue Don’t hit API every keystroke
useFocusEffect Reload + abort when leaving / blurring
useCallback Stable load / onOpen / renderItem
useState Query, items, loading, error, refreshing

15. Common pitfalls (RN edition)

  1. Missing cleanup → duplicate listeners / double intervals after navigate
  2. Fetch without abort/alive → state updates after unmount; messy Strict Mode
  3. useEffect instead of useFocusEffect → stale data on tabs
  4. useCallback without React.memo on rows → often zero benefit
  5. Unstable dependency objects → effect loops (const options = {page: 1} each render)
  6. useMemo / useCallback everywhere → noise, harder reviews, little gain
  7. Derived data in state → sync bugs; compute inline or with useMemo
  8. Conditional hooks → React crash / mismatched hook order
  9. Ignoring Debug vs Release → perf conclusions from Debug are often false
  10. Putting scroll position in Context → re-renders the tree every frame
// BAD — derived state duplication
const [items, setItems] = useState([]);
const [count, setCount] = useState(0);
useEffect(() => setCount(items.length), [items]);

// GOOD
const count = items.length;

// BAD — new object every render retriggers effect
useEffect(() => {
  load(options);
}, [{page: 1}]);

// GOOD
const page = 1;
useEffect(() => {
  load({page});
}, [page]);
Enter fullscreen mode Exit fullscreen mode

16. Quick revision checklist

Before merging a screen

  • [ ] All hooks at top level
  • [ ] Picked the right hook for the job (§3)
  • [ ] Effects that subscribe also unsubscribe
  • [ ] Async work uses AbortController or an alive flag
  • [ ] Tab screens that need fresh data use useFocusEffect
  • [ ] Lists: stable keys; useCallback only with memo rows if needed
  • [ ] No derived data mirrored into extra useState
  • [ ] Safe areas for custom headers
  • [ ] Smoke-test rotate + background/foreground once

Hook cheat card (full tables in §3)

Need Hook Where
UI value useState Screen / component
Complex transitions useReducer Wizards / multi-field flows
Sync external system useEffect Screen / custom hook
Measure / no flash useLayoutEffect Lists / chat scroll
Native node or mutable box useRef Inputs / lists / timers
Shared app data useContext Root provider → screens
Expensive derive useMemo Filters / heavy calc
Stable function useCallback FlatList / memo children
Screen focus refetch useFocusEffect Tab / stack screens
Notch / home bar useSafeAreaInsets Headers / full-screen UI
Orientation size useWindowDimensions Responsive layout
Light / dark useColorScheme Theme colors
Reused logic Custom useX src/hooks/

Conclusion

Hooks are how React Native screens think: state for UI, effects for the outside world, refs for imperative islands, context for shared app data, and custom hooks for reuse.

Takeaways

  1. Start from what you need, then pick the hook (§3) — don’t default everything to useState + useEffect
  2. Learn the Rules of Hooks once — they never change
  3. Cleanup is part of the feature: listeners, timers, and abort in-flight fetch
  4. Prefer useFocusEffect for navigation-aware data loading
  5. useCallback helps only when paired with React.memo / list identity needs
  6. Extract custom hooks when logic repeats; use a query library when you need cache/retry

Next steps

  1. Pick one screen and map each state/effect to the §3 decision table
  2. Add abort/alive + focus-aware fetching where missing
  3. Extract one custom hook this week (useDebouncedValue or useAppState)
  4. Audit one FlatList: stable keys, memoized rows, measured useCallback

Call to action

Which hook still feels confusing in React Native — useFocusEffect, dependency arrays, or knowing when not to use useMemo?

Comment below with a scenario from your app (chat, feed, auth, video). If this guide helped, share it with a teammate who still treats every bug as “wipe node_modules” — or still fetches only with a lonely useEffect([]) on tab screens.

Want a follow-up? Tell me: Reanimated hooks, TanStack Query + RN, or testing hooks with RNTL.

Top comments (0)