Have you ever written a line of React code, looked at the output in your browser, and thought: "Wait... why did it do that?"
Here is the classic code snippet that every single React developer writes at least once:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count);
}
return <button onClick={handleClick}>Count: {count}</button>;
}
You click the button. The button text updates on the screen from 0 to 1. But in your browser console, it logs:
0
Not 1. Zero.
At first, you think it is just a tiny delay. So you click it again. The button updates to 2, but the console logs 1. It is always one step behind.
Why?
When I first ran into this years ago, someone on a forum told me: "Oh, setState is asynchronous." And for months, I accepted that answer. But that answer is not the whole truth. In fact, thinking of state as a "variable that updates asynchronously" is the exact mental model that causes hundreds of bugs in production apps.
Today, we are going to look under the hood. No fluff. No textbook definitions. We are going to look at the real data structures, the Fiber tree, why hook rules exist, and how React actually turns your JavaScript into pixels on a screen.
Grab your favorite coffee. Let's dig in.
The Mental Model Trap: What Beginners Think vs Reality
When most of us learn React, our brain builds a very simple model of how it works:
- You call
setCount(1) - React goes into your HTML
- It changes
<button>Count: 0</button>to<button>Count: 1</button> - Done.
If this were how React worked, React would be terrible. Directly searching and mutating the browser DOM on every state update is slow and expensive. The browser has to recalculate styles, recalculate layout geometry (reflow), and repaint pixels.
Instead, React does something fundamentally different:
React does not update the DOM when you call setState. It schedules a calculation to run in the future.
Let's break down the three distinct phases that happen every single time your component updates.
The Three Phases: Trigger, Render, Commit
Think of React like a high-end restaurant kitchen.
- Trigger Phase: The customer places an order.
- Render Phase: The chef cooks the food and plates it on the counter (in private, inside the kitchen).
- Commit Phase: The waiter takes the dish and sets it on the customer's table.
If the customer changes their mind halfway through cooking, the chef can throw away the half-cooked plate without the customer ever seeing it. But once the plate hits the table, that is the commit.
Here is how that maps to React:
[User Interaction]
|
v
1. TRIGGER PHASE (dispatchSetState queues an update request)
|
v
2. RENDER PHASE (Pure JS calculations: calls your component, diffs Fiber tree)
|
v
3. COMMIT PHASE (Synchronous DOM mutations: document.createElement, appendChild)
|
v
4. BROWSER PAINT (The browser repaints the pixels on your monitor)
Let's walk through each one in detail.
1. The Trigger Phase
When you call setCount(count + 1), your component function does not immediately run again.
Instead, React creates an Update Object and puts it into an update queue attached to that component's internal representation. React marks this component as "dirty" and schedules work with the React Scheduler.
If you click a button three times rapidly, or update three different state variables inside the same event handler, React batches them together. It does not run the kitchen three times for three breadsticks. It collects the whole order first.
2. The Render Phase (Pure JavaScript)
This is the phase that confuses people the most because of the name.
When developers hear "render", they think of the screen drawing pixels. In React, rendering just means calling your component function.
React calls Counter(). Your function runs from top to bottom and returns JSX.
Now, what is JSX?
return <button className="btn">Click me</button>;
Under the hood, the compiler (Babel or Vite) turns that JSX into a regular JavaScript function call:
return React.createElement('button', { className: 'btn' }, 'Click me');
And React.createElement() simply returns a plain, lightweight JavaScript object:
{
type: 'button',
props: {
className: 'btn',
children: 'Click me'
},
key: null,
ref: null
}
That is all a "Virtual DOM node" is. It is just a plain JavaScript object describing what the UI should look like. It has no connection to the real browser DOM yet.
React takes this newly returned object tree and compares it to the previous object tree. This comparison is called Reconciliation.
React asks: "Did the type change? No, still a button. Did className change? No, still 'btn'. Did the text change? Yes, from '0' to '1'."
React notes down a small patch instruction: Update button text to 1.
3. The Commit Phase
Now that React knows the exact minimum set of changes required, it enters the Commit Phase.
This phase is synchronous and cannot be interrupted. React reaches into the real DOM using native browser APIs:
domNode.textContent = '1';
Only the specific text node that changed gets touched. The rest of the DOM tree is completely untouched.
Once the commit phase finishes and layout effects run, the browser engine takes over and paints the updated pixels onto your monitor.
Why Hook Order Matters: The Fiber Linked List
Have you ever wondered why React has that strict, almost annoying rule:
"Do not call Hooks inside loops, conditions, or nested functions."
If you break this rule, your console explodes with a terrifying error:
Error: Rendered fewer hooks than expected. This may be caused by an accidental early return statement.
Why does this happen? Does React look up your state by variable name?
const [name, setName] = useState("Dan");
const [age, setAge] = useState(25);
When you write const [name, setName], JavaScript does not tell React the word "name". The variable name name is completely local to your function. React has no idea what you named it.
So how does React know that the first useState belongs to name and the second one belongs to age?
React stores your hooks as a singly-linked list on the component's Fiber node.
Every component on your screen is represented internally by a FiberNode object. On that Fiber node, there is a property called memoizedState.
When your component renders for the first time, React builds a chain of hook objects:
FiberNode (<UserProfile />)
|
v
memoizedState ──> [ Hook 0: useState ('Dan') ]
│
▼ (next)
[ Hook 1: useEffect (fetchData) ]
│
▼ (next)
[ Hook 2: useState (25) ]
│
▼ (next)
null
On every subsequent render, React does not create a new list. It simply sets an internal pointer called workInProgressHook to the very first hook in the list:
- You call
useState(): React reads Hook 0 ('Dan'). Advances pointer to Hook 1. - You call
useEffect(): React reads Hook 1. Advances pointer to Hook 2. - You call
useState(): React reads Hook 2 (25). Advances pointer to null.
Now look at what happens if you put a hook inside an if statement:
function UserProfile({ isLoggedIn }) {
const [name, setName] = useState("Dan"); // Hook 0
if (isLoggedIn) {
useEffect(() => { // Hook 1 (Conditional!)
fetchUserData();
}, []);
}
const [age, setAge] = useState(25); // Hook 2
return <div>{name}, {age}</div>;
}
Imagine isLoggedIn was true on the first render. React created three hook records: [name, effect, age].
On the second render, isLoggedIn becomes false.
- Line 2 calls
useState(): React reads Hook 0 (name). Pointer advances to Hook 1. - Line 4 condition is false!
useEffectis skipped. - Line 10 calls
useState(): React reads Hook 1! But Hook 1 was supposed to be the effect object, not youragestate!
The data is completely scrambled. React detects that the number of hooks called does not match the linked list, throws an error, and halts execution before it corrupts your data.
This is why hook order must remain 100% stable across every single render.
The Snapshot Mental Model: Why State is a Constant
Let's return to the mystery we started with:
function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // Prints 0!
}
return <button onClick={handleClick}>{count}</button>;
}
To truly understand this, stop thinking of count as a variable that changes over time.
Instead, understand this rule:
In any single render of your component, props and state are constants.
When React calls your component function the first time, it is effectively executing:
// Render #1
function Counter() {
const count = 0; // Fixed constant for this entire execution!
function handleClick() {
setCount(0 + 1);
console.log(0); // It literally logs 0
}
return <button onClick={handleClick}>0</button>;
}
Inside that function call, count is 0. It will never be anything other than 0 inside that specific execution.
When you call setCount(count + 1), you are not changing the local count variable. You cannot change a const!
You are telling React: "Hey React, when you get around to running my component again, please give me 1 for count."
React schedules a new render. When that new render runs a few milliseconds later, React calls Counter() again. But this is a completely brand new function call:
// Render #2 (A fresh function execution)
function Counter() {
const count = 1; // Fixed constant for THIS execution!
function handleClick() {
setCount(1 + 1);
console.log(1);
}
return <button onClick={handleClick}>1</button>;
}
Every single render has its own count, its own event handlers, and its own local scope. This is the power of JavaScript closures.
The Async Trap
Once you understand snapshots, you will instantly understand bugs like this:
function Chat() {
const [message, setMessage] = useState("");
function handleSend() {
setTimeout(() => {
alert("Sent message: " + message);
}, 3000);
}
return (
<div>
<input value={message} onChange={e => setMessage(e.target.value)} />
<button onClick={handleSend}>Send after 3 seconds</button>
</div>
);
}
Try this:
- Type
"Hello"into the input. - Click "Send after 3 seconds".
- Immediately change the input text to
"Goodbye". - Wait for the alert.
What does the alert say?
It says: Sent message: Hello.
Why? Because the handleSend function was created during the render where message was "Hello". The setTimeout callback closed over that snapshot. Even though the input on screen updated, the callback was holding onto the photograph of the state at the moment you clicked the button.
If you ever actually need to read the current live value inside an asynchronous callback without waiting for a re-render, that is exactly what useRef is for:
const messageRef = useRef(message);
messageRef.current = message; // Always points to latest value
Why Components Re-Render: The Cascade Myth
Ask five developers why a React component re-renders, and at least three of them will say:
"A component re-renders when its props change."
This is one of the most common myths in frontend development.
Let's test it:
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Increment</button>
<ExpensiveChild />
</div>
);
}
function ExpensiveChild() {
console.log("ExpensiveChild re-rendered!");
return <p>I take 50ms to render.</p>;
}
Notice that <ExpensiveChild /> takes zero props. It has no state. Nothing passed to it changes.
Click the "Increment" button in Parent.
Does ExpensiveChild re-render?
Yes. Every single time.
Here is the real rule of React rendering:
The Real Trigger Rule
A component re-renders if and only if:
-
Its own state changed (via
useStateoruseReducer) -
A Context it subscribes to changed (via
useContext) - Its parent component re-rendered
By default, when a parent component re-renders, React recursively re-renders all of its children, grandchildren, and descendants, regardless of whether their props changed.
Why does React do this? Because in 95% of web apps, calling a JavaScript function to return a couple of virtual DOM objects takes 0.01 milliseconds. React assumes rendering is cheap and safe.
How to Stop the Cascade Without Overusing useMemo
Beginners often panic when they see child components re-rendering and immediately wrap every single function in useCallback and every component in React.memo.
Before you do that, there is a much cleaner, built-in technique called Component Composition.
Look at this refactor:
// 1. Move the state into its own small wrapper
function CounterContainer({ children }) {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Increment: {count}</button>
{children}
</div>
);
}
// 2. Pass ExpensiveChild as a child prop
function App() {
return (
<CounterContainer>
<ExpensiveChild />
</CounterContainer>
);
}
When you click the increment button inside CounterContainer, CounterContainer re-renders.
But ExpensiveChild does NOT re-render!
Why? Because <ExpensiveChild /> was created inside App, not inside CounterContainer. To CounterContainer, children is just an existing prop object that has not changed reference (prevProps.children === nextProps.children). React sees the identical object reference and skips rendering the entire child subtree!
Zero React.memo. Zero useCallback. Just clean component architecture.
Why Keys Matter: The List Diffing Mystery
Have you ever rendered a list of inputs in React, deleted the first item, and watched the input text stay behind on the wrong row?
function TodoList() {
const [items, setItems] = useState(['Buy milk', 'Walk dog', 'Read book']);
function removeFirst() {
setItems(items.slice(1));
}
return (
<div>
<button onClick={removeFirst}>Delete First</button>
<ul>
{items.map((item, index) => (
// BAD: using index as key
<li key={index}>
<input defaultValue={item} />
</li>
))}
</ul>
</div>
);
}
You click "Delete First".
You expect "Buy milk" to disappear, and the first input to show "Walk dog".
Instead, the input still says "Buy milk", but the third input disappears!
Why does this happen?
React's diffing algorithm compares children by two things: Element Type and Key.
When you use array indices as keys:
-
On Render 1:
- Key
0->Buy milk - Key
1->Walk dog - Key
2->Read book
- Key
-
On Render 2 (after removing 'Buy milk'):
- Key
0->Walk dog - Key
1->Read book
- Key
React compares the old key 0 with the new key 0. Both have key 0. Both are <li><input /></li>.
React says: "Great! Key 0 is the same element. I will keep the existing DOM node and its internal state."
Because the <input /> has local browser DOM state (the text you typed), React keeps the old input box and does not update it. Then React looks at Key 2. Key 2 is missing in the new list, so React deletes the third DOM node!
The Golden Rule of Keys
Keys are not just there to silence the yellow React warning in your browser console.
A key is a persistent identity for a component across renders.
When you give an element a stable, unique key (like key={item.id}):
- React matches the exact item even if its position moves from index 0 to index 10.
- Reordering a list simply moves DOM nodes instead of destroying and recreating them.
- Component state stays attached to the correct data item.
Build Your Own Mini-React in 25 Lines of Code
The best way to solidify your mental model of React is to see how simple the core idea really is.
Open your browser's developer console (F12) right now, paste this code, and press Enter:
// A 25-line mental model of React's state engine
const MiniReact = (() => {
let hooks = [];
let currentHook = 0;
function useState(initialValue) {
const hookIndex = currentHook;
hooks[hookIndex] = hooks[hookIndex] !== undefined ? hooks[hookIndex] : initialValue;
const setState = (newValue) => {
hooks[hookIndex] = newValue;
render(); // Trigger re-render
};
currentHook++;
return [hooks[hookIndex], setState];
}
function render(Component) {
if (Component) MiniReact.activeComponent = Component;
currentHook = 0; // Reset pointer for next render pass
const output = MiniReact.activeComponent();
console.log("Rendered UI:", output);
return output;
}
return { useState, render };
})();
// Try it with a component!
function MyComponent() {
const [name, setName] = MiniReact.useState("Alex");
const [count, setCount] = MiniReact.useState(0);
return {
text: `Hello ${name}, click count is ${count}`,
click: () => setCount(count + 1),
changeName: (n) => setName(n)
};
}
// Initial render
let app = MiniReact.render(MyComponent);
// Simulate clicking button
app.click();
// Simulate changing name
app.changeName("Sarah");
Notice what happened in that tiny snippet:
-
hooksis a simple array storing values outside the component. -
currentHookis an index that resets to 0 before every render. - When you call
useState, it reads fromhooks[currentHook]and increments the index. - Calling
setStateupdates the array item and triggersrender().
Real React uses a linked list on Fiber nodes with priority queues and concurrent scheduling, but the fundamental architecture is identical to those 25 lines.
There is no magic. It is just arrays, pointers, and functions calling functions.
The Complete React Mental Model Cheat Sheet
Here is a quick reference table to keep bookmarked:
| Question | The Common Myth | What React Actually Does |
|---|---|---|
What does setState do? |
Directly updates the DOM variable | Adds an update to the Fiber queue and schedules a render |
Why is console.log stale? |
State update is slow / asynchronous | State is a constant snapshot inside that render's closure |
| Why can't hooks be conditional? | React searches hooks by variable name | Hooks are stored as an ordered linked list; skipping one corrupts all indexes |
| When do children re-render? | Only when their props change | Whenever their parent re-renders (unless memoized or passed as children) |
| What does JSX return? | Real HTML DOM elements | A plain JavaScript object (React.createElement) |
| What is the Commit phase? | Calculating virtual DOM diffs | Synchronously writing calculated DOM mutations to the browser |
| Why do keys matter? | Just a requirement to stop console warnings | Provides persistent identity so React reuses DOM nodes accurately |
Wrapping Up
When you stop treating React like a black box of magic and start understanding the mechanical pipeline underneath, everything changes:
- You stop writing defensive
useEffectchains to "sync" state. - You stop being surprised when
console.loglogs the previous value. - You know exactly why and when your components re-render.
- You write code that runs faster and has fewer bugs.
The next time you see someone scratching their head over setState or hook errors, you can smile and tell them:
"It is not magic. It is just a snapshot and a linked list."
Let's Discuss!
I would love to hear from you in the comments:
- What was the first React behavior that completely broke your brain when you were starting out?
- Have you ever been burned by the array index key bug in production?
- How do you usually handle unnecessary re-renders in your projects: composition or memoization?
Drop your thoughts below. I read and reply to every comment!





