Problem and Applicable Context
Consider a React 19.2 dashboard that subscribes to live orders:
function LiveOrders({ tenantId }: { tenantId: string }) {
const [filter, setFilter] = useState("all")
const [count, setCount] = useState(0)
useEffect(() => {
const connection = connect(tenantId)
connection.on("order", (order) => {
if (matches(order, filter)) {
setCount(count + 1)
}
})
return () => connection.close()
}, [])
// Rendering and controls omitted.
}After the user changes the filter, the callback still applies "all". Several orders in one burst may leave the count at 1. If the component receives a different tenantId, it remains connected to the first tenant. Adding every referenced value to the dependency list appears to fix freshness, but then every count or filter change closes and recreates the connection.
Explain why these failures occur, how to prove which value is stale, and how to preserve the subscription's intended lifetime while callbacks observe the latest committed values.
This is a realistic frontend interview question because public React interview guides in both English and Chinese explicitly test stale closures, Hook rules, and useEffect dependencies. The deeper skill is not memorizing one workaround. It is deciding whether a value should restart an Effect, participate in a state transition, or be read non-reactively by an Effect-owned callback.
What the Interviewer Is Evaluating
First, can the candidate explain the mechanism accurately? Each render receives a snapshot of props and state. Functions created during that render close over that snapshot. React does not mutate the local filter, count, or tenantId variables after a later render. If an external system retains the first callback, that callback continues to read the first render's values. The closure is behaving normally; the Effect's declared synchronization contract is incomplete.
Second, can the candidate separate four different requirements?
| Requirement | Correct tool | What it changes | |---|---|---| | The external resource must follow a reactive value | Effect dependency | Cleans up and resynchronizes the Effect | | Next state derives from previous state | Functional updater | Calculates from React's queued current state | | An Effect-owned callback needs latest committed values without resubscribing | useEffectEvent in React 19.2 | Reads current props and state non-reactively | | Imperative, non-rendered mutable data needs stable identity | Ref | Stores a manually synchronized mutable value |
Third, can the candidate resist false fixes? useCallback(fn, []) preserves function identity but also preserves its first-render closure. Suppressing exhaustive-deps hides evidence instead of changing semantics. Recreating a WebSocket for every counter increment may be fresh but is an incorrect resource lifetime.
Finally, can the candidate cover cleanup and asynchronous ordering? A fresh closure does not cancel an old request, prevent an out-of-order response, or remove a leaked event listener. Those are separate concurrency and lifecycle obligations.
Clarifying Questions Before Answering
- Which values define the subscription identity? If
tenantIdselects the server-side
stream, changing it must reconnect. A display filter usually should not.
- Should historical events be re-evaluated when the filter changes? This decides whether the
filter belongs only in the callback or also triggers a new query or subscription.
- Does the callback calculate from previous state? Incrementing a count should use a
functional updater even when the callback otherwise reads fresh values.
- Which React version is deployed?
useEffectEventis available in React 19.2. On an older
version, a carefully synchronized ref may be the compatibility fallback.
- Can the external API replace its listener without reconnecting? Some libraries expose
separate subscribe and updateHandler operations, which may support a different minimal lifetime.
- Are asynchronous requests started by the callback? If so, define cancellation or a request
generation check in addition to solving captured values.
- Is Strict Mode enabled in development? Its extra setup and cleanup cycle can expose missing
cleanup, but it does not create stale closures.
30-Second Answer Framework
“A React callback reads the props and state snapshot from the render that created it. Here the Effect runs only once, so the connection retains the first tenant, filter, and count. I would make the subscription depend on tenantId, because that value changes which resource we connect to. I would update the count with setCount(current => current + 1), because it derives from previous state. For the order callback to read the latest filter without reconnecting, on React 19.2 I would put that non-reactive logic in useEffectEvent and call it from the Effect-owned listener. I would not suppress the dependency linter or use useCallback([]) as a freshness fix. Refs are a manual compatibility option for non-rendered mutable data, and async results still need cancellation or versioning.”
Step-by-Step Deep Dive
Step one: model every render as an immutable snapshot.
During the first render, assume these values are:
tenantId = "tenant-a"
filter = "all"
count = 0The Effect creates a connection and a callback that references exactly those local variables. Later, setFilter("paid") produces another render with a new filter variable. It does not edit the variable captured by the original callback. Because the empty dependency list says the Effect never needs to synchronize again, the external connection continues to own the original callback.
That predicts all three bugs without guessing:
matches(order, filter)keeps using"all".- Each matching event calls
setCount(0 + 1), so repeated events replace state with1rather
than composing increments.
- The connection remains associated with
"tenant-a"after a prop change.
A useful debugging log includes a render sequence number, the values seen during render, and the values seen inside the retained callback. Log the connection ID separately. This reveals whether the callback is stale, the subscription did not restart, or the server sent unexpected data.
Step two: classify each read by semantics before editing dependencies.
Ask why the Effect reads each value:
tenantIddetermines which external stream is synchronized. It is reactive and belongs in
the dependency list.
countis only needed to calculate the next count. Replace the read with a functional updater,
so it no longer needs to be captured.
filteraffects how a future event is handled, but changing it should not tear down the tenant
connection. The listener needs a latest-value read without making the subscription reactive to the filter.
This classification prevents the two extremes of an empty dependency list and a dependency list that restarts an expensive resource on every render-related change.
Step three: fix state transitions with functional updaters.
Change this:
setCount(count + 1)to this:
setCount((current) => current + 1)React queues the updater and passes it the current pending state when the next render is calculated. Ten events therefore compose ten increments even if their callback was registered earlier or React batches the updates. This only fixes the count dependency. It does not make filter or tenantId fresh.
Step four: declare dependencies that genuinely resynchronize the resource.
The connection's identity includes tenantId, so the Effect must depend on it. On a tenant change, React first runs the previous cleanup and then sets up the new connection. The cleanup must remove listeners or close the old connection so events from the previous tenant cannot update the current screen.
Adding filter and count would technically give each new callback current values, but it would also redefine the connection's lifetime. A counter update would cause a disconnect and reconnect. That can lose events, duplicate events during overlapping cleanup, reset server-side cursor state, and create unnecessary load. A dependency list is a behavioral specification, not a performance hint to be edited until the linter becomes quiet.
Step five: use an Effect Event for latest non-reactive reads in React 19.2.
The corrected component can keep connection lifetime and callback freshness separate:
function LiveOrders({ tenantId }: { tenantId: string }) {
const [filter, setFilter] = useState("all")
const [count, setCount] = useState(0)
const onOrder = useEffectEvent((order: Order) => {
if (matches(order, filter)) {
setCount((current) => current + 1)
}
})
useEffect(() => {
const connection = connect(tenantId)
connection.on("order", onOrder)
return () => connection.close()
}, [tenantId])
// Rendering and controls omitted.
}onOrder always observes the latest committed filter, while the Effect still resynchronizes only when tenantId changes. An Effect Event is called from an Effect or code connected to that Effect, such as the listener it registers. It is not a general event-handler replacement, should not be passed arbitrarily through the component tree, and must not be used to conceal a value that really should restart synchronization.
Do not add the Effect Event to the dependency list. Current React linting understands its non-reactive role. If the linter reports a different value, treat that warning as design feedback and change the code structure rather than disabling the rule.
Step six: understand when a ref is appropriate and what it costs.
Before React 19.2, a common compatibility pattern stores the latest value in a ref:
const filterRef = useRef(filter)
useEffect(() => {
filterRef.current = filter
}, [filter])
useEffect(() => {
const connection = connect(tenantId)
connection.on("order", (order) => {
if (matches(order, filterRef.current)) {
setCount((current) => current + 1)
}
})
return () => connection.close()
}, [tenantId])The ref object is stable across renders, and changing current does not trigger rendering. That makes refs appropriate for imperative values such as a latest handler, timer ID, or external API handle that is not itself displayed. The cost is manual synchronization: the dependency linter cannot prove that filterRef.current is updated correctly, and a missing synchronization Effect silently reintroduces stale behavior. Do not move displayed state into a ref merely to avoid a render, and do not read or write refs during rendering except for documented initialization patterns.
Step seven: keep useCallback and memoization in their proper role.
useCallback caches a function identity until one of its dependencies changes. It is useful when a memoized child or an external API cares about identity. It does not provide latest values by itself:
const onOrder = useCallback((order: Order) => {
if (matches(order, filter)) {
setCount((current) => current + 1)
}
}, [])This version still captures the initial filter. Adding [filter] refreshes the function, but the external connection must then replace its listener correctly. If listener removal requires the same function identity, setup and cleanup must use the same callback instance for that render. Memoization answers an identity question; it does not answer the synchronization-lifetime question.
Step eight: solve asynchronous ordering separately.
Suppose the latest filter starts a request. The "all" request may resolve after the newer "paid" request and overwrite its results. Even a callback with the latest filter cannot prevent that older request from completing. The Effect should abort obsolete work with an AbortController when supported, or associate each request with a monotonically increasing generation and commit only the latest generation.
Cleanup must also be symmetrical:
- close the exact connection created by that setup;
- remove the exact listener registered by that setup;
- clear intervals and timeouts;
- abort or invalidate pending asynchronous work;
- make late callbacks harmless after cleanup.
Strict Mode's development-only setup-cleanup-setup sequence is useful evidence here. Duplicate connections indicate an incomplete cleanup. It is not evidence that Strict Mode caused the stale closure.
Step nine: verify behavior by changing one dimension at a time.
| Test | Expected result | |---|---| | Emit three matching orders before React renders again | Count increases by three | | Change filter, then emit an order | Listener applies the new filter without reconnecting | | Change tenantId | Old connection closes once; new tenant connects once | | Emit from the old connection after cleanup | Current UI does not change | | Resolve two requests in reverse order | Only the latest request may commit | | Run under Strict Mode in development | Setup and cleanup remain symmetric; no duplicate listener | | Re-enable the Hook dependency lint rule | No suppressed or unexplained dependency warning remains |
In production, track connection setup and cleanup counts, active connection IDs, tenant switches, late-event drops, and aborted requests. Do not log private order payloads merely to diagnose callback freshness.
High-Quality Sample Answer
“I would start from React's render model. Every render gets a snapshot of props and state, and a function created in that render closes over that snapshot. Because this Effect has an empty dependency list, the external connection keeps the callback from the first render. It therefore sees the first tenant, filter, and count.
I would classify each value before changing the dependency list. tenantId selects the external resource, so it belongs in the dependencies and a change must close the old connection and open the new one. count is only used to derive next state, so I would call setCount(current => current + 1). That makes burst updates compose from React's queued state. The latest filter is needed when an Effect-owned order listener fires, but it should not restart the connection. Since this application uses React 19.2, I would put the filtering logic in useEffectEvent and register that Effect Event from an Effect that depends only on tenantId.
I would not suppress exhaustive-deps, because the dependency list describes synchronization. I would not use useCallback([]) as a freshness fix, because it retains the initial closure. On an older React version, a ref synchronized from filter can be a compatibility fallback, but that is manual and invisible to dependency analysis.
Finally, I would test burst increments, a filter change without reconnecting, a tenant change with exactly one cleanup and setup, an event arriving from a closed connection, and development Strict Mode. If callbacks launch requests, I would also abort or version obsolete requests, because closure freshness alone does not prevent out-of-order results.”
Common Mistakes
- Calling every closure a bug → callbacks are expected to capture a render snapshot →
identify the mismatch between the retained callback and the intended synchronization.
- Suppressing
exhaustive-deps→ the code promises that reactive reads never matter while
still using them → change the code so the dependency list truthfully describes the Effect.
- Adding every state value to dependencies → freshness is restored by recreating an expensive
subscription after every update → separate resource identity from latest callback reads.
- Using
useCallback(fn, [])→ stable identity also freezes the first closure → **give
useCallback correct dependencies or use the tool that matches the desired semantics.**
- Replacing state with a ref → ref writes do not render and can diverge from visible UI →
keep displayed data in state and reserve refs for imperative mutable data.
- Using
useEffectEventto hide a real dependency → the Effect no longer reacts when its
external resource should change → keep resource-defining values in the dependency list.
- Fixing the filter but keeping
setCount(count + 1)→ burst events still replace from a
captured count → use a functional updater for state derived from prior state.
- Ignoring cleanup and request ordering → leaked listeners and late responses still corrupt
the screen → remove, close, abort, or version every operation according to its lifecycle.
Follow-Up Questions and Responses
Follow-up 1: Why does setTimeout inside a click handler see an old state value?
The timeout callback belongs to the render in which the click occurred. Updating state schedules a new render; it does not mutate that handler's local state variable. If the delayed action is supposed to report the value at click time, the snapshot is correct. If it must use the latest committed value, model that requirement explicitly with an Effect Event in Effect-related code or a carefully maintained ref for an imperative callback.
Follow-up 2: Why not put filter in the Effect dependency list?
That is correct only if changing the filter should resynchronize the external system. If the server subscription itself is filter-specific, include it and reconnect. In this scenario the tenant stream stays the same and the filter is local event-processing policy, so reconnecting would give the wrong lifetime. State the product and API assumption before choosing.
Follow-up 3: Does useEffectEvent replace ordinary event handlers?
No. A button click is normally handled by an event handler created during rendering. An Effect Event is for non-reactive logic invoked by an Effect or code connected to it, such as a timer or subscription listener. It should remain local to the Effect that uses it and should not become a general callback transport mechanism.
Follow-up 4: Can a functional updater solve every stale closure?
No. It solves only a state transition that derives from that same state's previous value. It cannot make a prop, another state variable, or an external configuration current. In this example it fixes count increments but not the tenant connection or filter read.
Follow-up 5: When is a ref preferable to state?
Use a ref when a value must persist across renders, changes should not render the UI, and the value is used imperatively: a timer ID, DOM node, connection handle, or compatibility latest-value cell. If the rendered output depends on it, use state. A ref shifts freshness responsibility to the developer, so document and test its synchronization point.
Follow-up 6: How can Strict Mode help debug this issue?
Development Strict Mode runs an extra setup and cleanup cycle for Effects. If two listeners or connections remain, cleanup is incomplete or uses the wrong callback identity. Once cleanup is correct, the extra cycle should leave one active subscription. The stale snapshot itself follows normal JavaScript closure semantics in both modes.
Follow-up 7: What if an event fires between cleanup and the new connection?
Define the external system's delivery guarantee. The client may need a resumable cursor, sequence number, replay window, or a snapshot followed by a stream. A React dependency change can manage local lifetime, but it cannot guarantee lossless delivery across a server reconnection. That is a protocol requirement and should be tested separately.
Follow-up 8: How do you prevent an older fetch from overwriting newer results?
Abort the obsolete request during Effect cleanup when the API supports cancellation. Otherwise, assign a generation to each request and update state only when the completing generation is still current. A latest-value ref alone does not cancel network work and should not be presented as an ordering guarantee.