Back to Blog

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

ReactReact 19useEffectuseCallbackuseMemoPerformanceWeb DevelopmentTypeScript
React hooks performance optimization: useCallback, useMemo, and React.memo patterns

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.

tsx
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>; }

Fix 1: useCallback: Stabilize Function References

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

tsx
import { useEffect, useCallback, useState } from 'react'; 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>; }

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:

tsx
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

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:

tsx
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>; }

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

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

tsx
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 => ( <UserCard key={user.id} user={user} onSelect={handleSelect} /> ))} </ul> ); }

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:

tsx
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 }

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+):

typescript
// next.config.ts const nextConfig = { experimental: { reactCompiler: true, }, }; export default nextConfig;

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

tsx
// 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>; }

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
tsx
// 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]);

Decision Chart

SituationTool
Function in useEffect depsuseCallback or move function inside effect
Expensive derived valueuseMemo
Child component re-renders too oftenReact.memo + useCallback for prop callbacks
Custom hook returning functionsuseCallback on returned functions
React 19 + React Compiler enabledNothing, 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

X / Twitter
LinkedIn
Facebook
WhatsApp
Telegram

About Pooya Golchian

Common questions about Pooya's work, AI services, and how to start a project together.

Get practical AI and engineering playbooks

Weekly field notes on private AI, automation, and high-performance Next.js builds. Each edition is concise, implementation-ready, and tested in production work.

Open full subscription page

Get the latest insights on AI and full-stack development.