Typing Props, State & Hooks
React's hooks are generic functions — they accept type parameters that tell the compiler exactly what shape of data to expect. Typing them correctly eliminates a whole class of runtime errors and gives you precise autocompletion everywhere state flows through your component tree.
This page covers useState, useReducer, useContext, useCallback, useMemo, and the patterns for building fully-typed custom hooks.
useState
useState infers the state type from the initial value when it is unambiguous. Provide an explicit type parameter when the initial value is null, undefined, or a union:
import { useState } from 'react';
// Inference works — initial value is a string
const [name, setName] = useState(''); // string
// Inference works — initial value is a number
const [count, setCount] = useState(0); // number
// Must provide type — initial null means TypeScript would infer null
const [user, setUser] = useState<User | null>(null);
// Explicit type avoids narrowing issues later
const [items, setItems] = useState<string[]>([]); // not never[]
interface User {
id: number;
name: string;
email: string;
}The setter accepts either a new value or an updater function that receives the previous state:
const [count, setCount] = useState(0);
// Direct value
setCount(5);
setCount('five'); // Error: Argument of type 'string' is not assignable to parameter of type 'number'
// Updater function — receives previous state
setCount(prev => prev + 1);
setCount(prev => prev * 2);
// With objects — always spread to avoid mutation
const [user, setUser] = useState<User>({ id: 1, name: 'Alice', email: 'a@b.com' });
setUser(prev => ({ ...prev, name: 'Bob' })); // OK — partial update patternDiscriminated Union State
Async operations have three states: loading, success, and error. A discriminated union models this perfectly:
type AsyncState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function UserProfile({ userId }: { userId: string }) {
const [state, setState] = useState<AsyncState<User>>({ status: 'idle' });
async function loadUser() {
setState({ status: 'loading' });
try {
const user = await fetchUser(userId);
setState({ status: 'success', data: user });
} catch (err) {
setState({ status: 'error', error: err instanceof Error ? err : new Error(String(err)) });
}
}
if (state.status === 'idle') return <button onClick={loadUser}>Load</button>;
if (state.status === 'loading') return <div>Loading...</div>;
if (state.status === 'error') return <div>Error: {state.error.message}</div>;
// TypeScript narrows to { status: 'success'; data: User }
return <div>{state.data.name}</div>;
}isLoading, isError). TypeScript enforces that you handle every case.useReducer
useReducer is ideal for complex state with multiple related transitions. Type the state and action union explicitly:
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
coupon: string | null;
isOpen: boolean;
}
// Discriminated union of all possible actions
type CartAction =
| { type: 'ADD_ITEM'; payload: CartItem }
| { type: 'REMOVE_ITEM'; payload: { id: string } }
| { type: 'UPDATE_QTY'; payload: { id: string; quantity: number } }
| { type: 'APPLY_COUPON'; payload: { code: string } }
| { type: 'TOGGLE_CART' }
| { type: 'CLEAR' };
const initialState: CartState = { items: [], coupon: null, isOpen: false };
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case 'ADD_ITEM':
return { ...state, items: [...state.items, action.payload] };
case 'REMOVE_ITEM':
return { ...state, items: state.items.filter(i => i.id !== action.payload.id) };
case 'UPDATE_QTY':
return {
...state,
items: state.items.map(i =>
i.id === action.payload.id ? { ...i, quantity: action.payload.quantity } : i
),
};
case 'APPLY_COUPON':
return { ...state, coupon: action.payload.code };
case 'TOGGLE_CART':
return { ...state, isOpen: !state.isOpen };
case 'CLEAR':
return initialState;
}
}
function Cart() {
const [state, dispatch] = useReducer(cartReducer, initialState);
return (
<div>
<button onClick={() => dispatch({ type: 'TOGGLE_CART' })}>
Cart ({state.items.length})
</button>
{/* TypeScript errors if payload is wrong shape */}
<button onClick={() => dispatch({ type: 'ADD_ITEM', payload: { id: '1', name: 'Widget', price: 9.99, quantity: 1 } })}>
Add Item
</button>
</div>
);
}action parameter in each case branch, so action.payload has exactly the right type in every case — no manual casting needed.useContext
A common mistake is creating a context with null as the default, which makes the type T | null everywhere. A safer pattern uses a custom hook that throws when used outside the provider:
import { createContext, useContext, useState, ReactNode } from 'react';
interface ThemeContextValue {
theme: 'light' | 'dark';
toggleTheme: () => void;
}
// null as default — the context has no value until the Provider renders
const ThemeContext = createContext<ThemeContextValue | null>(null);
// Custom hook — throws a helpful error if used outside Provider
function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used inside <ThemeProvider>');
return ctx;
}
function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<'light' | 'dark'>('light');
const value: ThemeContextValue = {
theme,
toggleTheme: () => setTheme(t => t === 'light' ? 'dark' : 'light'),
};
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
// Consumer — no null checks needed
function ThemeToggle() {
const { theme, toggleTheme } = useTheme(); // type is ThemeContextValue, never null
return (
<button onClick={toggleTheme}>
Current: {theme}
</button>
);
}useCallback and useMemo
import { useCallback, useMemo } from 'react';
interface Product { id: number; name: string; price: number; category: string; }
function ProductList({ products }: { products: Product[] }) {
const [filter, setFilter] = useState('');
// useMemo — return type is inferred from the callback
const filtered = useMemo(
() => products.filter(p => p.name.toLowerCase().includes(filter.toLowerCase())),
[products, filter]
);
// filtered: Product[]
// useCallback — parameter and return types are inferred
const handleSearch = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setFilter(e.target.value);
},
[]
);
// handleSearch: (e: React.ChangeEvent<HTMLInputElement>) => void
return (
<div>
<input onChange={handleSearch} placeholder="Search..." />
{filtered.map(p => <div key={p.id}>{p.name} — ${p.price}</div>)}
</div>
);
}Custom Hooks
Custom hooks are regular functions that start with use. They should explicitly type their return values for clarity:
// Generic fetch hook
interface UseFetchResult<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => void;
}
function useFetch<T>(url: string): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
const json = await res.json() as T;
setData(json);
} catch (e) {
setError(e instanceof Error ? e : new Error(String(e)));
} finally {
setLoading(false);
}
}, [url]);
useEffect(() => { fetchData(); }, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
// Usage — T is provided at the call site
function UserPage({ userId }: { userId: string }) {
const { data: user, loading, error } = useFetch<User>(`/api/users/${userId}`);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!user) return null;
return <div>{user.name}</div>;
}useLocalStorage — Persisted State
function useLocalStorage<T>(key: string, initialValue: T): [T, (value: T) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? (JSON.parse(item) as T) : initialValue;
} catch {
return initialValue;
}
});
const setValue = useCallback((value: T) => {
try {
setStoredValue(value);
window.localStorage.setItem(key, JSON.stringify(value));
} catch (err) {
console.error('useLocalStorage error:', err);
}
}, [key]);
return [storedValue, setValue];
}
// Usage
const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');
const [user, setUser] = useLocalStorage<User | null>('user', null);Typing Component Props Patterns
Some common prop patterns and how to type them:
// Render prop pattern
interface DataLoaderProps<T> {
url: string;
render: (data: T) => ReactNode;
}
function DataLoader<T>({ url, render }: DataLoaderProps<T>) {
const { data } = useFetch<T>(url);
if (!data) return null;
return <>{render(data)}</>;
}
<DataLoader<User[]>
url="/api/users"
render={(users) => users.map(u => <span key={u.id}>{u.name}</span>)}
/>
// Controlled vs uncontrolled — use type union
interface InputProps {
// Controlled
value?: string;
onChange?: (value: string) => void;
// Uncontrolled
defaultValue?: string;
}
// Spread native props — extend the HTML element's type
interface TextFieldProps extends React.InputHTMLAttributes<HTMLInputElement> {
label: string;
error?: string;
}
function TextField({ label, error, ...inputProps }: TextFieldProps) {
return (
<div>
<label>{label}</label>
<input {...inputProps} />
{error && <span className="error">{error}</span>}
</div>
);
}Avoiding Common Typing Mistakes
Mistake | Problem | Fix |
|---|---|---|
useState([]) | Inferred as never[] | useState<string[]>([]) |
useState(null) | Inferred as null, not T | null | useState<User | null>(null) |
React.FC<Props> | Implicit children, historical baggage | function Comp({ }: Props) { } |
any in event handlers | Loses type safety | React.ChangeEvent<HTMLInputElement> |
useContext without null guard | Type is T | null everywhere | Custom hook that throws outside provider |
Mutating state directly | React does not re-render | Always return new object/array |
Key Takeaways
useState<T> — provide explicit type when initial value is null, undefined, or a union
Discriminated unions model async state far better than boolean flags
useReducer — type the state interface and a discriminated union for all actions
useContext — pair with a custom hook that throws when used outside the provider
Custom hooks should explicitly declare their return type interface
Extend HTMLAttributes<T> to pass through native element props safely
useCallback and useMemo infer types from the callback — rarely need explicit params
Render props and generic components let you build reusable, fully-typed abstractions