DEV_NET_CORE
GET_STARTED
ReactState management, performance, and rendering optimization

Memoization and avoiding needless rerenders

Overview

Memoization in React means reusing a previous result instead of doing the same work again on every render. It can apply to components with memo, calculated values with useMemo, and function references with useCallback. Avoiding needless rerenders means reducing render work that does not change the visible UI or that repeatedly recalculates expensive values.

This matters because React applications often slow down from large component trees, expensive list filtering, unstable object and function props, broad context providers, duplicated state, and effects that trigger extra updates. A rerender is not automatically bad. React can render components and still avoid unnecessary DOM mutations during the commit phase. The problem is unnecessary expensive render work, not every function call.

In interviews, this topic tests whether a candidate understands React's render model, referential equality, memoization trade-offs, profiling, context behavior, and practical performance design. Strong answers avoid both extremes: they do not add memoization everywhere, and they do not ignore real performance problems in large forms, tables, dashboards, editors, or frequently updating UI.

The practical goal is to first make state local, rendering pure, and props stable by design. Then measure or identify a real bottleneck and add the smallest memoization that improves the slow path.

Core Concepts

Render Work vs DOM Work

React rendering is the process of calling components to calculate the next UI. Committing is the process of applying the necessary DOM changes. A component can rerender without causing a DOM change.

Code
function Clock({ time }: { time: string }) {
  return (
    <>
      <h1>{time}</h1>
      <input />
    </>
  );
}

When time changes, Clock rerenders. React compares the new output with the previous output and updates only what changed. The <input> DOM node can stay in place.

For interviews, the important distinction is:

  • Renders are how React calculates UI.
  • Commits are how React changes the DOM.
  • A rerender is not always a performance bug.
  • A needless rerender matters when it causes expensive calculations, slow component rendering, layout work, network side effects, or visible lag.

Common Render Triggers

A component renders when:

  • It mounts for the first time.
  • Its state changes.
  • One of its ancestors renders and React renders the child as part of that subtree.
  • A context value it reads changes.
  • An external store subscription tells React the snapshot changed.
  • Its key changes and React treats it as a different component.

In development, Strict Mode can intentionally call render logic more than once to find impure render code. Do not confuse this development behavior with production performance.

What Counts as a Needless Rerender

A needless rerender is render work that could have been avoided without changing correct behavior.

Examples:

  • A heavy chart rerenders when an unrelated search input changes.
  • A memoized child receives a new object literal every render, so memoization never helps.
  • A context provider creates a new { state, dispatch } object every render, causing all consumers to update.
  • A component stores derived state in useState, then uses an effect to recalculate it, causing an extra render.
  • A table filters and sorts thousands of rows on every keystroke when the filtered result could be memoized or deferred.

Not every repeated render is worth fixing. A small button rerendering is usually cheaper than adding complex memoization.

Optimize State Shape First

Before adding memoization, improve the component design:

  • Keep transient state local instead of lifting it to a high parent.
  • Split large components so updates affect smaller subtrees.
  • Avoid duplicated derived state.
  • Move expensive logic out of render only when it is actually expensive.
  • Remove effects that only synchronize values that could be calculated during render.
  • Pass children as JSX when a wrapper owns state but should not force child recalculation.
Code
function Panel({ children }: { children: React.ReactNode }) {
  const [open, setOpen] = useState(false);

  return (
    <section>
      <button onClick={() => setOpen((value) => !value)}>Toggle</button>
      {open && children}
    </section>
  );
}

When Panel updates its own state, the child JSX identity can help avoid unnecessary work below the wrapper, depending on how the tree is structured. This is often cleaner than memoizing every nested component.

memo

memo creates a memoized component. React can skip rerendering the component when its parent rerenders and the component's props are the same as the previous render.

Code
import { memo } from "react";

type ProductRowProps = {
  id: string;
  name: string;
  price: number;
};

