DEV Community

surajrkhonde
surajrkhonde

Posted on

React, Explained Directly — Part 2: Production, Performance, and Modern Patterns

Continues directly from Part 1. Same rule: no stories, no metaphors — just the real mechanics, in plain English, with working code.


🔁 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>;
}
Enter fullscreen mode Exit fullscreen mode

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')} />;
}
Enter fullscreen mode Exit fullscreen mode

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]} />
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

👦 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:

  1. Record a Profiler session while performing the slow interaction.
  2. 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?
  3. 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.
  4. 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} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

👦 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>
  );
}
Enter fullscreen mode Exit fullscreen mode
// 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>;
}
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: Why does this matter in practice?

👨‍🦳 Uncle: Two concrete benefits:

  1. 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.
  2. 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>
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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. If a parent component fetches data and only then renders a child that fetches its own data, the child's fetch cannot start until the parent's finishes — even though the two requests have nothing to do with each other and could have run at the same time.

// ❌ Waterfall: Comments' fetch cannot start until Post's fetch resolves,
// because Comments isn't even rendered (and doesn't start fetching) until then
function Post({ id }) {
  const post = use(fetchPost(id));
  return (
    <div>
      <h1>{post.title}</h1>
      <Comments postId={id} />
    </div>
  );
}

function Comments({ postId }) {
  const comments = use(fetchComments(postId)); // starts only AFTER Post resolves
  return <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
}
Enter fullscreen mode Exit fullscreen mode
Timeline (waterfall):
[ fetch post ─────────]
                        [ fetch comments ─────────]
Total time = post time + comments time
Enter fullscreen mode Exit fullscreen mode

The fix — start both fetches at the same time, before either component needs to block rendering on them, typically by kicking off both requests in a parent (or even outside the component tree, in a route loader) and passing the promises down, rather than starting the second fetch only inside a component that renders after the first resolves:

// ✅ Parallel: both fetches start at the same time
function PostPage({ id }) {
  const postPromise = fetchPost(id);       // started immediately
  const commentsPromise = fetchComments(id); // started immediately, in parallel

  return (
    <Suspense fallback={<p>Loading...</p>}>
      <Post postPromise={postPromise} />
      <Comments commentsPromise={commentsPromise} />
    </Suspense>
  );
}

function Post({ postPromise }) {
  const post = use(postPromise);
  return <h1>{post.title}</h1>;
}

function Comments({ commentsPromise }) {
  const comments = use(commentsPromise);
  return <ul>{comments.map(c => <li key={c.id}>{c.text}</li>)}</ul>;
}
Enter fullscreen mode Exit fullscreen mode
Timeline (parallel):
[ fetch post ─────────]
[ fetch comments ─────────]
Total time = whichever one takes longer, not the sum of both
Enter fullscreen mode Exit fullscreen mode

This is the single most common real-world Suspense-for-data mistake — nesting components that each independently kick off their own fetch on render, instead of starting all known-needed requests as early and in parallel as possible, then letting Suspense boundaries handle the waiting.

👦 Nephew: How do multiple Suspense boundaries interact — does one slow fetch block everything?

👨‍🦳 Uncle: Only within its own boundary. If Post and Comments are each wrapped in separate <Suspense> boundaries instead of one shared boundary, a slow Comments fetch shows its own fallback while Post renders as soon as it's ready, instead of both being held back by whichever is slower.

<Suspense fallback={<p>Loading post...</p>}>
  <Post postPromise={postPromise} />
</Suspense>
<Suspense fallback={<p>Loading comments...</p>}>
  <Comments commentsPromise={commentsPromise} />
</Suspense>
Enter fullscreen mode Exit fullscreen mode

This lets fast content appear immediately while slower content continues loading independently — a deliberate design choice about how "chunky" or "granular" your loading states should be.


📜 Part 6: List Virtualization

👦 Nephew: Why is rendering a very long list actually a problem?

👨‍🦳 Uncle: Every DOM node has real memory and rendering cost. If you render 10,000 list items, the browser has to create, lay out, and paint 10,000 real DOM nodes — even though, at any given moment, the user can only actually see maybe 20 of them on screen (the rest are scrolled out of view). That's a massive amount of wasted work.

