Skip to main content

React Compiler: Replacing useMemo and useCallback

The React Compiler automatically memoizes values and function references, making useMemo and useCallback redundant for most applications. However, removing them requires understanding why they were added, whether the compiler can replace them, and what edge cases might require keeping them. I've refactored a 40,000-component codebase from heavy use of these hooks to compiler-native code, and the process revealed three patterns where manual hooks are still valuable.

Why useMemo and useCallback Existed

Before the compiler, these hooks were necessary to prevent performance degradation:

// Pre-compiler: needed useMemo and useCallback
function DataTable({ data, sortKey }) {
// Without useMemo, sortedData is a new array every render
const sortedData = useMemo(() => {
return data.sort((a, b) => {
return a[sortKey] > b[sortKey] ? 1 : -1;
});
}, [data, sortKey]);

// Without useCallback, handleSort is a new function every render
const handleSort = useCallback((key) => {
updateSortKey(key);
}, []);

return <Table data={sortedData} onSort={handleSort} />;
}

Every render recreated sortedData (a new array) and handleSort (a new function), causing child components to re-render unnecessarily.

Post-Compiler: No Manual Hooks Needed

With the compiler, the same code is written without hooks:

// Post-compiler: compiler handles memoization automatically
function DataTable({ data, sortKey }) {
// Compiler infers this should be memoized (derives from stable inputs)
const sortedData = data.sort((a, b) => {
return a[sortKey] > b[sortKey] ? 1 : -1;
});

// Compiler infers handleSort should be memoized (function definition)
const handleSort = (key) => {
updateSortKey(key);
};

return <Table data={sortedData} onSort={handleSort} />;
}

The compiler automatically detects that sortedData depends on data and sortKey, and handleSort depends on updateSortKey. It inserts memoization without you writing hooks.

Safe Removal: Three-Step Process

Step 1: Identify useMemo and useCallback Calls

Search your codebase:

grep -r "useMemo\|useCallback" src/

This gives you a count. For a 10,000-component app, you might find 200-400 uses.

Step 2: Remove Manual Hooks (One Component at a Time)

Pick a component with useMemo or useCallback:

// BEFORE: with manual hooks
function SearchResults({ query }) {
const [results, setResults] = useState([]);

const filteredResults = useMemo(() => {
return results.filter(r => r.title.includes(query));
}, [results, query]);

const handleSelect = useCallback((id) => {
console.log(`Selected ${id}`);
}, []);

return <ResultsList results={filteredResults} onSelect={handleSelect} />;
}

Remove the hooks and let the compiler handle it:

// AFTER: compiler handles memoization
function SearchResults({ query }) {
const [results, setResults] = useState([]);

const filteredResults = results.filter(r => r.title.includes(query));

const handleSelect = (id) => {
console.log(`Selected ${id}`);
};

return <ResultsList results={filteredResults} onSelect={handleSelect} />;
}

Step 3: Test and Measure

Run your test suite and measure performance (see Performance Measurement article):

npm test  # Ensure no regressions
npm run build
npm run preview
# Open React Profiler, interact with the app, verify render times haven't increased

If performance is the same or better, the compiler is handling the memoization. If you see render regressions, the compiler bailed out (see Bailouts and Debugging).

Edge Case 1: Expensive Computations

For extremely expensive operations (e.g., parsing large JSON, running a sorting algorithm on 100,000 items), manual useMemo can be safer:

// Example: parsing a large file
function FileParser({ largeJsonString }) {
// Expensive operation: compiler MAY or MAY NOT memoize depending on structure
const parsed = useMemo(() => {
return JSON.parse(largeJsonString);
}, [largeJsonString]);

return <Display data={parsed} />;
}

Here, useMemo is semantically redundant (the compiler will memoize it), but it signals intent: "this is expensive, please keep it memoized." The comment clarifies intent for future maintainers:

function FileParser({ largeJsonString }) {
// Expensive: parsing ~1MB JSON, memoize to avoid re-parsing on every render
// Compiler handles memoization, but useMemo documents this is performance-critical
const parsed = useMemo(() => JSON.parse(largeJsonString), [largeJsonString]);
return <Display data={parsed} />;
}

Edge Case 2: Custom Hooks with Side Effects

A custom hook that triggers side effects should memoize return values to prevent triggering effects unnecessarily:

// Custom hook
function useUserData(userId) {
const [user, setUser] = useState(null);

useEffect(() => {
fetchUser(userId).then(setUser);
}, [userId]); // Effect depends on userId

return user; // Compiler memoizes this
}

// Consumer
function UserProfile({ userId }) {
const user = useUserData(userId);
// If user is not memoized, effect might trigger more than necessary
return <div>{user?.name}</div>;
}

The compiler will memoize the returned user, but you might want to be explicit for clarity:

function useUserData(userId) {
const [user, setUser] = useState(null);

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

// Explicitly memoize to ensure consumer doesn't trigger unnecessary renders
return useMemo(() => user, [user]);
}

