Reasoning About React Hooks Through Render Snapshots and Trade-offs
A senior-level mental model for React and React Native Hooks: render snapshots, effects, refs, memoisation, custom Hooks, and how to explain trade-offs in interviews.
This article is published in English.
Sources: React & React Native Hooks: The Complete Senior Interview Guide
Senior React and React Native interviews rarely stop at "what does this Hook do?". A question about useState turns into batching and functional updates within a minute; a question about useEffect becomes a discussion of infinite loops, stale closures, cancellation and when an effect should not exist at all; useMemo and useCallback lead straight to "does that actually make it faster?". Memorised definitions do not survive that kind of follow-up. What does survive is a connected model, and this guide builds one: rendering, then state, closures, effects, refs, memoisation, custom Hooks, performance and finally architecture, with React Native examples wherever the platform changes the picture.
The rendering model everything else depends on
Start with one idea and hold on to it: when React renders a component, it calls the component function again from the top. Take the smallest possible counter.
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
{count}
</button>
);
}
It is natural to imagine that clicking the button reaches into the component and changes the count variable. That is not what happens. The setter asks React for a new render, and the next call of Counter receives the new value as a fresh constant.
Render 1
count = 0
↓
User clicks
↓
setCount(1)
↓
React schedules another render
↓
Render 2
count = 1
Every render therefore owns a snapshot of its props and state. Nothing inside that snapshot changes after the fact. This single observation explains closures over old values, stale state in timers, why effect dependencies exist, how callbacks and event handlers behave, and why memoisation is about identity. Interviewers who push past the API surface are usually probing whether you have this model.
useState: state that belongs to the render
useState gives a function component a piece of state that feeds its output.
const [count, setCount] = useState(0);
The pair you get back has two distinct roles: a value for the current render and a function that requests an update.
count
↓
current state
setCount
↓
request a state update
Calling the setter does not mutate anything in place; it schedules another render in which the component sees the new value.
How the questions escalate
A typical progression looks like this:
- Basic: what problem does
useStatesolve? - Intermediate: how is state different from an ordinary local variable?
- Senior: when is the updater function the right choice?
- Deeper: what does
setCount(count + 1), repeated three times in one handler, actually produce?
"It stores state" answers only the first line. A stronger answer covers the model:
useState holds a value that influences what the component renders. Calling the setter queues a new render with the updated value, so state is best treated as part of that render's snapshot, not as a variable to mutate. When the next value depends on the previous one, the updater function is the safe choice, because React batches updates and the captured value may already be out of date.
An ordinary variable, by contrast, is recreated with its initial value on every render and changing it never asks React to render again, which is why it cannot drive the UI.
Why functional updates matter
Here are three increments written against the captured value.
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
All three read the same count from the current snapshot, so after batching the result is one increment, not three. The updater form fixes that:
setCount(prev => prev + 1);
setCount(prev => prev + 1);
setCount(prev => prev + 1);
React passes each updater the most recent pending value, so the three calls chain correctly. Reach for this form whenever several updates happen together or are queued, whenever a callback might run with an old snapshot, and generally whenever the new value is computed from the old one.
A shopping basket in a React Native app is a realistic case. The quantity is rendered, so it belongs in state:
const [quantity, setQuantity] = useState(1);
The handlers derive the next value from the previous one, and the decrement clamps at one so the basket can never show zero or a negative quantity.
const increaseQuantity = () => {
setQuantity(prev => prev + 1);
};const decreaseQuantity = () => {
setQuantity(prev => Math.max(1, prev - 1));
};
Nothing changes between web and native
The Hook is identical on both platforms. A web dropdown might track whether it is open:
const [isOpen, setIsOpen] = useState(false);
A React Native screen might track modal visibility the same way:
const [isModalVisible, setModalVisible] = useState(false);
The state mechanism is shared; only the UI primitives it drives differ.
useEffect: synchronising with the outside world
No Hook generates more interview discussion. The common description, "it runs code after rendering", is true but leaves out the purpose. A better framing is that an effect keeps your component in sync with a system React does not control.
On the web that system might be DOM events, browser APIs, WebSockets, subscriptions, timers or network requests. In React Native it is often AppState, the Keyboard module, native modules, push notification listeners, network state, device APIs, WebSockets and timers.
How the questions escalate
- Basic: what is
useEffectfor? - Intermediate: walk through how the dependency array works.
- Senior: what makes an effect loop forever?
- Senior+: in which situations is an effect the wrong tool?
- Production: how do you tear down a WebSocket subscription?
Compare "useEffect is for API calls" with this:
useEffect exists mainly to keep a component synchronised with an external system. The dependency array lists the reactive values the effect reads, and it has to be accurate; the Hooks lint rule catches omissions. When the effect opens a subscription, starts a timer or attaches a listener, it returns a cleanup function so the old resource is released before the effect re-runs or when the component unmounts.
Reading the dependency array
With a dependency, the effect runs after the first render and again whenever that value changes between renders.
useEffect(() => {
fetchUser(userId);
}, [userId]);
With no array at all, it runs after every single render:
useEffect(() => {
// ...
});
With an empty array, it runs after the component mounts:
useEffect(() => {
// ...
}, []);
Be careful with the phrase "runs once". In development, Strict Mode deliberately mounts, cleans up and mounts again to surface effects that do not clean up properly. The precise statement is that an empty-array effect runs after mount, and that development Strict Mode may add an extra setup and cleanup cycle. Code that breaks under that double run usually has a real bug.
Cleanup keeps resources from leaking
A subscription that depends on userId opens a connection in the effect body:
useEffect(() => {
const subscription = subscribeToUpdates(userId);
and returns a function that closes it:
return () => {
subscription.unsubscribe();
};
}, [userId]);
When userId changes, React runs the previous cleanup before starting the new effect, so there is never more than one live subscription:
Old effect
↓
cleanup old subscription
↓
new effect
↓
subscribe using new userId
On unmount, the last cleanup runs and nothing is left behind:
component unmounts
↓
cleanup
↓
subscription removed
The same mechanism handles cancelling async work. For a fetch you can abort the request with an AbortController in cleanup, or set an ignore flag so a late response from an old userId does not overwrite newer data.
A React Native example: reacting to AppState
A more convincing effect than a data fetch is one that ties the component to the native app lifecycle. This listener refreshes data when the app comes back to the foreground and removes itself on unmount.
useEffect(() => {
const subscription = AppState.addEventListener(
"change",
nextState => {
if (nextState === "active") {
refreshData();
}
}
);
return () => {
subscription.remove();
};
}, []);
That is synchronisation with an external system in its clearest form, and it shows an interviewer you understand what effects are for.
Mistake: syncing derived values through an effect
Suppose a form stores first and last name:
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
A frequent anti-pattern adds a third piece of state and an effect to keep it updated:
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
This introduces redundant state and an extra render every time a name changes, and for one render the full name is out of date. Because the value is fully determined by existing state, compute it during render:
const fullName = `${firstName} ${lastName}`;
The principle worth stating out loud: if a value can be calculated synchronously from props or state, derive it while rendering instead of storing it and patching it in an effect. The pattern is examined in more depth in stop syncing state with useEffect.
Mistake: unstable object dependencies
Here an object literal is created inside the component and listed as a dependency.
const filters = {
status: "active",
};
useEffect(() => {
fetchUsers(filters);
}, [filters]);
Every render builds a new filters object, and dependencies are compared by reference, so the effect sees a "change" each time. If the effect updates state, the cycle feeds itself:
Render
↓
new filters object
↓
dependency changed
↓
effect
↓
setState
↓
render
↓
new filters object
↓
effect again
The reflexive fix is to wrap the object in memoisation:
useMemo(...)
Before doing that, ask whether the object needs to be created inside the component at all. A constant can move outside the component; a value built from props can often be replaced by depending on its primitive fields (for example status) or by constructing it inside the effect. Fixing the data flow is the senior move; adding memoisation to hide it is not.
useRef: memory that does not render
useRef returns a mutable container that persists across renders and whose changes never trigger a render.
const valueRef = useRef(0);
You read and write its contents through the current property:
valueRef.current
Assigning a new value, as below, updates the container but leaves the screen as it was until something else causes a render.
valueRef.current = 10;
Interviewers usually go from "how is a ref different from state?" to "give a real example" to "why pick a ref over state here?". A strong answer:
State is data that feeds rendering, so changing it schedules a render. A ref keeps a mutable value alive across renders without rendering when
.currentchanges. Refs fit imperative handles, timer IDs, previous values and other mutable data that the UI does not display.
On the web, a ref gives imperative access to a DOM node, for example to focus an input:
const inputRef = useRef<HTMLInputElement>(null);
inputRef.current?.focus();
In React Native, refs are just as common, since mobile UIs often need imperative calls such as scrolling a list back to the top:
const scrollRef = useRef<ScrollView>(null);
scrollRef.current?.scrollTo({
y: 0,
animated: true,
});
Choosing between state and a ref
The deciding question is simple: does changing this value need to change what the user sees? If it does, choose
useState
and if it does not, choose
useRef
The active tab obviously affects the UI, so it is state:
const [selectedTab, setSelectedTab] = useState("home");
A timer handle only exists so you can clear it later; nothing renders it, so a ref is the right home:
const timerRef = useRef<number | null>(null);
useMemo: caching a computed value
useMemo stores the result of a calculation and only recomputes it when a dependency changes. A filtered and sorted user list is the classic example:
const filteredUsers = useMemo(() => {
return users
.filter(user => user.active)
.sort(sortUsers);
}, [users]);
As long as users keeps the same reference, the filtering and sorting are skipped on subsequent renders. Watch for the hidden requirement: sortUsers must be stable too, or listed as a dependency, and sort mutates in place, which is safe here only because filter already produced a new array.
The follow-up is almost always "should we use it everywhere?". The answer that shows judgement:
No. useMemo is not free: it adds dependency tracking, memory and code to read. It pays off when an expensive calculation is repeated for no reason, or when a stable reference matters to something downstream, and profiling should come before adding it.
Memoising a string concatenation is the canonical overuse:
const fullName = useMemo(
() => `${firstName} ${lastName}`,
[firstName, lastName]
);
The work is trivial, so the plain expression is both faster to read and at least as fast to run:
const fullName = `${firstName} ${lastName}`;
Now consider a trading app in React Native showing thousands of trades, filtered by symbol and time window and sorted newest first.
const visibleTrades = useMemo(() => {
return trades
.filter(trade => trade.symbol === selectedSymbol)
.filter(trade => trade.timestamp >= startTime)
.sort((a, b) => b.timestamp - a.timestamp);
}, [trades, selectedSymbol, startTime]);
When that transformation is measurably expensive, memoisation earns its place. Even then, confirm it with a profiler rather than assuming.
useCallback: keeping a function's identity
useCallback returns the same function object between renders until its dependencies change.
const handlePress = useCallback(() => {
submitOrder();
}, [submitOrder]);
The difference from useMemo is only in what gets cached. One caches a value:
useMemo
→ memoises a value
The other caches a function reference:
useCallback
→ memoises a function reference
In fact useCallback(fn, deps) behaves like useMemo(() => fn, deps). "To improve performance" is a weak answer to "why use it?". A better one:
useCallback keeps a function's identity stable while its dependencies stay the same. It is worth using where reference equality actually matters, such as a callback passed to a memoised child or a function that another Hook lists as a dependency. It should not be added by default, because memoisation has its own cost and makes code harder to follow.
How it connects to React.memo
React.memo skips re-rendering a component when all its props are shallowly equal to the previous ones.
const UserRow = React.memo(function UserRow({
user,
onPress,
}) {
return (
// ...
);
});
If the parent passes a fresh arrow function each render, one prop is always different:
<UserRow
user={user}
onPress={() => openUser(user.id)}
/>
Because the reference changes, the shallow comparison fails and the memoised row renders anyway, which can quietly cancel the benefit of React.memo. A stable handler looks like this:
const handlePress = useCallback(() => {
openUser(user.id);
}, [user.id]);
and is passed down in place of the inline function:
<UserRow
user={user}
onPress={handlePress}
/>
Keep the caveat in view: do this when you have evidence that the child re-renders expensively, not as a reflex.
Closures and the stale value problem
Closures are a JavaScript feature rather than a Hook, but Hooks are hard to reason about without them. A closure is a function that keeps access to the variables of the scope where it was created.
function createLogger(name: string) {
return () => {
console.log(name);
};
}
The inner function still sees name long after createLogger has returned.
How a stale closure appears in React
Consider a component with a counter:
function Counter() {
const [count, setCount] = useState(0);
and two buttons, one that increments and one that logs the count after a one-second timeout:
const handleClick = () => {
setTimeout(() => {
console.log(count);
}, 1000);
}; return (
<>
<button onClick={handleClick}>
Log Count
</button> <button onClick={() => setCount(c => c + 1)}>
Increment
</button>
</>
);
}
Suppose the logging handler was created during a render where
count = 0
If you click "Log Count" and then increment several times within that second, the timeout still prints
0
even though later renders have moved on to
count = 1
count = 2
count = 3
The timeout callback closed over count from the render that created it, and that snapshot never changes.
Explaining it in an interview
Function components work in render snapshots: each render has its own values and its own closures. A callback created in one render captures that render's values, so something asynchronous like a timer can observe old state. The fix depends on the need: a functional update, correct effect dependencies, or a ref when the latest mutable value is genuinely required without causing a render.
That answer names the cause and offers three fixes with the conditions for each, which is exactly what a senior interviewer is listening for.
Custom Hooks: packaging behaviour
Custom Hooks are where the previous ideas combine. A custom Hook is a function whose name starts with use and that calls other Hooks, letting you extract stateful behaviour and reuse it. A profile loader starts by declaring its state:
function useUserProfile(userId: string) {
const [data, setData] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
It then runs the fetch in an effect keyed on userId and returns the three values:
useEffect(() => {
// fetch user
}, [userId]); return {
data,
loading,
error,
};
}
A screen consumes it without knowing how loading works:
function ProfileScreen({ userId }) {
const {
data,
loading,
error,
} = useUserProfile(userId);
and renders whatever it needs from the result:
// UI
}
Note that each component calling a custom Hook gets its own independent state; the Hook shares logic, not data.
What the interviewer is really checking
The question "can you write a custom Hook?" is really about whether you can spot a reusable behavioural abstraction. Good candidates look like this:
useAuth()
useUserProfile()
useNetworkStatus()
usePagination()
useDebounce()
useKeyboard()
useAppState()
usePermissions()
useInfiniteScroll()
In React Native, a network status Hook is a tidy example. It subscribes to connectivity changes, stores the flag in state and returns the unsubscribe function as its cleanup.
function useNetworkStatus() {
const [isConnected, setIsConnected] = useState(true);
useEffect(() => {
const unsubscribe = subscribeToNetworkChanges(
state => {
setIsConnected(state.isConnected);
}
);
return unsubscribe;
}, []);
return {
isConnected,
};
}
A checkout screen then branches on the result:
function CheckoutScreen() {
const { isConnected } = useNetworkStatus();
if (!isConnected) {
return <OfflineMessage />;
}
return <Checkout />;
}
The screen has no idea how network subscriptions are wired, and that ignorance is the whole value of the abstraction.
The rest of the Hook toolbox
Senior interviews tend to go beyond the five everyday Hooks, so know the wider set.
useContext
It reads a value provided higher in the tree.
const theme = useContext(ThemeContext);
It suits shared, cross-cutting concerns: theme, authentication, locale and configuration. It is not automatically a state-management library; every consumer re-renders when the provided value changes, which matters for frequently updating data.
useReducer
When several pieces of state change together, separate setters scatter the transition logic:
setLoading(...)
setData(...)
setError(...)
A reducer centralises it:
const [state, dispatch] = useReducer(reducer, initialState);
Components describe what happened and the reducer decides the next state:
dispatch({
type: "FETCH_SUCCESS",
payload: data,
});
Modelling transitions as named actions makes complex state easier to predict and to test.
useLayoutEffect
It has the same shape as useEffect but runs synchronously after React updates the tree and before the browser paints, which is what you need when you must measure layout and adjust before the user sees a flicker. React Native has comparable cases where layout timing matters. It is not a faster useEffect; because it blocks painting, overusing it hurts responsiveness.
useId
It generates stable unique IDs, mainly for accessibility relationships such as linking a label to an input. It shines on the web; in React Native, which has no browser DOM, the use cases are narrower.
useImperativeHandle
It lets a component choose exactly what an attached ref exposes, rather than leaking the underlying node. A reusable component might expose focusing:
ref.current?.focus();
or scrolling to the top:
ref.current?.scrollToTop();
That controlled imperative surface is particularly handy for shared React Native components.
useSyncExternalStore
It is the supported way to read from a store that lives outside React, giving consistent snapshots even with concurrent rendering. It is advanced, but it comes up in architecture discussions and is what state libraries build on.
useTransition
It marks some updates as non-urgent. Conceptually, urgent work keeps the interface responsive:
Urgent update
↓
Keep UI responsive
while non-urgent work may be scheduled at a lower priority:
Non-urgent update
↓
Can be scheduled with lower priority
This matters in large apps where an expensive re-render competes with typing or tapping.
useDeferredValue
It defers a value so that urgent interactions stay snappy. The classic scenario:
User typing
↓
Keep input responsive
↓
Defer expensive filtering
The input updates immediately while the heavy filtering catches up with a deferred copy of the query.
The Rules of Hooks
Two rules keep Hooks working, because React identifies each Hook by its call order.
Call Hooks at the top level
Never wrap a Hook in a condition, loop or early return:
if (isLoggedIn) {
useEffect(() => {});
}
Put the condition inside the Hook instead:
useEffect(() => {
if (!isLoggedIn) {
return;
}
and list the value it depends on:
// logic
}, [isLoggedIn]);
Call Hooks only from React functions
Hooks belong in function components and in custom Hooks, not in arbitrary helper functions or class methods.
Talking about performance
"This screen is slow, what would you do?" is a staple question. Answering "add useMemo and useCallback" signals the wrong instinct. A better answer:
The first step is finding out where the time actually goes: how often components render, which calculations are expensive, where component boundaries sit, how lists render, how state updates propagate, and what platform work is involved. Then profile and fix the real bottleneck.
React Native performance is wider than rendering
A mobile screen's cost is spread across several layers:
React rendering
↓
JavaScript execution
↓
Native platform work
↓
Layout
↓
Images
↓
Animations
↓
Lists
↓
Network
So when a React Native screen stutters, the culprit may not be
useState
or
useEffect
at all. It could be heavy JavaScript work, too many renders, large lists, image processing, animation load, native operations, network latency or state subscriptions that wake too many components. Knowing where to look across those layers is what distinguishes senior mobile experience.
A large list of transactions
Picture a finance app rendering thousands of transactions. The naive version recomputes the whole pipeline on every render:
const visibleTransactions = transactions
.filter(...)
.sort(...)
.map(...);
Memoising the filter and sort is one reasonable step:
const visibleTransactions = useMemo(() => {
return transactions
.filter(...)
.sort(...);
}, [transactions]);
It is rarely the whole answer. You also need list virtualisation, cheap item rendering, stable keys, memoised row components, narrow state subscriptions, sensible image loading and responsive interactions. In React Native, choosing FlatList or FlashList and configuring it well usually matters far more than one useMemo. The question to ask is where the bottleneck is, not which Hook to add.
State ownership and architecture
Context versus a store library
"When would you use Context instead of Redux or Zustand?" has no brand-name answer.
It depends on the state. Context works well for fairly stable, cross-cutting values like theme or configuration. For complex application state that updates often, needs selectors, predictable transitions or fine-grained subscriptions, a dedicated state library is usually the better fit.
The concepts that matter are ownership and update frequency.
Not everything belongs in a global store
A common architectural mistake is funnelling every value into one store:
Everything
↓
Global store
Ask instead who owns each piece of state. Local component state covers short-lived UI details:
isModalOpen
inputValue
selectedTab
Server state is data fetched from an API, often better handled by a dedicated server-state library that manages caching and refetching:
user profile
products
orders
transactions
Global client state is the small set of values many screens genuinely share:
theme
authentication state
application preferences
On the web, some state belongs in the URL so it survives reloads and can be shared as a link:
filters
search
pagination
The rule to remember: state should live at the narrowest scope that makes sense.
Putting it together on an investment screen
Imagine a React Native investment screen that needs market data, a selected symbol, chart data, portfolio data, AppState handling, scrolling and an expensive chart transformation. A simplified structure:
InvestmentScreen
│
├── useMarketData()
│
├── usePortfolio()
│
├── useAppState()
│
├── useRef()
│
├── useMemo()
│
└── useCallback()
│
▼
UI
A sketch of the component follows. Two things to notice: the snippet ends with a couple of prose lines and separators that are not valid code, so read it as pseudocode; and setSymbol from useState is already stable, so the useCallback wrapper here is optional, and you could pass the setter directly.
function InvestmentScreen() {
const [symbol, setSymbol] = useState("AAPL");
const scrollRef = useRef<ScrollView>(null);
const { data: marketData } = useMarketData(symbol);
const { portfolio } = usePortfolio();
const chartData = useMemo(() => {
return transformMarketData(marketData);
}, [marketData]);
const handleSymbolChange = useCallback(
(nextSymbol: string) => {
setSymbol(nextSymbol);
},
[]
);
return (
<ScrollView ref={scrollRef}>
<MarketHeader
symbol={symbol}
onSymbolChange={handleSymbolChange}
/>
<PriceChart data={chartData} />
<PortfolioSummary
portfolio={portfolio}
/>
</ScrollView>
);
}
---
Now each concept has a clear responsibility.
---
Each Hook now has a single, explainable responsibility:
useState
→ selected symboluseRef
→ imperative ScrollView accessuseMemo
→ expensive chart transformationuseCallback
→ stable handler where usefulCustom Hooks
→ market and portfolio behaviouruseEffect
→ internal synchronization within those Hooks
Effects stay inside the custom Hooks, where they synchronise with the market feed and the app lifecycle, and the screen itself reads almost like a description of its data.
Answering like a senior engineer
Go one level beyond the question
A common failure is answering only what was asked. For "what is useMemo?", "it memoises a value" is correct and forgettable. A fuller answer:
useMemo caches a computed value keyed on its dependencies. It is not a default, since it has overhead and adds complexity. It makes sense when an expensive calculation repeats unnecessarily or when a stable reference matters, ideally after profiling confirms the bottleneck.
In a few sentences you have shown API knowledge, performance awareness, trade-offs and judgement.
A five-step answer structure
A reliable template for any Hook question:
1. What is it?
2. Why does it exist?
3. Real example
4. Trade-off / pitfall
5. React Native example when relevant
Applied to useRef:
- What: a persistent mutable value that does not cause a render.
- Why: for data that must survive renders but is not part of the UI.
- Example: a timer ID or an imperative component handle.
- Trade-off: since updating it does not render, it must not hold UI state.
- React Native: a ref gives imperative control over a ScrollView.
Follow-ups to prepare for
For useState, be ready to discuss updater functions and batching of several calls, why mutating a plain variable leaves the screen unchanged, what triggers a re-render, where a given piece of state should live, and how to avoid state that is really derived.
For useEffect, expect questions on the role of the dependency array, stale closures, repeated runs, what the cleanup function is for, cancelling in-flight async work, situations where an effect is the wrong tool, and Strict Mode's double invocation in development.
For useRef, prepare to explain why writing to .current does not render, which situations favour a ref over state, how refs attach to DOM or native views, and what an imperative API means.
For useMemo, be clear on whether it always speeds things up, how it differs from useCallback, what referential equality means, when removing it is the right call, and how you would demonstrate that it helped.
For useCallback, know why a function's identity can matter, how it pairs with React.memo, when it adds nothing, whether it can slow things down, and what belongs in its dependency list.
A debugging exercise
Interviewers like handing over a flawed component. Here is one with several issues.
function UserList({ users }) {
const [search, setSearch] = useState("");
const filteredUsers = useMemo(() => {
return users.filter(user =>
user.name.includes(search)
);
}, [users]);
useEffect(() => {
console.log("Search:", search);
}, []);
return (
<List
data={filteredUsers}
renderItem={({ item }) => (
<UserRow
user={item}
onPress={() => selectUser(item.id)}
/>
)}
/>
);
}
Missing dependency in useMemo
The memo is keyed only on users:
}, [users]);
but the filter also reads
search
so the list never updates while the user types. The dependencies must include both:
}, [users, search]);
The lint rule would flag this immediately.
An effect frozen on the first value
This effect has an empty array:
useEffect(() => {
console.log(search);
}, []);
so it runs only after mount and logs the initial search. If the goal is to log each change, depend on it:
useEffect(() => {
console.log(search);
}, [search]);
The inline callback, handled with judgement
The row handler
onPress={() => selectUser(item.id)}
is a new function on every render. That alone is not a bug. First establish whether
UserRow
is expensive and whether it is really re-rendering unnecessarily. Only with that evidence would you reach for
useCallback
or restructure the components, for example by passing the id and a stable handler to the row. Showing that restraint is part of the expected answer.
Good, strong and senior answers
The progression is easy to remember. A good developer knows useCallback. A strong developer knows when to use it. A senior developer knows when not to, and can explain the trade-off. The same ladder applies to every Hook. The target is never
Maximum Hooks
but rather
Correct abstraction
+
Correct data flow
+
Correct performance trade-offs
One set of concepts for web and native
You do not need separate Hook knowledge for React and React Native. On the web, examples tend to involve
DOM
browser events
Web APIs
WebSockets
browser storage
while mobile discussions involve
AppState
Keyboard
native modules
device APIs
push notifications
network state
native event subscriptions
Yet the core toolkit is the same React on both:
useState
useEffect
useRef
useMemo
useCallback
useContext
useReducer
Custom Hooks
closures
dependency arrays
So explain the React concept first, then show how you apply it on the platform in question. That keeps you credible across web, mobile and hybrid roles.
Quick-reference cheat sheet
useState
State that triggers a render when updated. Typical uses:
form values
selected tabs
modal visibility
loading state
local UI state
useEffect
Synchronisation with external systems. Typical uses:
subscriptions
timers
WebSockets
AppState
native events
network operations
Avoid it for values you can derive during render.
useRef
A persistent mutable value that does not render. Typical uses:
DOM/native refs
timers
previous values
imperative APIs
mutable non-UI data
useMemo
A memoised computed value. Reach for it when
calculation is expensive
or
referential identity matters
and measure first.
useCallback
A memoised function reference. Worth it when
function identity matters
React.memo is involved
another Hook depends on function identity
Do not add it automatically.
useContext
Reads shared contextual data, such as:
theme
locale
authentication
configuration
useReducer
Models complex transitions. Useful when you have
many related state variables
complex transitions
state machine-like behaviour
Custom Hooks
Reusable stateful behaviour, for example:
useAuth()
useNetworkStatus()
useAppState()
usePagination()
useKeyboard()
useUserProfile()
A mental model instead of a glossary
Rather than memorising a one-word gloss per Hook,
useState = state
useEffect = side effect
useRef = reference
useMemo = memoisation
useCallback = callback
picture how they relate to a render: UI state flows through useState into re-renders, derived values come from useMemo, mutable non-UI data lives in useRef, and useEffect connects the rendered UI to APIs, events and native systems, with custom Hooks wrapping it all.
React Render
│
┌──────────┼──────────┐
│ │ │
UI State Derived Mutable
│ Values Data
│ │ │
useState useMemo useRef
│
▼
Re-render
│
▼
UI
│
▼
External Systems
│
▼
useEffect
│
▼
APIs / Events / Native
│
▼
Custom Hooks
Beneath all of it sit a handful of fundamentals:
Rendering
+
Closures
+
Referential Equality
+
Data Flow
+
Performance
Once those are solid, the individual Hooks become straightforward.
Questions to ask in every code review
Hooks are tools for managing different kinds of behaviour in a reactive app, and the skill that matters is knowing why you are using each one. These questions work in reviews as well as interviews:
- Does this state need to exist, or can it be derived?
- Is this effect actually synchronising with something external, and are its dependencies right?
- Could this callback be reading a stale closure?
- Does this ref hold imperative data, or UI state that should be in
useState? - Is this memoisation fixing a measured problem?
- Does this custom Hook hide useful complexity, or just add indirection?
- Should this state be local or global?
- Where is the real performance bottleneck?
Wrapping up
Hooks get easier once you stop treating them as separate APIs and see them as stages of one flow:
Rendering
↓
State
↓
Closures
↓
Effects
↓
References
↓
Memoisation
↓
Custom Hooks
↓
Performance
↓
Architecture
With that flow in mind, moving between React web and React Native is much less daunting. When someone asks why you used useMemo in a particular place, skip the textbook definition and walk through the story: the problem you saw, what caused it, why this Hook fitted, what trade-off you weighed, and how you measured the outcome.
Before an interview, check that you can talk naturally about:
- Fundamentals: the causes of a render, what a render snapshot is, how state differs from a plain variable, closures, referential equality and reconciliation.
- Hooks: the everyday five plus
useContextanduseReducer, the specialised ones (useLayoutEffect,useId,useImperativeHandle,useSyncExternalStore,useTransition,useDeferredValue) and how to design custom Hooks. - Effects: accurate dependencies, stale values in callbacks, cleanup and cancellation, subscription handling, Strict Mode behaviour and recognising when no effect is needed.
- Performance: how
React.memocompares props, the cost of memoisation, profiling renders, long lists, heavy computations and over-broad store subscriptions. - React Native: the app lifecycle via AppState, keyboard handling, native modules and device APIs, listeners registered on the native side, list tuning, the split between JavaScript and native work, and how the platform renders.
- Architecture: local versus shared state, client data versus server data, where custom Hook boundaries go, what each component is responsible for, who owns each value, and designing reusable component APIs.
The aim is not a memorised answer for everything, but enough understanding to reason through an unfamiliar problem in front of the interviewer.