TypeScript React Hooks: How to Type useState
The useState hook in React automatically infers the type of state from its initial value, but TypeScript's inference can be imprecise when you need to account for undefined, null, or union types later. By explicitly typing the generic argument to useState<T>, you catch state-shape mismatches at compile time and enable IntelliSense to guide your updates.
I've spent 8 years building large React applications in TypeScript, and the single biggest source of subtle bugs was mistyped state. This article covers the patterns you'll use 90% of the time and the advanced techniques for complex state shapes.
How TypeScript Infers useState Types
When you write const [count, setCount] = useState(0), TypeScript infers that count is a number and setCount expects a number. This works because the initial value 0 is unambiguous. However, if you initialize with null or a complex object, inference becomes unreliable.
TypeScript's inference engine looks at the initial value and assigns the tightest type possible. For primitive values like strings, numbers, and booleans, this is exact. For objects and arrays, the inferred type is often wider than you intend, and it may not account for shape changes during the component's lifetime.
Here's how automatic inference works in practice:
// Inferred as number — precise
const [count, setCount] = useState(0);
// Inferred as string — precise
const [name, setName] = useState('Alice');
// Inferred as null — loses precision; prevents later shape widening
const [user, setUser] = useState(null);
// Inferred as { id: number; name: string } — rigid
const [data, setData] = useState({ id: 1, name: 'Item' });
The problem arises when you later set state to a value the inferred type doesn't predict. For example, if you initialize user to null but later fetch and set a full user object, TypeScript won't know the object shape and flags it as an error.
Explicit Generic Types for Precision
To fix inference gaps, pass a type argument to useState<T>:
// Explicit: user can be User or null
interface User {
id: number;
email: string;
role: 'admin' | 'user';
}
const [user, setUser] = useState<User | null>(null);
// Now TypeScript knows user is User | null
// and setUser accepts User or null
setUser({ id: 1, email: '[email protected]', role: 'admin' });
setUser(null);
This explicit type makes state shapes predictable across the component's lifetime. Every time you call setUser, TypeScript checks that the new value matches User | null. This is the most common pattern and the foundation for all other typing strategies.
When you fetch data asynchronously and populate the state, the type guard ensures you've handled all cases (loading, success, error). For instance, a loading state often uses a union of states:
type LoadingState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: string };
const [state, setState] = useState<LoadingState<User>>({ status: 'idle' });
// TypeScript enforces correct shape per branch
if (state.status === 'success') {
console.log(state.data.email); // OK
// console.log(state.error); // Error: no 'error' in success branch
}
This discriminated union pattern is the most robust approach for complex state because TypeScript narrows the type automatically when you check the status field.
Advanced: Union Types and Discriminated Unions
For state that changes shape significantly—such as toggling between "logged in" and "logged out"—use discriminated unions. Each variant includes a type or status field that TypeScript uses to narrow the shape:
type AuthState =
| { type: 'idle' }
| { type: 'authenticating' }
| { type: 'authenticated'; token: string; user: { name: string; id: number } }
| { type: 'error'; message: string };
const [auth, setAuth] = useState<AuthState>({ type: 'idle' });
// In JSX or event handlers
switch (auth.type) {
case 'authenticated':
// TypeScript knows token and user exist here
return <span>Welcome, {auth.user.name}</span>;
case 'error':
// TypeScript knows message exists here
return <span>Error: {auth.message}</span>;
default:
return null;
}
Discriminated unions are superior to optional fields because they prevent "impossible" states (e.g., having both a token and an error simultaneously). This pattern scales across teams because new maintainers see exactly what states are possible.
For deeply nested or large objects, consider splitting state into multiple useState calls or using useReducer (covered in the next article). Multiple hooks for independent concerns are often easier to reason about:
// Split concerns into separate hooks
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
Generics and Reusable Hooks
When you build a custom hook that returns useState, use a generic type parameter so callers can specify the state type:
function useAsync<T>(
fn: () => Promise<T>,
deps: React.DependencyList
): {
data: T | null;
loading: boolean;
error: Error | null;
} {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
React.useEffect(() => {
setLoading(true);
fn()
.then((result) => {
setData(result);
setError(null);
})
.catch((err) => {
setError(err);
setData(null);
})
.finally(() => setLoading(false));
}, deps);
return { data, loading, error };
}
// Usage: TypeScript infers T as User from the fetch
const { data: user, loading, error } = useAsync(
() => fetch('/api/user').then((r) => r.json()),
[]
);
This pattern allows the hook to be reused for any async data type while preserving type safety for callers.
Key Takeaways
- TypeScript infers
useStatetype from the initial value, but explicituseState<T>ensures precision for nullable or union types. - Discriminated unions (
typeorstatusfield) prevent impossible state combinations and enable automatic type narrowing. - Split complex state into multiple hooks for independent concerns, or use
useReducerfor highly interdependent state. - When building custom hooks, use generic type parameters so callers specify the state type.
- Always account for loading and error states in async operations using union types.
Frequently Asked Questions
Why should I use useState<T> instead of relying on type inference?
Inference works for simple primitives and single-shape objects, but fails when state must hold multiple shapes (null before load, object after). Explicit useState<T | null> is clearer for collaborators and prevents type-narrowing bugs in setters.
What is a discriminated union and why is it better than optional fields?
A discriminated union uses a shared field (type, status) to distinguish variants. TypeScript narrows all properties based on that field, preventing "impossible" states (e.g., token and error both present). Optional fields allow all combinations, including invalid ones.
Can I use useState for complex nested state?
Yes, but consider splitting into multiple hooks or useReducer. Multiple useState calls scale better for independent concerns; useReducer excels when state changes are interdependent (e.g., user fetch triggers both data and loading state together).
How do I type a setState callback that accepts a previous value?
Use the functional form: setCount((prev: number) => prev + 1). TypeScript infers the parameter type from the state type, so no additional annotation is needed unless you want to override it.