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

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 + full hooks table)
  3. Core React hooks with scenario-based examples
  4. Concurrent & advanced hooks (useTransition, useDeferredValue, useId, …)
  5. React Native–specific hooks
  6. Navigation hooks
  7. Redux hooks
  8. Custom hooks you will actually reuse
  9. 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. useImperativeHandle
  12. useDebugValue
  13. useId
  14. useTransition & useDeferredValue
  15. useSyncExternalStore
  16. useInsertionEffect
  17. RN platform hooks
  18. Navigation hooks
  19. Redux hooks
  20. Custom hooks
  21. Full screen example
  22. Pitfalls
  23. 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
Concurrent Defer non-urgent UI work Search that filters a big catalog
External store Subscribe outside React state Redux, AppState adapters
Navigation Screen lifecycle & routing Focus refetch, route params
Platform Device / system signals Dark mode, window size

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 Full hooks reference

Hook Purpose
useState Local state
useEffect Side effects
useContext Access Context
useReducer Complex state
useRef Mutable references
useMemo Memoize computed values
useCallback Memoize functions
useLayoutEffect Run before paint
useImperativeHandle Expose child methods
useDebugValue Debug custom hooks
useId Unique IDs
useTransition Non-urgent updates
useDeferredValue Defer expensive rendering
useSyncExternalStore External store subscriptions
useInsertionEffect Library-level style insertion
useColorScheme Detect light/dark mode
useWindowDimensions Responsive screen dimensions
useNavigation Navigation actions
useRoute Access route params
useFocusEffect Screen focus lifecycle
useIsFocused Check screen focus
useNavigationState Read navigation state
useSelector Read Redux state
useDispatch Dispatch Redux actions
useStore Access Redux store

3.2 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
Expose .focus() / .scrollTo() from a child via ref useImperativeHandle + forwardRef Input wrappers, list wrappers
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
Keep typing smooth while a heavy list filters useDeferredValue / useTransition Search screens, big grids
Stable accessibility / form field ids useId Forms, a11y labels
Subscribe to Redux / Zustand / external store useSyncExternalStore or useSelector Store adapters, libraries
Label a custom hook in React DevTools useDebugValue Custom hooks only
Inject CSS-in-JS styles before layout (libs) useInsertionEffect Style libraries — not app screens
Reload when screen is focused (tabs) useFocusEffect Tab screens, stack screens that stay mounted
Boolean “is this screen focused?” useIsFocused Conditional UI / pause video
Read current route name / nav tree useNavigationState Analytics, deep-link helpers
Navigate or read route params useNavigation / useRoute Screens inside a navigator
Read / write Redux state useSelector / useDispatch Screens connected to Redux
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.3 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

Is typing / pressing blocked by a heavy re-render?
  YES → useDeferredValue (lag the heavy value) or useTransition (mark the update)
  NO  → leave concurrent hooks alone
Enter fullscreen mode Exit fullscreen mode

3.4 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.5 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
Heavy filter that shouldn’t block keystrokes useDeferredValue Defer list re-render
Mark a filter update as non-urgent useTransition Keep presses responsive
Pause video in background useEffect + AppState (or useAppState) Subscribe/cleanup native event
Focus password field useRef Imperative .focus(), no re-render
Parent calls child .focus() via ref useImperativeHandle Controlled public API
“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
Cart count from Redux useSelector Subscribe to one slice
Dispatch addToCart useDispatch Fire actions from UI
Custom header under notch useSafeAreaInsets Device-correct padding
2 columns phone / 3 tablet useWindowDimensions Responds to rotation
Dark mode colors useColorScheme Follows system appearance
Hide tab bar badge only when focused useIsFocused Focus is a boolean
Log current route name useNavigationState Read nav tree safely
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.6 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
useInsertionEffect in a screen useEffect / StyleSheet Reserved for style libraries
useStore to render UI from Redux useSelector useStore doesn’t subscribe
Returning whole nav state from useNavigationState Select route.name (etc.) Avoid extra re-renders
Fetch inside list row Fetch in screen / query hook N rows = N requests

3.7 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. useImperativeHandle — expose child methods

What it is

useImperativeHandle customizes the instance value that a parent receives when it holds a ref to a child. Pair with forwardRef.

Scenario: Parent focuses a wrapped TextInput

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

const PasswordField = forwardRef(function PasswordField(props, ref) {
  const inputRef = useRef(null);

  useImperativeHandle(ref, () => ({
    focus: () => inputRef.current?.focus(),
    clear: () => inputRef.current?.clear(),
  }));

  return <TextInput ref={inputRef} secureTextEntry {...props} />;
});

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

  return (
    <>
      <TextInput
        placeholder="Email"
        returnKeyType="next"
        onSubmitEditing={() => passwordRef.current?.focus()}
      />
      <PasswordField ref={passwordRef} placeholder="Password" />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

When to use

  • You own a reusable wrapper and want a small public API (.focus(), .scrollToEnd())
  • You want to hide internal refs from parents

When not to

  • Prefer props/onChange for normal data flow — don’t reach into children for state
  • Avoid exposing huge imperative surfaces; keep the handle tiny

12. useDebugValue — debug custom hooks

What it is

useDebugValue shows a label next to your custom hook in React DevTools. It has no effect on production UI.

import {useDebugValue, 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();
  }, []);

  useDebugValue(state); // DevTools: useAppState: "active"
  // Or format lazily for expensive labels:
  // useDebugValue(state, s => `app=${s}`);

  return state;
}
Enter fullscreen mode Exit fullscreen mode