const ProductRow = memo(function ProductRow({
  id,
  name,
  price,
}: ProductRowProps) {
  return (
    <tr>
      <td>{id}</td>
      <td>{name}</td>
      <td>{price}</td>
    </tr>
  );
});

Important rules:

  • memo compares props shallowly by default using Object.is.
  • memo is a performance optimization, not a correctness tool.
  • A memoized component still rerenders when its own state changes.
  • A memoized component still rerenders when a context value it reads changes.
  • memo is ineffective if props are always new references.
Code
// Breaks memo because columns is a new array on every render.
<ProductTable columns={["name", "price"]} rows={rows} />

Better:

Code
const productColumns = ["name", "price"];

function ProductsPage({ rows }: { rows: Product[] }) {
  return <ProductTable columns={productColumns} rows={rows} />;
}

Or, if the value depends on reactive inputs:

Code
const columns = useMemo(
  () => getVisibleColumns(role),
  [role]
);

return <ProductTable columns={columns} rows={rows} />;

useMemo

useMemo caches the result of a pure calculation between renders until one of its dependencies changes.

Code
const visibleProducts = useMemo(
  () => products.filter((product) => product.name.includes(query)),
  [products, query]
);

Use useMemo when:

  • The calculation is expensive enough to matter.
  • The calculated value is passed to a memoized child.
  • A stable object or array is needed as a dependency for another hook.
  • Recomputing the value on every render causes measurable lag.

Avoid useMemo when:

  • The calculation is trivial.
  • The value is only used once and is cheap to recreate.
  • You are trying to hide incorrect dependency logic.
  • You are caching impure work such as mutation, logging, or network calls.

Bad:

Code
const fullName = useMemo(() => `${firstName} ${lastName}`, [firstName, lastName]);

This is usually unnecessary because string concatenation is cheap.

Better:

Code
const fullName = `${firstName} ${lastName}`;

useCallback

useCallback caches a function reference between renders until its dependencies change.

Code
const handleSelect = useCallback((id: string) => {
  setSelectedId(id);
}, []);

return <ProductList products={products} onSelect={handleSelect} />;

useCallback does not prevent the parent component from rendering. It is useful when:

  • The callback is passed to a memoized child.
  • The callback is a dependency of another hook.
  • A custom hook returns stable callback references to consumers.

If a callback only updates state from previous state, use the updater form to avoid unnecessary dependencies.

Code
const addItem = useCallback((name: string) => {
  setItems((current) => [...current, { id: crypto.randomUUID(), name }]);
}, []);

This is better than depending on items and recreating the callback each time the list changes.

Referential Equality

React compares props and dependencies by identity for objects, arrays, and functions. Two object literals with the same fields are still different references.

Code
Object.is({ pageSize: 20 }, { pageSize: 20 }); // false
Object.is(["name"], ["name"]); // false
Object.is(() => {}, () => {}); // false

This matters because memoization often fails when props are recreated during render.

Bad:

Code
<DataGrid
  rows={rows}
  options={{ pageSize: 20, density: "compact" }}
  onRowClick={(row) => setSelected(row.id)}
/>

Better:

Code
const gridOptions = useMemo(
  () => ({ pageSize: 20, density: "compact" as const }),
  []
);

const handleRowClick = useCallback((row: Row) => {
  setSelected(row.id);
}, []);

<DataGrid rows={rows} options={gridOptions} onRowClick={handleRowClick} />;

Immutability and Structural Sharing

Memoization works best when state updates preserve references for unchanged data. This is called structural sharing.

Bad:

Code
setProducts((current) =>
  current.map((product) => ({ ...product }))
);

This recreates every product object, so memoized rows cannot tell which rows actually changed.

Better:

Code
setProducts((current) =>
  current.map((product) =>
    product.id === updated.id ? { ...product, price: updated.price } : product
  )
);

Only the changed item gets a new reference. Unchanged rows keep their references, which helps memoized child components and selectors.

Context and Memoization

Context changes rerender all consumers that read that context value. memo does not block a rerender caused by context the component uses.

Bad:

