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
- Mental model & Rules of Hooks
- What hook to use and where (decision guide + full hooks table)
- Core React hooks with scenario-based examples
- Concurrent & advanced hooks (
useTransition,useDeferredValue,useId, …) - React Native–specific hooks
- Navigation hooks
- Redux hooks
- Custom hooks you will actually reuse
- Common pitfalls + checklist
Table of Contents
- Mental model
- Rules of Hooks
- What hook to use and where
-
useState -
useEffect -
useLayoutEffect -
useRef -
useMemo&useCallback -
useReducer -
useContext -
useImperativeHandle -
useDebugValue -
useId -
useTransition&useDeferredValue -
useSyncExternalStore -
useInsertionEffect - RN platform hooks
- Navigation hooks
- Redux hooks
- Custom hooks
- Full screen example
- Pitfalls
- 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)
- Render — your function runs; hooks return current values
- Commit — React applies updates to native views
-
useLayoutEffect— runs after commit, before the user paints -
useEffect— runs after paint — safe for network, AppState, logging
2. Rules of Hooks (non-negotiable)
- Only call hooks at the top level (not inside
if, loops, or nested functions) - Only call hooks from React function components or custom hooks
- Custom hooks must start with
use - 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]);
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
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 →
useStateoruseFetch - 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 →
useNavigationlistener - Safe header →
useSafeAreaInsets
Root app shell
- Providers → Auth/Theme with
useState+useMemo - Bootstrap token →
useEffectonce - 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());
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>
);
}
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);
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>
);
}
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>}
/>
);
}
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();
}, []);
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]);
}
Cleanup is mandatory for:
-
AppState/Keyboardlisteners -
setInterval/setTimeout - WebSocket / EventEmitter subscriptions
- Navigation listeners
Prefer
useWindowDimensionsover manualDimensions.addEventListenerfor 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>}
/>
</>
);
}
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} /* ... */ />;
}
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 />
</>
);
}
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), []);
}
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);
}
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 /
FlatListdepends 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} /* ... */ />;
}
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}
/>
);
}
Interview line
useCallbackdoes not make the function faster — it keeps the same function reference soReact.memochildren / list cells can skip re-renders. Withoutmemo,useCallbackoften 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
}
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>
);
}
RN tips
- Put providers above
NavigationContainerso 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" />
</>
);
}
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/
onChangefor 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;
}
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>
);
}
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 —
useIdstill 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)} /* ... */ />
</>
);
}
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)} /* ... */ />
</>
);
}
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>;
}
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
-
getSnapshotmust return a cached / immutable value if nothing changed (same reference for objects) - Provide
getServerSnapshotwhen 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]);
}
For app developers
- Prefer
StyleSheet.create, theme objects, or your design-system APIs - You almost never call
useInsertionEffectin 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>
);
}
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}} />;
}
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>
);
}
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;
}
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>;
}
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} /* ... */ />;
}
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} />;
}
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;
}
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);
}}
/>
);
}
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>
);
}
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]);
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>;
}
Prefer useSelector for anything shown in the UI — useStore does not subscribe, so the component won’t re-render when state changes.
RN tips
- Put
<Provider store={store}>aboveNavigationContainer - 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};
}
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()
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'
}
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]);
}
Prefer abort/alive over useIsMounted
A global isMounted flag is an older pattern. Prefer:
-
AbortControllerfor fetch - Local
let alive = trueinside 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>
);
}
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)
- Missing cleanup → duplicate listeners / double intervals after navigate
-
Fetch without abort/
alive→ state updates after unmount; messy Strict Mode -
useEffectinstead ofuseFocusEffect→ stale data on tabs -
useCallbackwithoutReact.memoon rows → often zero benefit -
Unstable dependency objects → effect loops (
const options = {page: 1}each render) -
useMemo/useCallbackeverywhere → noise, harder reviews, little gain -
Derived data in state → sync bugs; compute inline or with
useMemo - Conditional hooks → React crash / mismatched hook order
- Ignoring Debug vs Release → perf conclusions from Debug are often false
- Putting scroll position in Context → re-renders the tree every frame
-
useInsertionEffectin app screens → wrong tool; use StyleSheet / theme - 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]);
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
AbortControlleror analiveflag - [ ] Tab screens that need fresh data use
useFocusEffect - [ ] Lists: stable keys;
useCallbackonly withmemorows 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
- Start from what you need, then pick the hook (§3.1) — don’t default everything to
useState+useEffect - Learn the Rules of Hooks once — they never change
- Cleanup is part of the feature: listeners, timers, and abort in-flight fetch
- Prefer
useFocusEffectfor navigation-aware data loading;useIsFocusedfor simple booleans -
useCallbackhelps only when paired withReact.memo/ list identity needs - Use
useTransition/useDeferredValuewhen heavy UI work blocks input — not as a substitute for list virtualization - Extract custom hooks when logic repeats; use Redux selectors / a query library when you need shared cache
Next steps
- Pick one screen and map each state/effect to the §3 decision table
- Add abort/
alive+ focus-aware fetching where missing - Extract one custom hook this week (
useDebouncedValueoruseAppState) - Audit one
FlatList: stable keys, memoized rows, measureduseCallback
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)