When you combine React’s useState hook with TypeScript, you get a powerful duo that catches bugs before they happen. But there’s more to it than just slapping types on your state variables. Understanding how TypeScript infers types, when to be explicit, and how to handle complex state shapes will transform your React development experience from “hoping it works” to “knowing it works.”
In this tutorial, we’ll build up from the simplest useState patterns to handling complex state shapes, using the JSONPlaceholder todos API as our real-world example throughout.
Type Inference: When TypeScript Just Knows
TypeScript is smart about inferring types from initial values. For simple primitives, you often don’t need explicit types:
import { useState } from 'react';
function TodoCounter() {
// TypeScript infers these types automatically
const [count, setCount] = useState(0); // number
const [title, setTitle] = useState(''); // string
const [isDone, setIsDone] = useState(false); // boolean
return (
<div>
<p>Todos completed: {count}</p>
<button onClick={() => setCount(count + 1)}>Mark one complete</button>
{/* TypeScript ensures type safety */}
<button onClick={() => setCount('five')}>
{' '}
{/* ❌ Error: string not assignable to number */}
This won't compile
</button>
</div>
);
}The beauty here? TypeScript automatically types setCount to only accept numbers or a function that returns a number. You get compile-time safety without writing a single type annotation.
When Inference Isn’t Enough: Being Explicit
While inference is great for primitives, you’ll need to be explicit in several common scenarios:
Empty Arrays: The Classic Trap
function TodoList() {
// ❌ Bad: TypeScript infers never[] - you can't add anything!
const [items, setItems] = useState([]);
// This will cause a TypeScript error
setItems(['Buy milk']); // Error: Type 'string' is not assignable to type 'never'
}The fix? Be explicit about what the array will contain:
function TodoList() {
// ✅ Good: Explicitly typed array
const [items, setItems] = useState<string[]>([]);
// Now this works perfectly
setItems(['Buy milk', 'Walk the dog']);
// TypeScript still protects you
setItems([1, 2, 3]); // ❌ Error: number[] not assignable to string[]
}Union Types for Constrained Values
When your state should only be certain specific values, guide TypeScript with union types:
type FilterStatus = 'all' | 'active' | 'completed';
function TodoFilter() {
// Without explicit typing, TypeScript infers 'string'
const [filter, setFilter] = useState<FilterStatus>('all');
// TypeScript ensures only valid values
setFilter('completed'); // ✅ Works
setFilter('pending'); // ❌ Error: 'pending' not assignable to FilterStatus
return (
<div>
<button onClick={() => setFilter('all')}>All</button>
<button onClick={() => setFilter('active')}>Active</button>
<button onClick={() => setFilter('completed')}>Completed</button>
</div>
);
}Working with Objects: The Todo Type
Let’s define our Todo type based on the JSONPlaceholder API structure:
interface Todo {
userId: number;
id: number;
title: string;
completed: boolean;
}
function TodoItem() {
// Single todo item (can be null initially)
const [todo, setTodo] = useState<Todo | null>(null);
// Array of todos
const [todos, setTodos] = useState<Todo[]>([]);
// Example: Adding a new todo
const addTodo = (title: string) => {
const newTodo: Todo = {
userId: 1,
id: Date.now(), // Temporary ID
title,
completed: false,
};
setTodos((prev) => [...prev, newTodo]);
};
// Example: Toggling completion
const toggleTodo = (id: number) => {
setTodos((prev) =>
prev.map((todo) => (todo.id === id ? { ...todo, completed: !todo.completed } : todo)),
);
};
return (
<div>
{todos.map((todo) => (
<div key={todo.id}>
<input type="checkbox" checked={todo.completed} onChange={() => toggleTodo(todo.id)} />
<span>{todo.title}</span>
</div>
))}
</div>
);
}The Null/Undefined Pattern for Async Data
When data hasn’t loaded yet, using null or undefined as initial state is a common pattern:
function TodoDetail({ todoId }: { todoId: number }) {
// null indicates "not loaded yet"
const [todo, setTodo] = useState<Todo | null>(null);
const [error, setError] = useState<string | null>(null);
// Load todo on mount
useEffect(() => {
fetch(`https://jsonplaceholder.typicode.com/todos/${todoId}`)
.then((res) => res.json())
.then((data: Todo) => setTodo(data))
.catch((err) => setError(err.message));
}, [todoId]);
// TypeScript makes you handle all cases
if (error) return <div>Error: {error}</div>;
if (!todo) return <div>Loading...</div>;
// TypeScript knows todo is definitely Todo here (not null)
return (
<div>
<h2>{todo.title}</h2>
<p>Status: {todo.completed ? 'Done' : 'Pending'}</p>
</div>
);
}Functional Updates: The Safe Way
When your next state depends on the previous state, use the functional update pattern. TypeScript automatically infers the parameter type:
function TodoStats() {
const [stats, setStats] = useState({
total: 0,
completed: 0,
active: 0,
});
// TypeScript knows 'prev' has the shape of our stats object
const incrementCompleted = () => {
setStats((prev) => ({
...prev,
completed: prev.completed + 1,
active: prev.active - 1,
}));
};
// This pattern prevents stale closure bugs
const batchUpdate = () => {
// All three updates will use the latest state
setStats((prev) => ({ ...prev, total: prev.total + 1 }));
setStats((prev) => ({ ...prev, active: prev.active + 1 }));
setStats((prev) => ({ ...prev, total: prev.total + 1 }));
};
return (
<div>
<p>Total: {stats.total}</p>
<p>Completed: {stats.completed}</p>
<p>Active: {stats.active}</p>
</div>
);
}Complex State: When to Split vs. Combine
Deciding whether to use multiple useState calls or combine state into an object depends on how the values relate:
// ✅ Good: Related values that change together
function TodoForm() {
const [formData, setFormData] = useState({
title: '',
description: '',
priority: 'medium' as 'low' | 'medium' | 'high',
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// All form data is together
console.log('Submitting:', formData);
};
return (
<form onSubmit={handleSubmit}>
<input
value={formData.title}
onChange={(e) =>
setFormData((prev) => ({
...prev,
title: e.target.value,
}))
}
/>
{/* Other fields... */}
</form>
);
}
// ✅ Good: Separate concerns that change independently
function TodoApp() {
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState<'all' | 'active' | 'completed'>('all');
const [searchTerm, setSearchTerm] = useState('');
// Each piece of state has a single responsibility
// They can be updated independently without affecting others
}Generic Custom Hooks with useState
Create reusable stateful logic with generic custom hooks:
// Generic hook for any toggleable value
function useToggle<T>(initialValue: T, alternateValue: T) {
const [value, setValue] = useState<T>(initialValue);
const toggle = useCallback(() => {
setValue((current) => (current === initialValue ? alternateValue : initialValue));
}, [initialValue, alternateValue]);
return [value, toggle, setValue] as const;
}
// Usage with different types
function TodoControls() {
const [view, toggleView] = useToggle('list', 'grid');
const [isDark, toggleTheme] = useToggle(false, true);
const [sortOrder, toggleSort] = useToggle<'asc' | 'desc'>('asc', 'desc');
return (
<div>
<button onClick={toggleView}>View: {view}</button>
<button onClick={toggleTheme}>Theme: {isDark ? 'Dark' : 'Light'}</button>
<button onClick={toggleSort}>Sort: {sortOrder}</button>
</div>
);
}Common Pitfalls and How to Avoid Them
Pitfall 1: Mutating State Directly
function TodoMutationBug() {
const [todos, setTodos] = useState<Todo[]>([]);
// ❌ Bad: Mutating state directly
const buggyAddTodo = (newTodo: Todo) => {
todos.push(newTodo); // This mutates the existing array
setTodos(todos); // React won't re-render!
};
// ✅ Good: Creating a new array
const correctAddTodo = (newTodo: Todo) => {
setTodos([...todos, newTodo]); // New array reference
};
// ✅ Better: Using functional update
const bestAddTodo = (newTodo: Todo) => {
setTodos((prev) => [...prev, newTodo]); // Always uses latest state
};
}Pitfall 2: Stale Closures in Event Handlers
function StaleClosureExample() {
const [count, setCount] = useState(0);
useEffect(() => {
// ❌ Bad: count is captured and becomes stale
const timer = setInterval(() => {
setCount(count + 1); // Always adds to the initial count!
}, 1000);
return () => clearInterval(timer);
}, []); // Empty deps means count is captured once
// ✅ Good: Using functional update
useEffect(() => {
const timer = setInterval(() => {
setCount((prev) => prev + 1); // Always uses current value
}, 1000);
return () => clearInterval(timer);
}, []); // Now the empty deps array is safe
}Pitfall 3: Over-specifying Types
// ❌ Unnecessary: TypeScript can infer this
const [name, setName] = useState<string>('');
// ✅ Let inference work for simple cases
const [name, setName] = useState('');
// ✅ Be explicit only when needed
const [user, setUser] = useState<User | null>(null);
const [items, setItems] = useState<string[]>([]);Performance Tips
Tip 1: Lazy Initial State
When initial state requires expensive computation, use a function:
function ExpensiveComponent() {
// ❌ This runs on every render
const [data, setData] = useState(expensiveComputation());
// ✅ This runs only once
const [data, setData] = useState(() => expensiveComputation());
// Real example: parsing localStorage
const [todos, setTodos] = useState<Todo[]>(() => {
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
});
}Tip 2: Batching State Updates
React automatically batches updates in event handlers:
function TodoBatching() {
const [todos, setTodos] = useState<Todo[]>([]);
const [count, setCount] = useState(0);
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
const handleClick = () => {
// These are automatically batched - one re-render
setTodos([]);
setCount(0);
setLastUpdate(new Date());
};
// In async code, updates are also batched in React 18+
const handleAsync = async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/todos');
const data = await response.json();
// Still batched in React 18+
setTodos(data);
setCount(data.length);
setLastUpdate(new Date());
};
}Putting It Together: A Complete Example
Here’s everything we’ve learned in a practical todo list component:
interface Todo {
userId: number;
id: number;
title: string;
completed: boolean;
}
type FilterStatus = 'all' | 'active' | 'completed';
function TodoManager() {
// Multiple pieces of state with proper typing
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState<FilterStatus>('all');
const [newTodoTitle, setNewTodoTitle] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// Computed value based on state
const filteredTodos = todos.filter((todo) => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});
// Add a new todo
const addTodo = (e: React.FormEvent) => {
e.preventDefault();
if (!newTodoTitle.trim()) return;
const newTodo: Todo = {
userId: 1,
id: Date.now(),
title: newTodoTitle,
completed: false,
};
setTodos((prev) => [...prev, newTodo]);
setNewTodoTitle('');
};
// Toggle todo completion
const toggleTodo = (id: number) => {
setTodos((prev) =>
prev.map((todo) => (todo.id === id ? { ...todo, completed: !todo.completed } : todo)),
);
};
// Delete a todo
const deleteTodo = (id: number) => {
setTodos((prev) => prev.filter((todo) => todo.id !== id));
};
return (
<div>
<h1>Todo Manager</h1>
{/* Add new todo form */}
<form onSubmit={addTodo}>
<input
type="text"
value={newTodoTitle}
onChange={(e) => setNewTodoTitle(e.target.value)}
placeholder="What needs to be done?"
/>
<button type="submit">Add</button>
</form>
{/* Filter buttons */}
<div>
<button onClick={() => setFilter('all')} disabled={filter === 'all'}>
All ({todos.length})
</button>
<button onClick={() => setFilter('active')} disabled={filter === 'active'}>
Active ({todos.filter((t) => !t.completed).length})
</button>
<button onClick={() => setFilter('completed')} disabled={filter === 'completed'}>
Completed ({todos.filter((t) => t.completed).length})
</button>
</div>
{/* Todo list */}
{error && <div>Error: {error}</div>}
{isLoading && <div>Loading...</div>}
<ul>
{filteredTodos.map((todo) => (
<li key={todo.id}>
<input type="checkbox" checked={todo.completed} onChange={() => toggleTodo(todo.id)} />
<span
style={{
textDecoration: todo.completed ? 'line-through' : 'none',
}}
>
{todo.title}
</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
);
}Key Takeaways
- Let TypeScript infer when possible - For simple primitives, TypeScript’s inference is usually sufficient
- Be explicit with complex types - Arrays, objects, and unions need explicit typing
- Use union types with null - The
Type | nullpattern is perfect for async data - Prefer functional updates - They prevent stale closure bugs and are more predictable
- Split state by concern - Keep unrelated state separate for cleaner code
- Initialize expensive state lazily - Use functions for expensive initial computations
What’s Next?
Now that you understand the fundamentals of useState with TypeScript, you’re ready to tackle async data fetching. In the next tutorial, we’ll combine useState with useEffect to fetch real todos from the JSONPlaceholder API, handle loading states, and manage errors—all with complete type safety.