React Hooks: Type Inference for useCallback
TypeScript's type inference allows React hooks like useCallback and useState to automatically infer the correct types from their initial values and dependencies. When you pass an event handler to useCallback, TypeScript infers the handler's parameter types from the function body, eliminating the need to manually annotate every handler parameter. However, you sometimes need to explicitly type the initial state or add type parameters when inference isn't sufficient, such as when state can be null initially but becomes an object later.
How Does Type Inference Work in React Hooks?
TypeScript infers types by analyzing the values and operations you perform. When you call useState(''), TypeScript infers the state type is string because the initial value is a string. Similarly, when you use useCallback((e) => { }), TypeScript infers e is a React.SyntheticEvent based on context. However, when the initial state is null or undefined, you must explicitly provide the type so TypeScript knows the eventual shape.
Inferring useState Types from Initial Values
When your initial state is a concrete value, TypeScript automatically infers the type:
import { useState } from 'react';
export const InferredState = () => {
// TypeScript infers the type is string
const [text, setText] = useState('');
// TypeScript infers the type is number
const [count, setCount] = useState(0);
// TypeScript infers the type is boolean
const [isVisible, setIsVisible] = useState(false);
// TypeScript infers the type is an object
const [user, setUser] = useState({
name: 'Alice',
age: 25,
});
const handleTextChange = (e: React.ChangeEvent<HTMLInputElement>) => {
// TypeScript knows setText expects a string
setText(e.currentTarget.value);
};
const handleCountClick = () => {
// TypeScript knows setCount expects a number
setCount(count + 1);
};
return (
<div>
<input onChange={handleTextChange} value={text} />
<button onClick={handleCountClick}>Count: {count}</button>
<p>{user.name} ({user.age})</p>
</div>
);
};
TypeScript infers the exact types from the initial values, so you never pass the wrong type to the setter. If you try to call setText(42) or setCount('hello'), TypeScript errors.
Explicitly Typing useState When Initial Value is Null
When you initialize state as null and later set it to an object, you must provide a type parameter so TypeScript knows the eventual type:
import { useState } from 'react';
interface UserProfile {
id: number;
name: string;
email: string;
}
export const NullableState = () => {
// Without explicit type, TypeScript infers type is null
// const [user, setUser] = useState(null); // Type is null, not UserProfile
// Explicitly provide the type as a union
const [user, setUser] = useState<UserProfile | null>(null);
const handleLoadUser = (userData: UserProfile) => {
// TypeScript knows setUser expects UserProfile or null
setUser(userData);
};
const handleClearUser = () => {
setUser(null);
};
return (
<div>
{user ? (
<p>{user.name} ({user.email})</p>
) : (
<p>No user loaded</p>
)}
<button onClick={() => handleLoadUser({ id: 1, name: 'Bob', email: '[email protected]' })}>
Load User
</button>
<button onClick={handleClearUser}>Clear</button>
</div>
);
};
The <UserProfile | null> type parameter tells TypeScript that user can be either a UserProfile or null. This is essential for optional state that loads asynchronously.
Inferring useCallback Handler Types
useCallback infers handler parameter types from how you use them inside the callback. You don't need to annotate the event parameter because TypeScript infers it:
import { useState, useCallback } from 'react';
export const InferredCallback = () => {
const [email, setEmail] = useState('');
// TypeScript infers e is React.ChangeEvent<HTMLInputElement>
const handleEmailChange = useCallback((e) => {
setEmail(e.currentTarget.value);
}, []);
// For click handlers, TypeScript infers e is React.MouseEvent
const handleSubmit = useCallback((e) => {
e.preventDefault();
console.log('Submitting:', email);
}, [email]);
return (
<form onSubmit={handleSubmit}>
<input onChange={handleEmailChange} value={email} placeholder="Email" />
<button type="submit">Submit</button>
</form>
);
};
TypeScript infers e is a ChangeEvent<HTMLInputElement> because you call e.currentTarget.value. If you tried to access a property that doesn't exist on ChangeEvent, TypeScript would warn you.
When You Must Explicitly Type useCallback
If your callback doesn't use its parameter in a way TypeScript can infer, you must explicitly type it:
import { useCallback } from 'react';
interface FormData {
name: string;
email: string;
}
interface ButtonProps {
onDone: (data: FormData) => void;
}
export const ExplicitCallbackType = (props: ButtonProps) => {
// Without the explicit type, TypeScript infers e is unknown
const handleClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
// Do something, then call the prop
props.onDone({ name: 'Alice', email: '[email protected]' });
}, [props]);
return <button onClick={handleClick}>Submit</button>;
};
Here, the handler doesn't immediately reveal its type from operations on e, so you must annotate it. This is less common than letting TypeScript infer.
Using useCallback with Dependency Arrays
When you add dependencies to useCallback, TypeScript ensures they're used correctly in the body. If you reference a value that's not in the dependency array, TypeScript warns:
import { useState, useCallback } from 'react';
export const CallbackDependencies = () => {
const [count, setCount] = useState(0);
const [text, setText] = useState('');
// TypeScript warns if you use count or text but don't include them in the dependency array
const handleClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
console.log('Count:', count); // Error: count is not in dependencies
// To fix, add count to the dependency array:
setCount(count + 1);
}, []); // Missing count in dependencies
// Correct version
const handleClickCorrect = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
setCount(count + 1);
}, [count]); // count is in dependencies
return (
<form>
<input value={text} onChange={e => setText(e.currentTarget.value)} />
<button onClick={handleClickCorrect}>Count: {count}</button>
</form>
);
};
TypeScript uses the eslint-plugin-react-hooks rules to warn when dependencies are missing or unnecessary. Following these warnings prevents bugs where stale values are captured in closures.
Key Takeaways
- TypeScript automatically infers state types from initial values in
useState. - Use explicit type parameters like
useState<Type | null>(null)when initial state isnullbut will become another type. useCallbackinfers handler parameter types from how you use them in the callback body.- Annotate callback parameters explicitly only when TypeScript can't infer the type.
- Always include variables used in callbacks in the dependency array; TypeScript warns if you miss them.
Frequently Asked Questions
Why does TypeScript sometimes show unknown for callback parameters?
If the parameter isn't used in a way TypeScript can analyze, it defaults to unknown. Explicitly annotate the parameter type: (e: React.MouseEvent<HTMLButtonElement>) => { }.
Can I use type parameters with useState for non-nullable state?
Yes, but it's optional. useState<string>('') and useState('') are equivalent when the initial value is not null. Explicit type parameters are useful for clarity or when the type is complex.
What's the difference between useCallback and useMemo type inference?
useCallback infers handler types from the function body. useMemo infers from the return type of the function. Both use the same inference mechanism.
How do I type a callback that returns a value?
Include the return type in the explicit annotation: const callback = useCallback((): number => { return 42; }, []). TypeScript infers the return type from the function body if you don't specify it.
Does inference work with custom hooks I create?
Yes, if your custom hook returns inferred types from useState or other hooks. TypeScript propagates the types through your hook chain.