Code
function AppProviders({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  return (
    <AuthContext value={{ user, setUser }}>
      {children}
    </AuthContext>
  );
}

The provider value is a new object every render.

Better:

Code
function AppProviders({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User | null>(null);

  const value = useMemo(
    () => ({ user, setUser }),
    [user]
  );

  return (
    <AuthContext value={value}>
      {children}
    </AuthContext>
  );
}

For larger applications, split context by update frequency or responsibility:

  • AuthStateContext for user.
  • AuthActionsContext for stable actions.
  • ThemeContext for visual theme.
  • Feature-specific contexts instead of one global application context.

This keeps unrelated changes from rerendering unrelated consumers.

Custom Comparison Functions

memo accepts an optional comparison function.

Code
const Chart = memo(
  function Chart({ points }: { points: Point[] }) {
    return <ExpensiveChart points={points} />;
  },
  (prev, next) => prev.points === next.points
);

Custom comparison can help for very specific cases, but it is risky:

  • The comparison can be more expensive than rendering.
  • Deep equality can freeze the UI on large objects.
  • Returning true incorrectly can show stale UI.
  • Ignoring function props can preserve stale closures.

Prefer stable props and structural sharing before custom comparison.

Profiling Before Optimizing

Use React DevTools Profiler or the <Profiler> component to identify expensive commits and components. The useful questions are:

  • Which interaction is slow?
  • Which components render during that interaction?
  • Which components take the most time?
  • Are they rendering with the same props?
  • Would moving state, splitting components, or memoization reduce work?
Code
import { Profiler } from "react";

function onRender(
  id: string,
  phase: "mount" | "update" | "nested-update",
  actualDuration: number,
  baseDuration: number
) {
  console.log({ id, phase, actualDuration, baseDuration });
}

export function App() {
  return (
    <Profiler id="ProductsPage" onRender={onRender}>
      <ProductsPage />
    </Profiler>
  );
}

actualDuration helps show how much time was spent rendering for the current update. baseDuration estimates the cost of rendering the subtree without memoization. A falling actual duration compared with base duration can indicate useful memoization.

React Compiler

React Compiler can automatically apply memoization-style optimizations in supported builds, reducing the need for manual memo, useMemo, and useCallback. Interview answers should mention it without assuming every codebase has adopted it.

Even with compiler support, the fundamentals still matter:

  • Components and hooks must stay pure.
  • State should be placed close to where it is used.
  • Expensive work should be understood and measured.
  • Effects should not create unnecessary render chains.
  • Manual memoization may still appear in existing codebases and library boundaries.

Practical Optimization Flow

A good optimization flow is:

  • Reproduce the slow interaction.
  • Profile the render path.
  • Check for broad state or context updates.
  • Remove derived state and effect-driven render loops.
  • Split components around update boundaries.
  • Stabilize props only where it helps.
  • Add memo, useMemo, or useCallback to the smallest useful area.
  • Re-profile to confirm the improvement.

Common Mistakes

Common mistakes include:

  • Adding memo to every component without measuring.
  • Using useMemo for cheap calculations.
  • Passing new object, array, or function props to memoized children.
  • Using useCallback without a memoized child or hook dependency reason.
  • Omitting dependencies to keep a value stable.
  • Adding deep custom comparisons that cost more than rendering.
  • Treating rerenders as correctness bugs.
  • Ignoring context updates.
  • Mutating state in place, which can break both rendering and memoization assumptions.

Best Practices

Best practices include:

  • Keep rendering pure.
  • Keep state as local as practical.
  • Prefer simple props over large object props.
  • Preserve references for unchanged items.
  • Use updater functions to reduce callback dependencies.
  • Memoize provider values when passing objects through context.
  • Split context by responsibility and update frequency.
  • Profile before and after optimization.
  • Use virtualization for very large lists instead of relying only on memoization.
  • Remove memoization that no longer helps.

Interview Practice

PreviousContext plus reducer vs external storesNext UpSuspense, transitions, and rendering priority concepts