DEV Community

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.memo plus 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.