RN tip: Use it on hooks you share across the team (useAuth, useFetch) so DevTools dumps are readable during screen debugging.


13. useId — unique IDs

What it is

useId returns a unique string ID that is stable across renders for that component instance. Handy for accessibility pairing when you don’t want collision-prone Math.random().

import {useId} from 'react';
import {Text, TextInput, View} from 'react-native';

function LabeledInput({label, value, onChangeText}) {
  const id = useId();

  return (
    <View>
      <Text nativeID={`${id}-label`}>{label}</Text>
      <TextInput
        accessibilityLabelledBy={[`${id}-label`]}
        value={value}
        onChangeText={onChangeText}
      />
    </View>
  );
}
Enter fullscreen mode Exit fullscreen mode

Notes

  • IDs are for identity / a11y, not as React keys in lists
  • Prefer list item ids from your data for keyExtractor
  • On RN, a11y APIs differ slightly by platform — useId still gives you a stable unique string

14. useTransition & useDeferredValue — keep UI responsive

When a state update triggers heavy work (filtering thousands of rows), mark it non-urgent so typing / presses stay snappy.

useTransition — wrap the update

import {useState, useTransition} from 'react';
import {ActivityIndicator, FlatList, Pressable, Text} from 'react-native';

function CatalogScreen({allProducts}) {
  const [filter, setFilter] = useState('all');
  const [isPending, startTransition] = useTransition();

  const visible =
    filter === 'all'
      ? allProducts
      : allProducts.filter(p => p.category === filter);

  return (
    <>
      <Pressable
        onPress={() => {
          startTransition(() => {
            setFilter('sale');
          });
        }}>
        <Text>On sale</Text>
      </Pressable>
      {isPending ? <ActivityIndicator /> : null}
      <FlatList data={visible} keyExtractor={p => String(p.id)} /* ... */ />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

useDeferredValue — defer a derived value

import {useDeferredValue, useMemo, useState} from 'react';
import {FlatList, TextInput} from 'react-native';

function SearchScreen({products}) {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);

  const results = useMemo(() => {
    const q = deferredQuery.trim().toLowerCase();
    if (!q) return products;
    return products.filter(p => p.title.toLowerCase().includes(q));
  }, [products, deferredQuery]);

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

When to use which

Need Hook
You control the setState that kicks off heavy work useTransition
You already have a fast-changing value (search text) and want to lag the expensive consumer useDeferredValue

RN tips

  • Still prefer FlashList / virtualization for huge lists — these hooks don’t replace list performance work
  • Pair with a lightweight pending indicator (isPending) so users know the filter is catching up
  • For network debounce, keep useDebouncedValue — concurrent hooks don’t replace API throttling

15. useSyncExternalStore — external store subscriptions

What it is

The officially supported way to subscribe a component to an external store (Redux under the hood, Zustand, a native module event bus, AppState wrappers) so React stays consistent with concurrent rendering.

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

function subscribe(onStoreChange) {
  const sub = AppState.addEventListener('change', onStoreChange);
  return () => sub.remove();
}

function getSnapshot() {
  return AppState.currentState;
}

function useAppStateStore() {
  return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

function StatusBanner() {
  const state = useAppStateStore();
  return <Text>App is {state}</Text>;
}
Enter fullscreen mode Exit fullscreen mode

When to use

  • Building a tiny store / bridging a non-React data source
  • Library authors wiring subscriptions correctly

When not to

  • App screens usually use useSelector (Redux), Context, or a library hook that already wraps this
  • Don’t reinvent Redux with a hand-rolled store unless you need to

Rules

  • getSnapshot must return a cached / immutable value if nothing changed (same reference for objects)
  • Provide getServerSnapshot when SSR matters (rare in RN; still good for shared packages)

16. useInsertionEffect — library-level style insertion

What it is

useInsertionEffect fires before layout effects. It exists so CSS-in-JS libraries can inject styles early enough that layout reads see them.

import {useInsertionEffect} from 'react';

// Pattern for style libraries — not typical app screen code
function useInjectStyle(rule) {
  useInsertionEffect(() => {
    // library injects a style rule into a stylesheet
    const remove = styleEngine.insert(rule);
    return remove;
  }, [rule]);
}
Enter fullscreen mode Exit fullscreen mode

For app developers

  • Prefer StyleSheet.create, theme objects, or your design-system APIs
  • You almost never call useInsertionEffect in a screen
  • If you maintain a styling library for RN/web, this is the right lifecycle slot

17. 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

18. Navigation hooks (React Navigation)

Hook Purpose
useNavigation Navigate, go back, set options, add listeners
useRoute Read route.params / route name for this screen
useFocusEffect Run an effect when the screen is focused (with cleanup)
useIsFocused Boolean: is this screen currently focused?
useNavigationState Read (a slice of) the navigation state tree

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: useIsFocused — pause work when blurred

Prefer useFocusEffect when you need subscribe/cleanup. Use useIsFocused when you only need a boolean for UI or a child.

import {useIsFocused} from '@react-navigation/native';

function VideoTab() {
  const isFocused = useIsFocused();

  return <VideoPlayer paused={!isFocused} source={clip} />;
}
Enter fullscreen mode Exit fullscreen mode

Scenario: useNavigationState — current route name

import {useEffect} from 'react';
import {useNavigationState} from '@react-navigation/native';

function useCurrentRouteName() {
  return useNavigationState(state => {
    let route = state.routes[state.index];
    while (route.state) {
      route = route.state.routes[route.state.index];
    }
    return route.name;
  });
}

function AnalyticsScreenTracker() {
  const routeName = useCurrentRouteName();

  useEffect(() => {
    analytics.screen(routeName);
  }, [routeName]);

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Tip: Pass a selector into useNavigationState so you re-render only when the selected slice changes — don’t return the whole state object unless you must.

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

19. Redux hooks (useSelector, useDispatch, useStore)

Use these when the app already has a Redux (or Redux Toolkit) store. They replace the old connect() HOC.

Hook Purpose
useSelector Read a slice of state; re-render when it changes
useDispatch Get dispatch to fire actions / thunks
useStore Rare — access the store instance directly

Scenario: Cart badge + add item

import {useDispatch, useSelector} from 'react-redux';
import {Pressable, Text} from 'react-native';
import {addItem} from './cartSlice';

function CartBadge() {
  const count = useSelector(state => state.cart.items.length);
  return <Text>{count}</Text>;
}

function ProductRow({product}) {
  const dispatch = useDispatch();

  return (
    <Pressable onPress={() => dispatch(addItem(product))}>
      <Text>Add {product.title}</Text>
    </Pressable>
  );
}
Enter fullscreen mode Exit fullscreen mode

useSelector tips

// GOOD — primitive / stable selected value
const count = useSelector(state => state.cart.items.length);

// CARE — new array/object every time → extra re-renders
const items = useSelector(state =>
  state.cart.items.filter(i => i.qty > 0),
);

// BETTER — select raw data, derive in the component (or use a memoized selector)
const items = useSelector(state => state.cart.items);
const visible = useMemo(() => items.filter(i => i.qty > 0), [items]);
Enter fullscreen mode Exit fullscreen mode

For expensive derived slices, use Reselect / RTK createSelector so equality checks stay cheap.

useStore — escape hatch

import {useStore} from 'react-redux';

function CheckoutButton() {
  const store = useStore();

  const onPress = () => {
    // Read once outside React render — e.g. analytics / non-reactive snapshot
    const {cart} = store.getState();
    analytics.track('checkout_start', {count: cart.items.length});
  };

  return <Pressable onPress={onPress}><Text>Checkout</Text></Pressable>;
}
Enter fullscreen mode Exit fullscreen mode

Prefer useSelector for anything shown in the UIuseStore does not subscribe, so the component won’t re-render when state changes.

RN tips

  • Put <Provider store={store}> above NavigationContainer
  • Keep selectors narrow (badge count ≠ whole cart) to limit screen re-renders
  • Side effects (API) belong in thunks / listeners / useEffect, not inside reducers

20. 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.


21. 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

22. 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
  11. useInsertionEffect in app screens → wrong tool; use StyleSheet / theme
  12. Wide Redux selectors → whole-slice select re-renders everything; keep them narrow
// 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

23. 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 table in §3.1)

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
Expose child methods via ref useImperativeHandle Wrappers / forwardRef
Shared app data useContext Root provider → screens
Expensive derive useMemo Filters / heavy calc
Stable function useCallback FlatList / memo children
Non-urgent / deferred UI useTransition / useDeferredValue Heavy search / filters
Unique a11y ids useId Forms / labels
External store subscribe useSyncExternalStore Store adapters
Style lib injection useInsertionEffect Libraries only
DevTools label useDebugValue Custom hooks
Screen focus refetch useFocusEffect Tab / stack screens
Focus boolean useIsFocused Pause UI when blurred
Nav state slice useNavigationState Analytics / route name
Navigate / params useNavigation / useRoute Screens
Redux read / write useSelector / useDispatch Connected 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.1) — 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; useIsFocused for simple booleans
  5. useCallback helps only when paired with React.memo / list identity needs
  6. Use useTransition / useDeferredValue when heavy UI work blocks input — not as a substitute for list virtualization
  7. Extract custom hooks when logic repeats; use Redux selectors / a query library when you need shared cache

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)