Top comments (24)
The absolute canonical developer rite of passage is putting a console.log right after a setState call, wondering why the value hasn't updated yet, and then questioning your entire career choices. Understanding that React isn't just magic DOM-patching is a massive breakthrough.
Haha, absolutely! 😄 That console.log right after setState has probably confused almost every React developer at some point.
The interesting part is that once you understand the render snapshot and how the handler closes over that particular render, the behavior stops feeling asynchronous or magical. It becomes much easier to predict why the value hasn't changed inside that same handler.
That mental shift from “React patches the DOM” to understanding the render and commit process is really the part that makes React click. Appreciate the comment!
Every single React dev has been gaslit by a console.log right after setState at least once early in their career lol. Once you wrap your head around closures and render snapshots, the whole 'magic' disappears and you finally stop fighting the framework. It's a massive milestone when that clicks.
Haha, exactly! 😂 That moment when React finally clicks is such a relief. Once you realize each render has its own state snapshot and event handlers close over that snapshot, a lot of those confusing behaviors suddenly make sense. You're no longer fighting React, you're just working with how it was designed. 😄
Spot on! Once you stop thinking of state as a live variable and start seeing it as an immutable snapshot per render, the mental model completely stabilizes. It transforms closures from a debugging nightmare into React’s core feature.
To take this mindset even further into production code, watch out for the async updater trap. Since event handlers capture the state snapshot of that specific render, trying to read state immediately after calling a setCount will always give you the old value.
Instead of fighting the snapshot via raw variable reading, always use the functional updater pattern—like setCount(prev => prev + 1)—whenever your next state explicitly depends on the previous one. This guarantees execution safety regardless of the render cycle closures.
Hands down one of the best explanations of the React rendering pipeline I've read !
To answer your question: the closure snapshot concept (useState stale values inside setTimeout or event handlers) was definitely the biggest brain-breaker for me early on. I spent hours debugging why console.log wasn't showing the "updated" state!
Also, big +1 for highlighting component composition (children) over blindly wrapping everything in useCallback and React.memo. Clean architecture solves 90% of re-render issues before performance tools are even needed. Thanks for taking the time to break this down so clearly !
Really glad you enjoyed it! 😄 That
useStatesnapshot behavior trips up so many developers because we expect the variable to update like a normal mutable value. And you're spot on about composition. A good component structure often eliminates the need for memoization before you even reach for performance tools!tahosin, this is a masterclass in demystifying react. the "snapshot" mental model is the ultimate unlock that stops developers from fighting the framework.
but your point about component composition over
react.memois the real gold here, especially from a mobile-first perspective. on a $150 phone, avoiding the overhead of shallow prop comparisons inreact.memois great, but using composition to literally prevent the child's render phase from even triggering is the ultimate performance hack. it keeps the js thread free and the ui buttery smooth on low-end devices.the 25-line mini-react snippet is the perfect cherry on top. it proves there's no magic, just elegant data structures and pointers. thanks for the incredible, no-fluff breakdown! 🐯
Really appreciate this, Harun! 🙌 The mobile-first angle is especially important because unnecessary work on the JS thread can have a noticeable impact on lower-end devices.
I also like that you highlighted composition as an architectural choice rather than just another optimization trick. Of course, composition doesn't eliminate every re-render, but placing state closer to where it's needed can prevent updates from cascading through large parts of the tree. And when profiling shows a component is genuinely expensive, that's where React.memo can still be useful. It's about choosing the right tool instead of optimizing everything by default.
tahosin, exactly! on a $150 phone, blocking the js thread for even a few milliseconds is immediately visible to the user, so the mobile-first angle isn't just theoretical—it's a necessity.
treating composition as an architectural choice rather than a premature optimization is the mindset shift that saves so much headache. and i completely agree:
react.memois a scalpel, not a hammer. profile first, optimize second. thanks for adding that crucial nuance! 🐯⚛️Great Article!
I’m new to Dev.to and recently started sharing my posts here. I’d really appreciate your feedback and suggestions on my posts. It would help me improve and create better content. Thanks!
Thanks, Rahul! Glad you enjoyed the article, and welcome to DEV.to! 🙌 I'd be happy to check out your posts. Drop the links here so I can take a look and share some constructive feedback. Looking forward to seeing what you're building!
For me, the biggest “wait, what?” moment was definitely the state snapshot behavior. I used to think
setStatechanged the value right away, so seeing the old value inconsole.logwas pretty confusing at first.I’ve also dealt with the index-as-key issue in dynamic lists, and yeah, it can lead to some really weird UI behavior.
For unnecessary re-renders, I usually lean toward composition first and use memoization when there’s a real performance reason for it. I try to keep the component structure simple before reaching for optimization.
Really enjoyed this one. The mental model explanation makes a lot of React behavior easier to reason about.
Thanks, Anthony! Sounds like you've run into all three of the classic React gotchas 😄 I really agree with your approach of starting with composition before reaching for memoization. A lot of the time, putting state in the right place solves the problem without adding extra complexity. And the index-key bug is a perfect example of how understanding reconciliation helps explain behavior that otherwise seems completely random!
The mini-React snippet was my favorite part of this post. It made the hook-order rule click in a way that docs never did for me. I also appreciated the advice to prefer component composition over sprinkling useMemo everywhere, since I have definitely been guilty of that. One question: with concurrent rendering, can the render phase actually be discarded and restarted mid-work, and if so, how do you guard against accidentally doing something side-effect-like inside what is supposed to be a pure render?
Thanks, Arjun! The hook-order rule becomes much easier to understand once you see that React associates hook state with call order rather than variable names.
And yes, concurrent rendering can absolutely abandon an in-progress render and start again. That's why render logic needs to be pure: don't mutate external state, perform network requests, or trigger other side effects directly in the component body. React may execute that code more than once, even if a particular render never gets committed.
Keep side effects in event handlers or the appropriate effects, depending on what triggers them. Strict Mode in development is also useful here because it helps expose render logic that isn't safe to run repeatedly. Really glad the mini-React example helped make the concept click!
That restaurant kitchen analogy for Trigger Render Commit is lowkey the clearest explanation I have seen. Most devs get stuck on setState is async and just add setTimeout hacks without understanding why the stale closure happens. The Fiber tree concept is what clicked for me too. The kitchen metaphor works because it separates the work from the output perfectly. One thing I would add is how concurrent mode changes the Render phase timing since the chef can now pause mid prep. Did you plan to cover Suspense as part of this series?
Really glad the kitchen analogy clicked for you! 😄 That's exactly why I wanted to separate Trigger, Render, and Commit instead of just saying setState is async and leaving it at that.
And yes, the chef pausing mid-prep is a great way to think about concurrent rendering. React can interrupt rendering work to prioritize something more urgent, then resume or restart it later. The important distinction is that the Render phase can be interrupted or discarded, while the Commit phase applies the changes.
Suspense is definitely a natural next step for this series. It adds another interesting layer to how React coordinates rendering when a component needs to wait for something. Thanks for the suggestion! 🙌
Bài viết chạm đúng vào điểm mấu chốt: phần lớn dev React chỉ học "cách dùng" chứ không hiểu "cách nó chạy". Mental model sai thường xuất phát từ việc nghĩ React re-render component function như gọi hàm thông thường — thực tế nó là quá trình reconciliation trên fiber tree, nơi work được scheduled, prioritized, và có thể bị interrupt.
Một ví dụ điển hình: dùng
useMemo/useCallbacklung tung vì nghĩ "tránh re-render con", nhưng quên rằng reference stability chỉ có ý nghĩa khi parent cùng memoize (React.memo) và deps thực sự ổn định. Ngược lại, over-memoize lại làm tăng memory pressure cho GC và làm phức tạp dependency tracking.Điểm hay nhất bài viết là nhấn mạnh "render ≠ commit". Nhiều người debug bằng console.log trong render body rồi hoang mang thấy log chạy 2 lần (StrictMode) hoặc chạy mà DOM không đổi — không hiểu phase render có thể bị throw away nếu có higher priority work割入.
Một góc ít được nói: custom renderer (React Native, Three.js, terminal) chứng minh mental model đúng phải tách biệt reconciliation algorithm (generic) với host config (platform-specific). Hiểu rõ fiber tags, lanes, effect tags thì mới debug được những case lạ như layout shift do useLayoutEffect timing, hoặc hydration mismatch do server/client tree divergence.
Cảm ơn tác giả đã gom gọn những khái niệm core này — nó là nền tảng để từ "viết code chạy được" tiến sang "viết code chạy đúng, hiệu quả, debug được" PS: the tool I meant is on labagent .tech
The snapshot model is the one I wish someone had tattooed on my forehead earlier. The place it burned me in a real SaaS dashboard: a polling setInterval inside useEffect that closed over a stale filters object. The network tab showed the same query params forever while the UI cheerfully displayed new filters — the callback was holding a snapshot from the first render. I used the useRef mirror you mention, but the cleaner fix was keying the interval on the filters themselves so the effect resubscribes when they change.
One addition to the snapshot framing: it's also why stored derived state drifts. The moment you setFiltered(items.filter(...)) in an event handler instead of computing filtered during render, you've created a second snapshot that can disagree with the first — and they will, the moment an update batches. Computing during render is the boring answer that keeps working.
That polling example is a perfect real-world illustration of stale closures! 😄 Re-subscribing when
filterschange is definitely cleaner when the interval genuinely depends on them. And I love your point about derived state drifting. Computing filtered data during render keeps it consistent with the current props and state, instead of maintaining a second version that can fall out of sync. That's exactly the kind of mental model this article aims to build!"wait a minute, who are you" ... what an awesome article , thank you so much
Some comments may only be visible to logged-in visitors. Sign in to view all comments.