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)
- Core hooks with scenario-based examples
- React Native–specific hooks
- Navigation 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 - RN platform hooks
- Navigation 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 |
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 One-glance decision table
| You need to… | Use | Where it usually lives |
|---|---|---|
| Show / change UI data (input, toggle, modal, loading flag) | useState |
Screen, component, custom hook |
| Many related updates / wizard / state machine | useReducer |
Complex screens, form flows |
| Run something after render (API, listener, timer) | useEffect |
Screens, providers, custom hooks |
| Fix layout/scroll before user sees a flash | useLayoutEffect |
Lists, measuring views, chat scroll |
| Keep a value without re-render (timer id, flag) | useRef |
Any component / custom hook |
Call imperative native API (.focus(), scroll) |
useRef + ref={}
|
Inputs, FlatList, video players |
| Share auth / theme / locale across many screens |
useContext (+ Provider) |
App.js / root → read in screens |
| Cache expensive derived data | useMemo |
Filters, sorted lists, heavy calc |
| Keep a stable function for list / memo child | useCallback |
FlatList handlers, memoized rows |
| Reload when screen is focused (tabs) | useFocusEffect |
Tab screens, stack screens that stay mounted |
| Navigate or read route params |
useNavigation / useRoute
|
Screens inside a navigator |
| Pad for notch / home indicator | useSafeAreaInsets |
Custom headers, full-screen modals |
| React to rotate / width breakpoints | useWindowDimensions |
Responsive layouts, grids |
| Follow system light/dark | useColorScheme |
Theme tokens, screen backgrounds |
| Reuse the same logic in 2+ places |
Custom hook (useX) |
src/hooks/ |
3.2 Decision flow (ask in order)
Does the UI need to update when this value changes?
YES → useState (or useReducer if transitions are complex)
NO → Is it a DOM/native node or “latest value” box?
YES → useRef
NO → Is it shared across many screens?
YES → useContext (or external store)
NO → Derive it: const x = ... or useMemo if expensive
Is this talking to the outside world (API, AppState, Keyboard)?
YES → useEffect (cleanup!)
Need it on every focus in a tab? → useFocusEffect
Need zero visual flash? → useLayoutEffect
Is a child / FlatList re-rendering too much because props change identity?
YES → useCallback for functions, useMemo for objects/arrays
NO → don’t add memo hooks yet
3.3 Where each hook belongs in an RN app
| Layer | Typical hooks | Avoid here |
|---|---|---|
Root (App.js) |
useState/useReducer for bootstrapping, Context Providers |
Heavy fetch for every screen |
| Providers (Auth, Theme) |
useState, useMemo (value), useEffect (hydrate token) |
Navigation hooks (not inside nav yet) |
| Navigators | Rarely — keep thin | Business useEffect fetch |
| Screens |
useState, useEffect / useFocusEffect, nav hooks, safe area, dimensions |
Deep prop drilling — lift to context/hook |
| List rows / pure UI | Prefer props only; maybe useState for local press/animation |
Fetching, AppState, navigation side effects |
src/hooks/ |
Any composition of the above | JSX (hooks return data/fns, not UI) |
3.4 Situation → hook (scenario cheat sheet)
| Scenario | Hook(s) | Why |
|---|---|---|
| TextInput value | useState |
UI must re-render as user types |
| Modal open/close |
useState or useToggle
|
Simple boolean UI |
| Checkout steps + address + payment | useReducer |
Multiple coordinated transitions |
| Fetch on first mount only | useEffect([]) |
One-shot sync with server |
| Fetch every time user opens a tab | useFocusEffect |
Tab screen often stays mounted |
| Search while typing |
useState + useDebouncedValue + useEffect
|
Debounce then fetch |
| Pause video in background |
useEffect + AppState (or useAppState) |
Subscribe/cleanup native event |
| Focus password field | useRef |
Imperative .focus(), no re-render |
| “Latest props” inside a long-lived listener |
useRef + useEffect
|
Avoid re-binding listener every render |
| Filter 5k products | useMemo |
Expensive derive; don’t store duplicate state |
FlatList renderItem / onPress
|
useCallback |
Stable identity → fewer cell updates |
| Auth user in Profile + Home + Settings |
useContext / useAuth
|
Avoid prop drilling through navigators |
| Custom header under notch | useSafeAreaInsets |
Device-correct padding |
| 2 columns phone / 3 tablet | useWindowDimensions |
Responds to rotation |
| Dark mode colors | useColorScheme |
Follows system appearance |
| Prevent leave with dirty form |
useNavigation + useEffect (beforeRemove) |
Navigation lifecycle |
| Same fetch logic on 3 screens |
useFetch / React Query |
Share logic; Query if you need cache |
| Cache + retry + focus refetch | TanStack Query / SWR | Don’t hand-roll this in useEffect
|
| Cancel in-flight request on leave |
AbortController in effect cleanup |
Cleaner than isMounted flags |
3.5 What not to use (common mis-picks)
| Tempting choice | Better choice | Why |
|---|---|---|
useState for items.length
|
Derive: items.length
|
Don’t sync derived data |
useEffect to update state from props |
Compute during render | Avoid extra render loops |
useEffect for tab refetch |
useFocusEffect |
Mount ≠ focus |
useMemo on every variable |
Use only when measured | Noise, little gain |
useCallback on every function |
Use for list/memo children | Same reason |
useRef for something shown in UI |
useState |
Ref changes don’t re-render |
| Context for high-frequency values (scroll X) |
useRef / Reanimated shared value |
Context re-renders all consumers |
| Fetch inside list row | Fetch in screen / query hook | N rows = N requests |
3.6 Mini map — “I am building X”
Login screen
- Fields →
useState - Submit loading/error →
useState - Focus next input →
useRef - On success navigate →
useNavigation
Feed / Explore
- Data/loading →
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. 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;
}
12. Navigation hooks (React Navigation)
Scenario: Open details + read params
import {useNavigation, useRoute, useFocusEffect} from '@react-navigation/native';
import {useCallback, useState} from 'react';
function HomeScreen() {
const navigation = useNavigation();
return (
<Pressable
onPress={() =>
navigation.navigate('Details', {id: 42, title: 'React Native'})
}>
<Text>Open details</Text>
</Pressable>
);
}
function DetailsScreen() {
const route = useRoute();
const {id, title} = route.params;
return <Text>{title} (#{id})</Text>;
}
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: 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);
}}
/>
);
}
13. Custom hooks — the real power move
A custom hook = reusable stateful logic with a use name.
useFetch — loading / error / data
Teaching version with AbortController. For production apps with cache/retry, prefer TanStack Query.
import {useEffect, useState} from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!url) return;
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(url, {signal: controller.signal})
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
})
.then(json => setData(json))
.catch(e => {
if (e.name === 'AbortError') return;
setError(e);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [url]);
return {data, error, loading};
}
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.
14. Putting it together — screen scenario
Feature: Explore screen with search, pull-to-refresh, responsive grid, and safe area.
import React, {useCallback, useState} from 'react';
import {
ActivityIndicator,
FlatList,
Text,
TextInput,
useWindowDimensions,
View,
} from 'react-native';
import {useFocusEffect, useNavigation} from '@react-navigation/native';
import {useSafeAreaInsets} from 'react-native-safe-area-context';
function ExploreScreen() {
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const {width} = useWindowDimensions();
const numColumns = width >= 768 ? 3 : 2;
const [query, setQuery] = useState('');
const debounced = useDebouncedValue(query);
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState(null);
const load = useCallback(async (q, {signal} = {}) => {
const data = await api.getExplore(q, {signal});
setItems(data);
}, []);
useFocusEffect(
useCallback(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
load(debounced, {signal: controller.signal})
.catch(e => {
if (e.name !== 'AbortError') setError(e.message);
})
.finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [load, debounced]),
);
const onRefresh = useCallback(async () => {
setRefreshing(true);
try {
await load(debounced);
} catch (e) {
setError(e.message);
} finally {
setRefreshing(false);
}
}, [load, debounced]);
const onOpen = useCallback(
id => navigation.navigate('ExploreDetails', {id}),
[navigation],
);
const renderItem = useCallback(
({item}) => <ProductCard item={item} onOpen={onOpen} />,
[onOpen],
);
return (
<View style={{flex: 1, paddingTop: insets.top}}>
<TextInput value={query} onChangeText={setQuery} placeholder="Search" />
{loading && !refreshing ? <ActivityIndicator /> : null}
{error ? <Text>{error}</Text> : null}
<FlatList
data={items}
numColumns={numColumns}
key={numColumns}
refreshing={refreshing}
onRefresh={onRefresh}
renderItem={renderItem}
keyExtractor={item => String(item.id)}
/>
</View>
);
}
Hooks used and why
| Hook | Role |
|---|---|
useNavigation |
Open details |
useSafeAreaInsets |
Notch padding |
useWindowDimensions |
Responsive columns |
useDebouncedValue |
Don’t hit API every keystroke |
useFocusEffect |
Reload + abort when leaving / blurring |
useCallback |
Stable load / onOpen / renderItem
|
useState |
Query, items, loading, error, refreshing |
15. Common pitfalls (RN edition)
- 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
// 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]);
16. Quick revision checklist
Before merging a screen
- [ ] All hooks at top level
- [ ] Picked the right hook for the job (§3)
- [ ] Effects that subscribe also unsubscribe
- [ ] Async work uses
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 tables in §3)
| Need | Hook | Where |
|---|---|---|
| UI value | useState |
Screen / component |
| Complex transitions | useReducer |
Wizards / multi-field flows |
| Sync external system | useEffect |
Screen / custom hook |
| Measure / no flash | useLayoutEffect |
Lists / chat scroll |
| Native node or mutable box | useRef |
Inputs / lists / timers |
| Shared app data | useContext |
Root provider → screens |
| Expensive derive | useMemo |
Filters / heavy calc |
| Stable function | useCallback |
FlatList / memo children |
| Screen focus refetch | useFocusEffect |
Tab / stack screens |
| Notch / home bar | useSafeAreaInsets |
Headers / full-screen UI |
| Orientation size | useWindowDimensions |
Responsive layout |
| Light / dark | useColorScheme |
Theme colors |
| Reused logic | Custom useX
|
src/hooks/ |
Conclusion
Hooks are how React Native screens think: state for UI, effects for the outside world, refs for imperative islands, context for shared app data, and custom hooks for reuse.
Takeaways
- Start from what you need, then pick the hook (§3) — 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 -
useCallbackhelps only when paired withReact.memo/ list identity needs - Extract custom hooks when logic repeats; use a query library when you need cache/retry
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)