Virtualization solves this by only rendering the DOM nodes that are actually visible (plus a small buffer just outside the visible area, so scrolling doesn't show blank gaps), and swapping their content as the user scrolls — instead of creating a real DOM node for every single item up front.

import { FixedSizeList } from 'react-window';

function Row({ index, style }) {
  return <div style={style}>Row {index}</div>;
}

function VirtualizedList({ items }) {
  return (
    <FixedSizeList
      height={400}       // visible container height
      itemCount={items.length}
      itemSize={35}       // height of each row
      width={300}
    >
      {Row}
    </FixedSizeList>
  );
}
Enter fullscreen mode Exit fullscreen mode

Internally, this only renders enough <div> rows to fill the visible 400px area (plus a small buffer), regardless of whether items.length is 50 or 500,000 — as the user scrolls, react-window recycles those same few DOM nodes with updated content and repositions them, rather than creating new nodes for every item that scrolls into view.

👦 Nephew: When do I actually need this?

👨‍🦳 Uncle: Once a list regularly renders more items than can comfortably fit on screen at once — a rough rule of thumb many teams use is somewhere in the low hundreds of items and up, though the real threshold depends on how complex each row is to render. A simple one-line text row might be fine unvirtualized at a thousand items; a row with images, buttons, and nested components might start causing visible lag at a few hundred.


📝 Part 7: Forms at Scale — Why Plain useState Gets Messy

👦 Nephew: What's actually wrong with just using useState for every form field?

👨‍🦳 Uncle: Nothing is wrong for a small form. The problem is what happens as a form grows: every keystroke in a controlled input triggers a re-render of the entire component (and, by the default rule from Part 1, every child it renders) — for a form with twenty fields, typing in one field can re-render the other nineteen unnecessarily, unless you've carefully memoized each of them.

Libraries like React Hook Form solve this by defaulting to uncontrolled inputs internally (using refs, from Part 1's controlled vs. uncontrolled discussion) — the library reads input values directly from the DOM via refs, rather than storing every keystroke in React state and re-rendering on each one.

import { useForm } from 'react-hook-form';

function SignupForm() {
  const { register, handleSubmit, formState: { errors } } = useForm();

  function onSubmit(data) {
    console.log(data); // { email: '...', password: '...' }
  }

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email', { required: 'Email is required' })} />
      {errors.email && <p>{errors.email.message}</p>}

      <input type="password" {...register('password', { minLength: 8 })} />

      <button type="submit">Sign up</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

register('email', ...) returns props (ref, onChange, onBlur, name) that React Hook Form attaches to the input — it manages the input's value internally via the ref, and validation/error state is only surfaced when needed (e.g., on submit, or on blur, depending on configuration), rather than triggering a full-form re-render on every keystroke.

👦 Nephew: So the actual win is fewer re-renders, not just less code?

👨‍🦳 Uncle: Fewer re-renders is the core technical win. Less boilerplate (no manually wiring up a useState and onChange per field) is the secondary, more visible benefit — but for a form with many fields or complex validation, the re-render reduction is the part that actually matters for performance.


🔷 Part 8: TypeScript with React

👦 Nephew: What's actually different about typing React components versus normal TypeScript?

👨‍🦳 Uncle: The core ideas are the same TypeScript you already know — the React-specific parts are mostly about typing props, children, events, and refs correctly.

Typing props

type ButtonProps = {
  label: string;
  onClick: () => void;
  disabled?: boolean; // optional prop
};

function Button({ label, onClick, disabled }: ButtonProps) {
  return <button onClick={onClick} disabled={disabled}>{label}</button>;
}
Enter fullscreen mode Exit fullscreen mode

Typing children

type CardProps = {
  title: string;
  children: React.ReactNode; // covers strings, elements, arrays of elements, etc.
};

function Card({ title, children }: CardProps) {
  return (
    <div>
      <h2>{title}</h2>
      {children}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Generic, reusable components

type ListProps<T> = {
  items: T[];
  renderItem: (item: T) => React.ReactNode;
};

function List<T>({ items, renderItem }: ListProps<T>) {
  return <ul>{items.map((item, i) => <li key={i}>{renderItem(item)}</li>)}</ul>;
}

// Usage — T is inferred as `string` here automatically:
<List items={['a', 'b']} renderItem={(item) => <span>{item}</span>} />
Enter fullscreen mode Exit fullscreen mode

Typing events

Event handler parameters need a specific React event type, not a generic Event:

function Input() {
  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    console.log(e.target.value);
  }
  return <input onChange={handleChange} />;
}
Enter fullscreen mode Exit fullscreen mode

Typing useState when it can't be inferred

const [user, setUser] = useState<User | null>(null); // without the type, TS would infer `null` forever
Enter fullscreen mode Exit fullscreen mode

Typing useRef

const inputRef = useRef<HTMLInputElement>(null);
// inputRef.current is HTMLInputElement | null — TypeScript forces you to
// check for null before accessing properties like .focus()
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: What actually trips people up most?

👨‍🦳 Uncle: Two things specifically: forgetting that useRef for a DOM element starts as null (so accessing .current.value without a null check is a type error, correctly catching a real runtime risk), and reaching for the wrong event type (using generic Event instead of the more specific React.ChangeEvent<HTMLInputElement>, React.MouseEvent<HTMLButtonElement>, etc., which actually have the properties you need typed correctly).


✅ Part 9: Testing React Components

👦 Nephew: What does React Testing Library actually test, and how is that different from just checking implementation details?

👨‍🦳 Uncle: Testing Library's core philosophy: test what the user experiences, not how the component is internally built. This means querying the rendered output the way a user would perceive it (by visible text, labels, roles) rather than reaching into component internals (state variables, specific class names, implementation-specific structure).

import { render, screen, fireEvent } from '@testing-library/react';

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

test('increments the count when the button is clicked', () => {
  render(<Counter />);

  expect(screen.getByText('Count: 0')).toBeInTheDocument();

  fireEvent.click(screen.getByText('Increment'));

  expect(screen.getByText('Count: 1')).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

Notice the test never touches count directly, never checks internal state — it renders the component, interacts with it exactly as a user would (finding the button by its visible text, clicking it), and asserts on what's now visible on screen.

Mocking hooks and context in tests

// Mocking a custom hook that fetches data, so the test doesn't hit a real network call
jest.mock('./useUser', () => ({
  useUser: () => ({ name: 'Test User', loading: false }),
}));

test('displays the user name', () => {
  render(<Profile />);
  expect(screen.getByText('Test User')).toBeInTheDocument();
});
Enter fullscreen mode Exit fullscreen mode

For Context, tests typically wrap the component under test in the relevant Provider with test-specific values, rather than mocking the Context mechanism itself:

function renderWithTheme(ui, theme = 'dark') {
  return render(
    <ThemeContext.Provider value={theme}>{ui}</ThemeContext.Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

👦 Nephew: Why are snapshot tests "usually a bad idea," as you put it?

👨‍🦳 Uncle: A snapshot test captures the entire rendered output and compares it against a saved reference on every future test run — any difference, even a harmless one (a class name reordered, a wrapping <div> added for an unrelated reason), fails the test. In practice, this trains developers to reflexively run "update snapshot" without actually reading what changed, which defeats the entire purpose of having a test catch unintended changes. Testing specific, meaningful behavior (as in the counter example above) catches real regressions without that noise, because the assertion is about something you actually intended to guarantee, not "the entire output happens to match whatever it was last time."


🎯 Part 10: Common Interview Traps

👦 Nephew: What are the specific gotchas that come up most in interviews?

👨‍🦳 Uncle: Here are the ones that catch people who understand React's API but haven't thought through the underlying mechanics.

1. Closures inside loops with hooks

function BuggyComponent() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setTimeout(() => {
      setCount(count + 1); // captures `count` from THIS render, not a live reference
    }, 3000);
  }

  return <button onClick={handleClick}>{count}</button>;
}
Enter fullscreen mode Exit fullscreen mode

If you click the button three times quickly, each setTimeout captured whatever count was at the moment it was created — not a live value. If count was 0 for all three clicks (because they happened faster than 3 seconds apart), all three timeouts will set it to 1, not 3. Fix: use the function form of the setter, which always receives the guaranteed-current value regardless of what the closure captured:

setTimeout(() => {
  setCount(prev => prev + 1); // always correct, regardless of stale closures
}, 3000);
Enter fullscreen mode Exit fullscreen mode

2. Why changing key resets a component's state entirely

<UserProfile key={userId} userId={userId} />
Enter fullscreen mode Exit fullscreen mode

If userId changes, React doesn't just update this component with new props — it unmounts the old instance entirely and mounts a brand new one, because key is how React decides whether it's looking at "the same component, updated" versus "a genuinely different component instance." This is often used deliberately to force a full reset of a component's internal state when switching between logically distinct entities — but it's a common trap when someone changes a key unintentionally (say, using an array index that shifts) and can't figure out why a component's local state keeps getting wiped.

3. Why two setState calls in one handler don't cause two separate renders

function handleClick() {
  setCount(count + 1);
  setFlag(true);
  // React batches both of these into a SINGLE re-render, not two
}
Enter fullscreen mode Exit fullscreen mode

React batches multiple state updates that happen within the same event handler (and, since React 18, within promises, timeouts, and other async contexts too, via automatic batching) into one re-render, for efficiency. This is why relying on count being updated immediately after calling setCount inside the same function is unreliable — you're reading a value from before the batched update actually applies. It's the same underlying mechanic as the async setState behavior covered in Part 1.

4. The actual cost of a controlled input, at scale

An interviewer may ask: "what's the performance cost of a controlled <input>?" The precise answer: every keystroke calls setState, which triggers a re-render of that component — and, per the rule from this document's Part 1, every child it renders by default. For a single isolated input, this is negligible. For a large form where that input's parent also renders many unrelated sibling components, every keystroke can re-render far more of the tree than necessary, unless those siblings are wrapped in React.memo or the input's state is scoped to a smaller, more localized component.

5. Why useEffect with no dependency array is different from an empty array

useEffect(() => { ... });       // runs after EVERY render, no exceptions
useEffect(() => { ... }, []);   // runs once, after the first render only
useEffect(() => { ... }, [x]);  // runs after the first render, and again whenever `x` changes
Enter fullscreen mode Exit fullscreen mode

Omitting the array entirely is a genuinely different behavior from an empty array — a surprisingly common point of confusion, since both "look like" they're not filtering on anything.


📌 Recap

  • Re-renders cascade by default — a parent re-rendering re-renders all its children unless explicitly memoized, and React.memo only works if props (including functions and objects) keep stable references via useCallback/useMemo.
  • The Profiler shows you what actually re-rendered, why, and how long each render took — install React DevTools, record in development mode, and use "why did this render" plus the ranked view to target real bottlenecks instead of guessing.
  • useTransition/useDeferredValue let you mark specific updates as non-urgent, so React can keep urgent interactions (typing, clicking) responsive while slower updates catch up in the background.
  • React Server Components run only on the server and never ship their code to the browser — a genuinely different model from SSR, not just a variation of it. 'use client' opts a component into the browser-executed, interactive world.
  • Suspense for data fetching shows fallback UI while data resolves, but its biggest real-world risk is accidental request waterfalls — always start independent fetches in parallel, and use separate Suspense boundaries when you want independent loading states.
  • List virtualization avoids creating real DOM nodes for off-screen items, which matters once a list is long enough that unnecessary DOM node count becomes the actual bottleneck.
  • Form libraries like React Hook Form default to uncontrolled inputs via refs specifically to avoid re-rendering the whole form on every keystroke.
  • TypeScript with React is mostly normal TypeScript, plus knowing the specific types for events, refs, and children.
  • Testing Library deliberately tests behavior visible to a user, not internal implementation — which is also why snapshot tests, which capture everything indiscriminately, tend to produce low-value, easily-ignored test failures.
  • Interview traps are almost always about the same few underlying mechanics repeating in different disguises: stale closures, what key actually means to React, batched state updates, and the real cost of default re-render behavior.

📋 Quick Reference — Part 2

Concept What it actually does
Default re-render cascade Parent re-renders → all rendered children re-render, regardless of prop changes
React.memo + stable refs Skips re-rendering only if props are reference-stable; needs useCallback/useMemo upstream
React DevTools Profiler Records renders, shows duration and "why did this render," best used in development
useTransition Marks a state update as non-urgent/interruptible
useDeferredValue Lets a slow-to-render consumer lag slightly behind a fast-changing value
React Server Components Components whose code runs only on the server and never ships to the browser
'use client' Opts a component into browser execution, enabling hooks and interactivity
Suspense (data) Shows fallback UI while a component's required data isn't ready yet
Request waterfall Sequential fetches that could have run in parallel, needlessly adding up their times
List virtualization Renders only visible (plus buffer) list items as real DOM nodes
React Hook Form Uses refs/uncontrolled inputs internally to avoid re-rendering on every keystroke
React.ReactNode The type covering anything renderable as children
Testing Library philosophy Query and assert by what a user sees/does, not internal implementation
Snapshot tests Capture entire output; often produce noisy, low-value failures
Stale closure in setTimeout/loops Captures the value at creation time, not a live reference — fix with the function-updater form
key changing Forces full unmount + remount of a component, resetting all its internal state
Automatic batching Multiple setState calls in one handler (or async context, since React 18) collapse into one render
Missing vs. empty dependency array No array = runs every render; [] = runs once, after first render only

Same rule as before: for every row, be able to explain the actual mechanic in your own words. That's the difference between having seen the API and actually being able to debug it under pressure.

Top comments (0)