How to type useState with an object.
Nullable, partial, and correct.
useState({ name: 'Alice' }) works — inference handles it. But useState(null) doesn't, because TypeScript infers null, not User | null. And updating a single field of an object state requires a spread pattern that trips up beginners. This page covers both: the generic annotation for nullable state, and the correct update pattern for object state.
Short answer: Annotate with the generic when the initial value doesn't cover the full range: useState. Update with the functional setter and spread: setUser(prev => ({ ...prev, name: 'Bob' })). Never mutate the object directly — React compares state by reference and won't re-render.
✅ Solution 1 — Generic annotation for nullable state
When the object arrives later (fetch, form fill, selection).
The most common React + TS error: useState(null) infers the type null — nothing else. setUser(user) fails because User isn't assignable to null. Fix: provide the type explicitly. The generic argument expands the state's allowed range.
import { useState } from 'react'; interface User { id: string; name: string; email: string; } function Profile() { // ─── The common mistake ────────────────────── // const [user, setUser] = useState(null); // ↑ user: null — nothing else. // setUser({ id: '1', name: 'A', email: 'a@x' }); // ❌ Argument of type '{ id: string; ... }' is not assignable // to parameter of type 'SetStateAction' // ─── The fix: annotate with a union ────────── const [user, setUser] = useStatenull>(null); // user: User | null // setUser: (u: User | null | ((prev: User | null) => User | null)) => void function load() { setUser({ id: '1', name: 'Alice', email: 'a@x.com' }); // ✅ } function clear() { setUser(null); // ✅ } // ─── Accessing requires narrowing ──────────── // return {user.name}
; // ❌ 'user' is possibly 'null'. if (!user) { return ; } return (); } export default Profile;{user.name}
import { useState } from 'react'; function Profile() { const [user, setUser] = useState(null); function load() { setUser({ id: '1', name: 'Alice', email: 'a@x.com' }); } function clear() { setUser(null); } if (!user) { return React.createElement('button', { onClick: load }, 'Load'); } return React.createElement('div', null, React.createElement('p', null, user.name), React.createElement('button', { onClick: clear }, 'Clear') ); } export default Profile;
Inference from initial value
useState(null) → null. useState(0) → number. useState('') → string. The generic only when you need to widen.
The union annotation
useState — the state can hold a User or null. setUser(user) and setUser(null) both compile.
Narrow before access
user is User | null. Accessing user.name requires narrowing: early return, optional chaining, or a check.
Same pattern for arrays
useState — empty array annotation is required, because useState([]) infers never[].
✅ Solution 2 — Update a single field with spread
The pattern that trips up every React beginner.
Setting object state isn't like setting a primitive. React compares state by reference — mutating the existing object doesn't change the reference, so no re-render. The fix: always return a new object. Use the functional setter with spread: setUser(prev => ({ ...prev, name: 'Bob' })).
import { useState } from 'react'; interface Form { name: string; email: string; age: number; } function Form() { const [form, setForm] = useState
import { useState } from 'react'; function Form() { const [form, setForm] = useState({ name: '', email: '', age: 0 }); function badUpdate() { form.name = 'Alice'; setForm(form); } function updateName(name) { setForm({ ...form, name }); } function updateAge(age) { setForm(prev => ({ ...prev, age })); } const [profile, setProfile] = useState({ name: 'Alice', address: { city: 'Springfield', zip: '12345' } }); function updateCity(city) { setProfile(prev => ({ ...prev, address: { ...prev.address, city } })); } return React.createElement('div', null, React.createElement('input', { value: form.name, onChange: e => updateName(e.target.value) }), React.createElement('input', { type: 'number', value: form.age, onChange: e => updateAge(Number(e.target.value)) }), React.createElement('input', { value: profile.address.city, onChange: e => updateCity(e.target.value) }) ); } export default Form;
Never mutate
form.name = 'X' mutates the object. React compares state by reference — same reference, no re-render. Fix: always spread into a new object.
Functional setter
setForm(prev => ({ ...prev, name })) uses the latest state. Critical when two updates happen in the same tick — otherwise one is lost.
Spread at every level
Nested updates need spreads at every level: { ...prev, address: { ...prev.address, city } }. Each level creates a new object.
Why not Object.assign?
;Object.assign(form, { name }) mutates — same problem as form.name = 'X'. Always return a new object.
The functional setter should be your default, not the exception. setUser(prev => ({ ...prev, name })) is one extra character than setUser({ ...user, name }) but it's always correct. The direct-spread form reads user from the closure — if two updates happen in the same render tick, both see stale state and one is lost. This bug is subtle, hard to reproduce, and shows up in production. Use the functional setter everywhere. You'll never debug a lost update again.
✅ Solution 3 — Typed setter helpers
Reusable, typed field updaters for object state.
If you have many fields, writing one spread-call per field gets repetitive. A typed helper generates the setter for any field: updateField('name', value). This is the pattern React Hook Form and similar libraries use internally — and it composes cleanly with TypeScript.
import { useState } from 'react'; interface Form { name: string; email: string; age: number; } function Form() { const [form, setForm] = useState
import { useState } from 'react'; function Form() { const [form, setForm] = useState({ name: '', email: '', age: 0 }); function updateField(key, value) { setForm(prev => ({ ...prev, [key]: value })); } updateField('name', 'Alice'); updateField('age', 30); return React.createElement('div', null, React.createElement('input', { value: form.name, onChange: e => updateField('name', e.target.value) }), React.createElement('input', { value: form.email, onChange: e => updateField('email', e.target.value) }), React.createElement('input', { type: 'number', value: form.age, onChange: e => updateField('age', Number(e.target.value)) }) ); } export default Form;
K is the key type — must be one of Form's keys. value: Form[K] means the value type depends on the key. This is the generic-from-Lesson-3 pattern applied.
Type-safe dispatch
updateField('name', 30) fails — 'name' requires a string. This catches field/value mismatches at compile time.
Extract to a hook
The same pattern as useObjectState gives every form the type-safe updateField for free. Reuse it across the app.
Replaces per-field updaters
Instead of updateName, updateEmail, updateAge — one updateField handles all three. Less code, fully typed.
⚠️ Common mistakes
The useState-with-object bugs that cost real time.
useState(null)useState(null) infers null. setUser(user) fails. Fix: useState.
form.name = 'X' doesn't trigger a re-render — React compares by reference. Fix: always return a new object from the setter.
setForm({ ...form, name }) captures stale form if called twice in the same tick. Fix: setForm(prev => ({ ...prev, name })).
{ ...prev, address: { city } } drops zip. Fix: { ...prev, address: { ...prev.address, city } }.
Object.assignObject.assign(form, { name }) mutates. Same bug as direct mutation. Fix: { ...form, name } or functional setter.
useState([]) infers never[]. Fix: useState. Same pattern as nullable objects — annotate when inference isn't enough.
Three useState calls that always change together are a smell. Fix: one useState with an object, or useReducer if transitions matter.
user.name when user is User | null fails. Fix: user?.name, early return, or explicit check.
❓ Frequently asked
How do I type useState with an object in TypeScript?
Provide the type as a generic argument to useState: const [user, setUser] = useState
How do I update a single field of an object in useState?
Use the functional setter with spread: setUser(prev => ({ ...prev, name: 'Bob' })). The spread copies all fields, then 'name' overwrites one. This is required for correctness — mutating prev.name directly doesn't trigger a re-render. Always return a new object from the setter.
Why does useState(null) fail to accept setUser(user)?
Because TypeScript infers the state type from the initial value. useState(null) gives type null — not User | null. Calling setUser(user) then fails because User isn't assignable to null. Fix: provide the generic explicitly: useState
Should I use useState or useReducer for object state?
Use useState if the object is simple and updates are independent. Use useReducer if the object has related fields that must transition together, or if the update logic is complex. Rule of thumb: 1–2 fields with simple updates → useState. 3+ related fields, or transitions → useReducer.