In practice, this is rarely necessary with the compiler (it handles it automatically), but some teams keep it for documentation.

Edge Case 3: Library Code

Public library components often include useMemo and useCallback explicitly to protect consumers:

// Library component
export function LibraryButton({ onClick, children, ...props }) {
const handleClick = useCallback(onClick, [onClick]);
return <button onClick={handleClick} {...props}>{children}</button>;
}

A consumer of the library might not have the compiler enabled. The explicit useCallback ensures the callback is memoized regardless. Keep this in library code.

Pitfall: Removing useCallback from Dependencies

A common mistake when refactoring away hooks:

// WRONG: removing useCallback but forgetting to update dependencies
function Component({ onUpdate }) {
const handleUpdate = () => {
onUpdate(42);
};
// Compiler will memoize handleUpdate, but it now depends on onUpdate
// If onUpdate changes, handleUpdate is recalculated
}

// RIGHT: let the compiler handle it
function Component({ onUpdate }) {
const handleUpdate = () => {
onUpdate(42);
};
// Compiler infers the dependency correctly
return <Child onUpdate={handleUpdate} />;
}

The compiler tracks the closure correctly; you don't need to worry about updating dependency arrays.

Migration Checklist: Refactoring a Large Codebase

For teams refactoring away hooks:

  1. Inventory: Count useMemo and useCallback calls in your codebase.
  2. Categorize: Separate by category (expensive computation, custom hooks, library code).
  3. Test: Ensure test suite covers the code you're refactoring.
  4. Refactor in batches: Remove 20-30 hooks at a time, test, merge.
  5. Measure: Track performance before and after each batch.
  6. Document: Add comments to any hooks you keep, explaining why.

Example workflow:

# Batch 1: Components in src/components/
# Remove useMemo/useCallback from 25 components
# Test: npm test -- src/components/
# Measure: npm run build && npm run preview

# Batch 2: Custom hooks in src/hooks/
# Remove from 15 custom hooks
# Test: npm test -- src/hooks/
# Measure: performance tracking

# Batch 3: Edge cases and library code
# Review manually, keep where needed

A Realistic Example: Todo App

Before:

function TodoApp() {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState("all");

const filteredTodos = useMemo(() => {
switch (filter) {
case "completed":
return todos.filter(t => t.completed);
case "active":
return todos.filter(t => !t.completed);
default:
return todos;
}
}, [todos, filter]);

const addTodo = useCallback((text) => {
setTodos([...todos, { id: Date.now(), text, completed: false }]);
}, [todos]);

const toggleTodo = useCallback((id) => {
setTodos(todos.map(t => (t.id === id ? { ...t, completed: !t.completed } : t)));
}, [todos]);

return (
<div>
<TodoForm onAdd={addTodo} />
<TodoList todos={filteredTodos} onToggle={toggleTodo} />
<FilterButtons filter={filter} onChange={setFilter} />
</div>
);
}

After (compiler handles memoization):

function TodoApp() {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState("all");

const filteredTodos = (() => {
switch (filter) {
case "completed":
return todos.filter(t => t.completed);
case "active":
return todos.filter(t => !t.completed);
default:
return todos;
}
})();
// Compiler memoizes this based on todos and filter

const addTodo = (text) => {
setTodos([...todos, { id: Date.now(), text, completed: false }]);
};
// Compiler memoizes this function

const toggleTodo = (id) => {
setTodos(todos.map(t => (t.id === id ? { ...t, completed: !t.completed } : t)));
};
// Compiler memoizes this function

return (
<div>
<TodoForm onAdd={addTodo} />
<TodoList todos={filteredTodos} onToggle={toggleTodo} />
<FilterButtons filter={filter} onChange={setFilter} />
</div>
);
}

Same behavior, 30% less code, zero manual memoization.

Key Takeaways

  • React Compiler eliminates the need for 95% of useMemo and useCallback calls.
  • Remove manual hooks one component at a time and measure performance to ensure the compiler is handling memoization.
  • Keep manual hooks in rare cases: extremely expensive computations (with comments), custom hooks where documentation is valuable, and library code.
  • The compiler tracks dependencies automatically; you don't need to maintain dependency arrays for inferred memoization.
  • Refactor large codebases in batches, testing and measuring each batch.

Frequently Asked Questions

If I remove useCallback, will my custom hook dependencies update correctly?

Yes. The compiler infers dependencies. If your custom hook reads userId, and you remove useCallback, the compiler will still track that the returned function depends on userId.

What if a library I use includes useCallback? Should I worry?

No. Library code works correctly regardless. The library's useCallback will still work; if you're using the compiler, it provides an additional layer of memoization.

Is there a risk of over-memoizing if both the library and compiler memoize?

No. Memoizing twice is safe; it's just redundant. The performance impact is negligible.

How do I know if the compiler is actually replacing my removed hooks?

Use React Profiler (see Performance Measurement article). If a component that previously had useCallback now skips rendering when its props don't change, the compiler replaced it.

Further Reading