DEV Community

Cover image for React useEffect & useCallback in 2026: Stop Unnecessary Re-renders (React 19 Guide)
Pooya Golchian
Pooya Golchian

Posted on • Originally published at pooya.blog

React useEffect & useCallback in 2026: Stop Unnecessary Re-renders (React 19 Guide)

Unnecessary effect re-runs are one of the most common React performance issues. They manifest as duplicate API calls, flickering UIs, and infinite render loops. Understanding why they happen, and when to reach for each memoization tool, is essential for production-grade React.

The Root Cause

React compares effect dependencies using Object.is (strict reference equality). Functions are recreated on every render by default, so their reference changes every time, even if the function body is identical.

function MyComponent({ userId }: { userId: string }) {
  const [data, setData] = useState(null);

  // ❌ Problem: fetchUser is a new function instance on every render
  const fetchUser = () => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setData);
  };

  // The effect re-runs on EVERY render because fetchUser changes every render
  useEffect(() => {
    fetchUser();
  }, [fetchUser]);

  return <div>{data?.name}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Fix 1: useCallback: Stabilize Function References

useCallback returns a memoized version of the function that only changes when its declared dependencies change:


function MyComponent({ userId }: { userId: string }) {
  const [data, setData] = useState(null);

  // ✅ Only recreated when userId changes
  const fetchUser = useCallback(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(setData);
  }, [userId]);

  useEffect(() => {
    fetchUser();
  }, [fetchUser]); // Stable unless userId changes

  return <div>{data?.name}</div>;
}
Enter fullscreen mode Exit fullscreen mode

Fix 2: Move the Function Inside the Effect

If the function is only used inside one effect, move it inside. No dependency array entry needed:

useEffect(() => {
  // Function lives here, no memoization required
  async function fetchUser() {
    const res = await fetch(`/api/users/${userId}`);
    const json = await res.json();
    setData(json);
  }

  fetchUser();
}, [userId]); // userId is the only real dependency
Enter fullscreen mode Exit fullscreen mode

This is often the cleanest solution and preferred by the React team for effects with a single-use function.

Fix 3: useMemo: Memoize Expensive Derived Values

useCallback is syntactic sugar for useMemo returning a function. Use useMemo directly for computed values:

function ProductList({ products, searchTerm }: Props) {
  // ❌ Recomputes on every render
  const filtered = products.filter(p =>
    p.name.toLowerCase().includes(searchTerm.toLowerCase())
  );

  // ✅ Only recomputes when products or searchTerm changes
  const filtered = useMemo(() =>
    products.filter(p =>
      p.name.toLowerCase().includes(searchTerm.toLowerCase())
    ),
    [products, searchTerm]
  );

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

Fix 4: React.memo: Skip Child Re-renders

Wrap child components that receive stable props to prevent re-renders when the parent re-renders:

const UserCard = React.memo(({ user, onSelect }: UserCardProps) => {
  console.log('UserCard render'); // Should only log when user or onSelect changes
  return (
    <div onClick={() => onSelect(user.id)}>
      {user.name}
    </div>
  );
});

function ParentComponent({ users }: { users: User[] }) {
  const [selected, setSelected] = useState<string | null>(null);

  // ✅ Stable: memoize the callback passed to memo'd child
  const handleSelect = useCallback((id: string) => {
    setSelected(id);
  }, []); // No deps, setSelected is always stable

  return (
    <ul>
      {users.map(user => (

      ))}
    </ul>
  );
}
Enter fullscreen mode Exit fullscreen mode

React.memo + useCallback is the canonical pattern for preventing child re-renders.

Fix 5: Custom Hooks: Return Stable References

When building custom hooks, always memoize the values you return:

function useUserData(userId: string) {
  const [data, setData] = useState<User | null>(null);
  const [loading, setLoading] = useState(false);

  const refetch = useCallback(async () => {
    setLoading(true);
    try {
      const res = await fetch(`/api/users/${userId}`);
      setData(await res.json());
    } finally {
      setLoading(false);
    }
  }, [userId]);

  useEffect(() => {
    refetch();
  }, [refetch]);

  return { data, loading, refetch }; // refetch is stable per userId
}
Enter fullscreen mode Exit fullscreen mode

React 19: The Compiler Handles Most of This Automatically

React 19 ships the React Compiler, a Babel/SWC plugin that analyzes your components and automatically inserts memoization where needed. It eliminates most manual useCallback and useMemo calls.

Enable it in next.config.ts (Next.js 15+):

// next.config.ts
const nextConfig = {
  experimental: {
    reactCompiler: true,
  },
};
export default nextConfig;
Enter fullscreen mode Exit fullscreen mode

With the compiler enabled, the following code is automatically optimized without any useCallback:

// React Compiler makes this safe, no manual memoization needed
function AutoOptimized({ userId }: { userId: string }) {
  const [data, setData] = useState(null);

  const fetchUser = () => {
    fetch(`/api/users/${userId}`).then(r => r.json()).then(setData);
  };

  useEffect(() => { fetchUser(); }, [userId]);

  return <div>{data?.name}</div>;
}
Enter fullscreen mode Exit fullscreen mode

The compiler is opt-in for now. Until you enable it, the manual patterns above remain necessary.

Profiling: Find What’s Actually Slow

  1. Install React DevTools (Chrome/Firefox extension)
  2. Open Profiler tab → click Record → interact with your app → Stop
  3. Click a component bar → check "Why did this render?"
  4. Look for: "Props changed" with identical-looking values → unstable references
// Quick diagnostic: log whenever effect runs
useEffect(() => {
  console.log('Effect fired. userId:', userId);
  // If this logs on every keystroke unrelated to userId,
  // you have an unstable reference in the dependency array
}, [userId]);
Enter fullscreen mode Exit fullscreen mode

Decision Chart

Situation Tool
Function in useEffect deps useCallback or move function inside effect
Expensive derived value useMemo
Child component re-renders too often React.memo + useCallback for prop callbacks
Custom hook returning functions useCallback on returned functions
React 19 + React Compiler enabled Nothing, compiler handles it

What NOT to Memoize

  • Simple inline event handlers (onClick={() => setCount(c => c + 1)})
  • Functions that aren’t in dependency arrays or passed to memoized children
  • Everything preemptively: measure first, optimize second

The rule: memoize to solve a specific, measured problem, not as a default coding style.

Further Reading

Top comments (0)