React useCallback TypeScript: Type Dependencies and Functions
useCallback memoizes a function so it retains the same reference across renders (unless dependencies change). In TypeScript, you type the callback function's parameters and return value, and TypeScript validates that the memoized function remains compatible with its callers.
The key insight is that useCallback doesn't change the function's type—it only preserves its identity. TypeScript's job is to ensure the memoized version is as safe as the original.
Basic useCallback Typing
When you create a callback with useCallback, annotate the function's signature before wrapping:
interface OnChangeEvent {
target: {
value: string;
};
}
function SearchInput() {
const [query, setQuery] = React.useState('');
// Explicit function type annotation
const handleChange = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>): void => {
setQuery(e.currentTarget.value);
},
[]
);
return (
<div>
<input onChange={handleChange} placeholder="Search..." />
<p>Query: {query}</p>
</div>
);
}
The inline function inside useCallback takes React.ChangeEvent<HTMLInputElement> and returns void. TypeScript validates this matches the event handler's type when you pass it to <input>.
For functions with complex signatures, extract the type to a separate interface:
type OnClickCallback = (id: string, event: React.MouseEvent<HTMLButtonElement>) => Promise<void>;
function ItemList() {
const [selectedId, setSelectedId] = React.useState<string | null>(null);
const handleItemClick: OnClickCallback = React.useCallback(
async (id, event) => {
event.stopPropagation();
setSelectedId(id);
await fetch(`/api/items/${id}`);
},
[]
);
return (
<div>
<button onClick={(e) => handleItemClick('item1', e)}>Item 1</button>
<button onClick={(e) => handleItemClick('item2', e)}>Item 2</button>
</div>
);
}
useCallback with State Updater Functions
When a callback needs the latest state but depends on it, use the functional form of useState to avoid adding state to the dependency array:
function Counter() {
const [count, setCount] = React.useState(0);
// Good: depends only on callback logic, not state
const increment = React.useCallback((): void => {
setCount((prev) => prev + 1);
}, []);
// Bad: depends on count, so callback is recreated each render
const incrementBad = React.useCallback((): void => {
setCount(count + 1);
}, [count]);
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
</div>
);
}
The key is using the updater form setCount((prev) => prev + 1). This way, the callback doesn't depend on the current value of count, so the dependency array can be empty and the callback's reference never changes.
useCallback for Child Props
When passing a callback to a memoized child component, memoize it with useCallback to prevent unnecessary re-renders:
interface ItemProps {
id: string;
label: string;
onDelete: (id: string) => Promise<void>;
}
const Item = React.memo(({ id, label, onDelete }: ItemProps) => (
<div>
<span>{label}</span>
<button onClick={() => onDelete(id)}>Delete</button>
</div>
));
function ItemList() {
const [items, setItems] = React.useState<string[]>(['Item 1', 'Item 2']);
// Without useCallback, onDelete is recreated every render,
// so Item re-renders even if items haven't changed.
const handleDelete = React.useCallback(
async (id: string): Promise<void> => {
await fetch(`/api/items/${id}`, { method: 'DELETE' });
setItems((prev) => prev.filter((_, i) => i !== +id));
},
[]
);
return (
<div>
{items.map((label, index) => (
<Item key={index} id={String(index)} label={label} onDelete={handleDelete} />
))}
</div>
);
}
Without useCallback, the handleDelete function is recreated every render, and React.memo sees a new prop and re-renders Item unnecessarily. useCallback ensures the callback reference is stable.
Dependencies in useCallback
Dependencies in useCallback must be accurate. If you use a variable in the callback but forget to add it to the dependency array, the callback captures a stale value:
function SearchWithFilter() {
const [query, setQuery] = React.useState('');
const [filter, setFilter] = React.useState('all');
// Bug: uses filter but doesn't depend on it
const searchBad = React.useCallback((): void => {
console.log(`Searching for "${query}" with filter "${filter}"`);
// filter is stale—always the initial value 'all'
}, [query]); // Missing filter in dependencies
// Correct
const searchGood = React.useCallback((): void => {
console.log(`Searching for "${query}" with filter "${filter}"`);
}, [query, filter]);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.currentTarget.value)} />
<select value={filter} onChange={(e) => setFilter(e.currentTarget.value)}>
<option value="all">All</option>
<option value="recent">Recent</option>
</select>
<button onClick={searchGood}>Search</button>
</div>
);
}
TypeScript's useCallback typing alone cannot catch missing dependencies—that's a job for the ESLint plugin eslint-plugin-react-hooks. Always enable it in your project.
Generic useCallback
For custom hooks that return memoized callbacks, use a generic type parameter:
function useAsync<T, P extends any[]>(
fn: (...args: P) => Promise<T>,
onSuccess?: (result: T) => void,
onError?: (error: Error) => void
): {
execute: (...args: P) => Promise<T>;
loading: boolean;
error: Error | null;
} {
const [loading, setLoading] = React.useState(false);
const [error, setError] = React.useState<Error | null>(null);
const execute = React.useCallback(
async (...args: P): Promise<T> => {
setLoading(true);
setError(null);
try {
const result = await fn(...args);
onSuccess?.(result);
return result;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
setError(error);
onError?.(error);
throw error;
} finally {
setLoading(false);
}
},
[fn, onSuccess, onError]
);
return { execute, loading, error };
}
// Usage
interface FetchUserResult {
id: number;
name: string;
}
function Profile() {
const { execute: fetchUser, loading, error } = useAsync<FetchUserResult, [string]>(
(userId) => fetch(`/api/users/${userId}`).then((r) => r.json()),
(user) => console.log('Loaded:', user)
);
return (
<button onClick={() => fetchUser('123')} disabled={loading}>
{loading ? 'Loading...' : 'Load User'}
</button>
);
}
The generic <T, P extends any[]> allows the hook to work with any async function. P is an array type representing the function's parameters, so TypeScript validates that the caller's arguments match.
useCallback vs useRef for Functions
In some cases, storing a function in a ref is simpler than useCallback:
function DebounceSearch() {
const [query, setQuery] = React.useState('');
const searchFnRef = React.useRef<(q: string) => Promise<void>>();
const timerRef = React.useRef<NodeJS.Timeout>();
searchFnRef.current = React.useCallback(async (q: string) => {
const results = await fetch(`/api/search?q=${q}`).then((r) => r.json());
console.log('Results:', results);
}, []);
const handleChange = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.currentTarget.value;
setQuery(value);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(
() => searchFnRef.current?.(value),
500
);
},
[]
);
return <input onChange={handleChange} value={query} />;
}
Here, searchFnRef is updated without triggering a dependency, so the debounce handler doesn't need searchFnRef in its dependencies. This avoids infinite loops when async functions are involved.
Key Takeaways
- Type callback functions with explicit parameter and return types before wrapping in
useCallback. - Use the functional form of state updaters (e.g.,
setCount(prev => prev + 1)) to avoid adding state to dependencies. - Always include all dependencies that the callback uses. Enable
eslint-plugin-react-hooksto catch missing ones. - Memoize callbacks passed to memoized child components to prevent unnecessary re-renders.
- Generic
useCallbackin custom hooks allows type-safe callback reuse across components.
Frequently Asked Questions
When should I use useCallback instead of just defining a function?
Use useCallback when the function is a dependency of another hook (like useEffect) or is passed to a memoized child component. For event handlers that are only used in the same component, a fresh function per render is often fine.
Can useCallback prevent all re-renders?
No. useCallback only memoizes the function reference. The component still re-renders. To prevent re-renders, pair it with React.memo on child components that receive the callback.
What if I need to use a variable that changes but don't want the callback to recreate?
Store it in a ref and update the ref separately. Use useRef for the value and useCallback for the function. The function depends only on itself, and the ref is always current.
Does useCallback have a performance cost?
Yes, slightly. useCallback has a small overhead. Don't overuse it for trivial callbacks. Use it for callbacks passed to memoized children or used in dependency arrays.