React, Explained Directly - Part 2: Production, Performance, and Modern Patterns
๐ Part 1: Why Re-Renders Actually Happen
๐ฆ Nephew: I understand useState causes a re-render. But sometimes components re-render when I didn't change their props at all. What's the actual rule?
๐จโ๐ฆณ Uncle: The real rule is simple, and most confusion comes from not knowing it: when a component re-renders, every child it renders in its JSX also re-renders by default - regardless of whether that child's props actually changed.
function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>{count}</button>
<Child /> {/* re-renders every time Parent re-renders, even with zero props */}
</div>
);
}
function Child() {
console.log('Child rendered');
return <p>I am a child</p>;
}
Click the button, Parent re-renders because its state changed, and Child re-renders too - even though Child receives no props at all and nothing about it changed. This is intentional default behavior, not a bug.
๐ฆ Nephew: So React.memo fixes this?
๐จโ๐ฆณ Uncle: It can, but only if you also fix a very common way people accidentally defeat it.
React.memo skips re-rendering a component if its props are shallowly equal to last time. "Shallow" means: for objects, arrays, and functions, it compares by reference, not by contents.
const Child = React.memo(function Child({ onClick }) {
console.log('Child rendered');
return <button onClick={onClick}>Click</button>;
});
function Parent() {
const [count, setCount] = useState(0);
// โ This creates a BRAND NEW function on every single render.
// Even though it "does the same thing," it's a different reference,
// so React.memo sees a "changed" prop and re-renders Child anyway.
return <Child onClick={() => console.log('clicked')} />;
}
Same problem with inline objects and arrays:
// โ New object reference every render - defeats memo even if the values are identical
<Child style={{ color: 'red' }} />
<Child items={[1, 2, 3]} />
The fix - stabilize the reference with useCallback (for functions) or useMemo (for objects/arrays), so the reference itself only changes when the actual dependencies change:
function Parent() {
const [count, setCount] = useState(0);
const handleClick = useCallback(() => {
console.log('clicked');
}, []); // same function reference across renders
return <Child onClick={handleClick} />; // now React.memo actually works
}
๐ฆ Nephew: So useCallback and useMemo aren't really about "making things faster" by themselves?
๐จโ๐ฆณ Uncle: Correct, and this trips people up. useMemo / useCallback do not make the calculation or function itself faster. Their only job is producing a stable reference so that something downstream - usually React.memo, or a dependency array in another hook - can correctly detect "nothing actually changed." Using them without a React.memo'd child or a dependency array consuming them does nothing useful, and can even add slight overhead for no benefit.
๐งช Part 2: React DevTools Profiler - Actually Measuring Re-Renders
๐ฆ Nephew: How do I actually check any of this, instead of guessing from console.log?
๐จโ๐ฆณ Uncle: The React DevTools Profiler. Here's the full setup and how to read it.
๐ ๏ธ Setup
| Step | What to do |
|---|---|
| 1๏ธโฃ | Install the React Developer Tools browser extension - available for Chrome, Firefox, and Edge. Search "React Developer Tools" in your browser's extension store. |
| 2๏ธโฃ | Open your app in the browser โ open DevTools (F12, or right-click โ Inspect). You'll now see two new tabs: โ๏ธ Components and โ๏ธ Profiler. Don't see them? Refresh the page - the extension needs the page to load with it already active. |
| 3๏ธโฃ | โ ๏ธ Profile in development mode, not production. Production builds are minified and strip out most of the debugging info the Profiler relies on. Some setups offer a special "profiling build" of production React for advanced cases - but for everyday work, just stay in development mode. |
โถ๏ธ How to Actually Use It
| Step | Action |
|---|---|
| 1๏ธโฃ | Open the Profiler tab. |
| 2๏ธโฃ | Click the โบ record button to start recording. |
| 3๏ธโฃ | Interact with your app normally - click buttons, type, trigger the exact behavior you're investigating. |
| 4๏ธโฃ | Click the โบ record button again to stop. |
| 5๏ธโฃ | You'll now see a ๐ฅ flame graph - a visual timeline of every render that happened during the recording. |
๐ What to Actually Check
- ๐ Which components rendered, and how many times. Each render shows up as a colored bar. Use the scrubber at the top ("commit 1 of N") to step through each individual render event one at a time.
- โฑ๏ธ Render duration. Each bar's size reflects how long that render took. A component taking disproportionately long for how simple it looks is your first candidate for optimization - splitting it up, memoizing an expensive calculation, or virtualizing a list (covered later).
- โ "Why did this render?" Select a component inside a specific commit, and the side panel can tell you exactly why it re-rendered: props changed, state changed, hooks changed, or simply "parent re-rendered." This is the single most useful clue for hunting down unnecessary re-renders.
- ๐ก You may need to turn this on first - click the โ๏ธ gear icon in the Profiler tab and enable "Record why each component rendered while profiling."
- ๐ซ๏ธ Grey / faded components. These did not re-render during that commit. If you expected a component to re-render and it's greyed out instead, that's confirmation your optimization (like
React.memo) is actually working as intended. - ๐ Ranked view. A toggle next to the flame graph that sorts every component by render duration within a commit - the fastest way to spot the single slowest offender without scanning the whole tree by eye.
๐ฆ Nephew: What's the actual workflow when I suspect a performance problem?
๐จโ๐ฆณ Uncle: Record a Profiler session while performing the slow interaction. Look at total commit duration - is one single commit unusually long, or are there just too many commits happening (excessive re-renders) for a simple interaction?
- For a long single commit - drill into which component inside it took the most time; that's your target for splitting up or optimizing the actual work.
- For too many commits - use "why did this render" to find components re-rendering that shouldn't need to, and apply
React.memoplus stabilized props (useMemo/useCallback) where the underlying data genuinely didn't need to trigger that specific child.
โณ Part 3: useTransition and useDeferredValue
๐ฆ Nephew: How are these different from just using useMemo or useEffect?
๐จโ๐ฆณ Uncle: These are Concurrent Rendering APIs - they don't cache a value or run a side effect; they tell React how urgently to treat a particular update, letting React deliberately delay less important rendering work in favor of keeping the UI responsive.
useTransition
Marks a state update as non-urgent, meaning React can interrupt it if something more urgent (like further typing) comes in, instead of blocking the UI while that update renders.
function SearchPage() {
const [query, setQuery] = useState('');
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleChange(e) {
setQuery(e.target.value); // urgent - the input must feel instant
startTransition(() => {
// non-urgent - React can delay/interrupt this if needed
setResults(computeExpensiveSearchResults(e.target.value));
});
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending && <p>Updating results...</p>}
<ResultsList results={results} />
</>
);
}
Without useTransition, if computeExpensiveSearchResults is slow, every keystroke could feel laggy, because React would render the expensive results update synchronously before it could process the next keystroke. With useTransition, the input stays responsive, and the results list updates as soon as React can fit that work in without blocking urgent input - isPending tells you when that background update is still catching up.
useDeferredValue
Similar goal, different shape - instead of wrapping the update in a transition, you wrap a value, telling React it's okay to render with a slightly stale version of that value while it catches up:
function SearchPage() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
{/* This list re-renders using deferredQuery, which lags slightly behind query during fast typing, keeping input responsive */}
<ResultsList query={deferredQuery} />
</>
);
}
๐ฆ Nephew: When do I use which?
๐จโ๐ฆณ Uncle: useTransition when you are triggering the state update yourself (inside an event handler) and can explicitly mark specific setState calls as non-urgent. useDeferredValue when you're consuming a value (often a prop, or state you don't directly control the setter for) and want to let a slow-to-render consumer of that value lag slightly behind, without touching how the value itself is set.
๐ฅ๏ธ Part 4: React Server Components (RSC)
๐ฆ Nephew: How is this different from the Server-Side Rendering we already covered in Part 1?
๐จโ๐ฆณ Uncle: This is a genuinely different model, not just a variation of SSR - it's worth being precise about the difference.
- SSR (traditional): the server renders your components to HTML for the initial load, sends that HTML down, and then the same component code also exists in the JavaScript bundle sent to the browser, so React can hydrate and take over on the client. The component code ships to the browser either way.
- React Server Components: certain components are explicitly marked to run only on the server, and their code is never sent to the browser at all. They can do things like directly query a database or read a file, and they output their result as part of the page - but the browser never downloads their source code, their dependencies, or re-executes them.
// This is a Server Component (the default in Next.js App Router - no directive needed)
async function ProductList() {
const products = await db.query('SELECT * FROM products'); // runs only on the server
return (
<ul>
{products.map(p => <li key={p.id}>{p.name}</li>)}
</ul>
);
}
// This is a Client Component - explicitly opted in with the directive
'use client';
function LikeButton() {
const [liked, setLiked] = useState(false); // useState requires client-side interactivity
return <button onClick={() => setLiked(!liked)}>{liked ? 'Liked' : 'Like'}</button>;
}
๐ฆ Nephew: Why does this matter in practice?
๐จโ๐ฆณ Uncle: Two concrete benefits:
- Smaller JavaScript bundles. If a component only renders static content and never needs interactivity (
useState,onClick, browser APIs), it doesn't need to ship any JavaScript for it at all - the server computes the result once and sends the output. Only components that actually need to run in the browser ('use client') contribute to the bundle size. - Direct backend access without an API layer. A Server Component can call a database or a filesystem directly in its own code - no need to build a separate API endpoint just to fetch that data for the frontend to call.
The rule that matters day to day: Server Components cannot use useState, useEffect, or any browser-only API (window, localStorage, event handlers) - those require 'use client'. Client Components, in turn, cannot directly await a database call the way a Server Component can. You architect your component tree with this boundary in mind - typically, Server Components at the top handling data-heavy, non-interactive parts, with small Client Components nested inside specifically for the interactive pieces.
โฑ๏ธ Part 5: Suspense for Data Fetching
๐ฆ Nephew: In Part 1, Suspense was only for lazy-loaded components. How does it work for actual data?
๐จโ๐ฆณ Uncle: The mechanism is the same underlying idea, extended: Suspense shows a fallback UI while something the component needs is not yet ready - in Part 1, that "something" was a component's code; here, it's data.
The core mechanism
A component can "suspend" - meaning it throws a special signal telling React "I'm not ready yet, try again once this resolves." The nearest parent <Suspense> boundary catches that signal and shows its fallback until the data resolves, then re-renders the actual component with the data available.
<Suspense fallback={<p>Loading profile...</p>}>
<ProfileDetails userId={id} />
</Suspense>
You don't manually throw these signals yourself in most real code - a data-fetching library that supports Suspense does it for you internally. This is why "Suspense for data fetching" isn't something you can bolt onto a plain fetch() call trivially; it requires a library (React Query supports it, as does the use() hook pattern in newer React versions, and RSC has its own built-in integration) that knows how to participate in this signaling correctly, including caching, so the same request doesn't restart from scratch every time the component re-suspends.
use() with Suspense (modern pattern)
function ProfileDetails({ userId }) {
const user = use(fetchUser(userId)); // suspends until the promise resolves
return <p>{user.name}</p>;
}
<Suspense fallback={<p>Loading...</p>}>
<ProfileDetails userId={5} />
</Suspense>
use() can read a Promise directly inside a component's render, and if that promise hasn't resolved yet, the component suspends, letting the nearest <Suspense> boundary show its fallback instead of the component rendering in a broken, half-loaded state.
Waterfalls vs. parallel fetching - the real problem this section is about
๐ฆ Nephew: What's the actual danger with data-fetching Suspense, if I set it up carelessly?
๐จโ๐ฆณ Uncle: Request waterfalls.
Comments
No comments yet. Start the discussion.