DEV Community

reactuse.com
reactuse.com

Posted on • Originally published at reactuse.com

React Hooks Performance: How to Avoid Unnecessary Re-renders

Performance is the concern that separates production-quality React code from tutorial-grade code. Most React applications do not have a rendering problem — but the ones that do can feel sluggish and frustrating. The key is knowing when optimization matters and what tools actually help.

When Does React Re-render?

A component re-renders when:

  1. Its state changes — calling setState re-renders the component and all children.
  2. Its parent re-renders — even if props haven't changed, children re-render by default.
  3. A consumed context changes — any useContext consumer re-renders when the context value updates.

Every optimization technique targets one or more of these triggers.

Rule 1: Don't Optimize Prematurely

Wrapping every value in useMemo and every function in useCallback is not optimization — it is overhead. Memoization has a cost: storing previous values, comparing dependencies, managing cached references. If the computation is trivial, memoization costs more than recomputing.

// Don't do this — memoization costs more than the addition
const total = useMemo(() => price + tax, [price, tax]);

// Just compute it
const total = price + tax;
Enter fullscreen mode Exit fullscreen mode

Measure first with React DevTools Profiler. Optimize the slow parts, not everything.

useMemo — When It Actually Helps

useMemo caches a computed value and recalculates only when dependencies change. It helps in two scenarios:

Expensive computations:

function ProductList({ products, filter }: Props) {
  const filtered = useMemo(
    () => products.filter((p) => p.category === filter),
    [products, filter]
  );

  return (
    <ul>
      {filtered.map((p) => <li key={p.id}>{p.name}</li>)}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

Preserving referential equality for memoized children:

function Dashboard({ data }: Props) {
  const chartConfig = useMemo(
    () => ({ labels: data.map((d) => d.label), values: data.map((d) => d.value) }),
    [data]
  );
  return <MemoizedChart config={chartConfig} />;
}
Enter fullscreen mode Exit fullscreen mode

useCallback — The Misunderstood Hook

useCallback only matters when the function is passed to a React.memo child or used as a hook dependency. Without React.memo on the child, useCallback does nothing useful.

// Before: new reference every render, MemoizedList always re-renders
function SearchPage() {
  const [query, setQuery] = useState("");
  const handleSelect = (id: string) => console.log("Selected:", id);
  return <MemoizedList onSelect={handleSelect} />;
}

// After: stable reference, MemoizedList skips re-renders
function SearchPage() {
  const [query, setQuery] = useState("");
  const handleSelect = useCallback((id: string) => {
    console.log("Selected:", id);
  }, []);
  return <MemoizedList onSelect={handleSelect} />;
}
Enter fullscreen mode Exit fullscreen mode

State Structure Matters

Split unrelated state:

// Bad: updating name re-renders components that only read age
const [form, setForm] = useState({ name: "", age: 0 });

// Good: independent updates
const [name, setName] = useState("");
const [age, setAge] = useState(0);
Enter fullscreen mode Exit fullscreen mode

Derive what you can:

// Bad: extra state that must stay in sync
const [items, setItems] = useState<Item[]>([]);
const [count, setCount] = useState(0);

// Good: derived value
const [items, setItems] = useState<Item[]>([]);
const count = items.length;
Enter fullscreen mode Exit fullscreen mode

The useRef Pattern for Stable Callbacks

Store the latest callback in a ref to get a stable function reference that always calls the newest version — without adding the callback to dependency arrays:

import { useLatest } from "@reactuses/core";

function useInterval(callback: () => void, delay: number) {
  const callbackRef = useLatest(callback);
  useEffect(() => {
    const id = setInterval(() => callbackRef.current(), delay);
    return () => clearInterval(id);
  }, [delay]); // callback is NOT a dependency
}
Enter fullscreen mode Exit fullscreen mode

How ReactUse Hooks Handle Performance Internally

ReactUse hooks use three key patterns:

  1. Refs for callbacks. Hooks like useThrottleFn store your callback via useLatest. The wrapper calls the latest version through the ref — no stale closures, no need for useCallback.

  2. Memoized setup. Expensive initialization (creating throttled functions) is wrapped in useMemo, running only when configuration changes.

  3. Automatic cleanup. Pending timers are cancelled on unmount via useUnmount.

// Simplified internal pattern of useThrottleFn
function useThrottleFn(fn, wait, options) {
  const fnRef = useLatest(fn);
  const throttled = useMemo(
    () => throttle((...args) => fnRef.current(...args), wait, options),
    [wait]
  );
  useUnmount(() => throttled.cancel());
  return { run: throttled, cancel: throttled.cancel, flush: throttled.flush };
}
Enter fullscreen mode Exit fullscreen mode

You don't need to wrap callbacks in useCallback before passing them to ReactUse hooks — the ref pattern handles it.

Practical Example: Optimized Search

Before — API call on every keystroke:

function Search() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState<Item[]>([]);

  useEffect(() => {
    if (query) {
      fetch(`/api/search?q=${query}`).then((r) => r.json()).then(setResults);
    }
  }, [query]);

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <ResultList items={results} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

After — debounced + memoized, ~90% fewer API calls:

import { useDebounce } from "@reactuses/core";
import { memo, useState, useEffect } from "react";

const MemoizedResultList = memo(ResultList);

function Search() {
  const [query, setQuery] = useState("");
  const debouncedQuery = useDebounce(query, 300);
  const [results, setResults] = useState<Item[]>([]);

  useEffect(() => {
    if (debouncedQuery) {
      fetch(`/api/search?q=${debouncedQuery}`).then((r) => r.json()).then(setResults);
    }
  }, [debouncedQuery]);

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <MemoizedResultList items={results} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

React 19 Compiler

The React Compiler aims to automatically insert useMemo and useCallback at build time. When it ships broadly, many manual memoization patterns become unnecessary. However, it does not replace good state design, debouncing, throttling, or ref-based patterns. It automates the mechanical part — the architectural decisions remain yours.

Common Mistakes

  1. Memoizing everything. Adds overhead without measurable benefit for trivial computations.
  2. useCallback without React.memo. Stable reference is useless if the child re-renders anyway.
  3. All state in one object. Every field update triggers re-renders for all consumers.
  4. Wrong dependency arrays. Missing deps cause stale closures; extra deps cause unnecessary recomputation.
  5. Inline objects in JSX. style={{ color: "red" }} creates new references every render, defeating memoization.

Frequently Asked Questions

When should I use useMemo?

Use useMemo when you have measured a slow computation (filtering large arrays, complex transformations) or when you need referential equality for a value passed to a React.memo child. Do not use it for trivial calculations like arithmetic or string concatenation.

Does useCallback improve performance on its own?

No. useCallback only helps when the returned function is passed to a component wrapped in React.memo or used as a dependency in another hook like useEffect. Without those consumers, the stable reference has no effect.

How do I know if re-renders are a problem?

Use the React DevTools Profiler. It shows which components rendered, how long each render took, and what triggered it. Focus on components with render times above 1-2ms or those that re-render frequently with no visible change.

Will the React Compiler make all this unnecessary?

Partially. The compiler automates useMemo and useCallback insertion. It does not automate state structure decisions, debouncing, throttling, or architectural choices about component boundaries.


ReactUse provides 100+ production-ready hooks for React — TypeScript-first, tree-shakable, SSR-compatible.

Get started with ReactUse →

Top comments (0)