Problem Solved

React + TS · useState with object

Home › Courses › TypeScript › Problems › useState with object
🔷 Problem · 5 min read

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(null). 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.

nullable.tsx
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 (

{user.name}

); } export default Profile;
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(null) — 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' })).

update-field.tsx
import { useState } from 'react';

interface Form {
name: string;
email: string;
age: number;
}

function Form() {
const [form, setForm] = useState
({ name: '', email: '', age: 0 }); // ─── The bug: mutation doesn't re-render ───── function badUpdate() { form.name = 'Alice'; // ❌ mutates — React won't re-render setForm(form); // same reference → bail out } // ─── The fix 1: spread (simple) ────────────── function updateName(name: string) { setForm({ ...form, name }); // ✅ new object } // ─── The fix 2: functional setter (safest) ─── function updateAge(age: number) { setForm(prev => ({ ...prev, age })); // ✅ uses latest state } // ─── Why functional setter is better ───────── // If two updates happen in the same tick: // updateName('X') (closure captures old form) // updateAge(30) (closure captures old form) // Both spread from the same stale form — one update is lost. // // With functional setter: // setForm(prev => ({ ...prev, name: 'X' })) // setForm(prev => ({ ...prev, age: 30 })) // Each receives the LATEST state — no lost update. // ─── Nested object update ──────────────────── interface Profile { name: string; address: { city: string; zip: string }; } const [profile, setProfile] = useState({ name: 'Alice', address: { city: 'Springfield', zip: '12345' } }); function updateCity(city: string) { setProfile(prev => ({ ...prev, address: { ...prev.address, city } // spread every level })); } return (
updateName(e.target.value)} /> "number" value={form.age} onChange={e => updateAge(Number(e.target.value))} /> updateCity(e.target.value)} />
); } export default Form;
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.

typed-updater.tsx
import { useState } from 'react';

interface Form {
name: string;
email: string;
age: number;
}

function Form() {
const [form, setForm] = useState({
name: '',
email: '',
age: 0
});

// ─── Typed field updater ─────────────────────
function updateFieldextends keyof Form>(key: K, value: Form[K]) {
setForm(prev => ({ ...prev, [key]: value }));
}

// ─── Type-safe usage ─────────────────────────
updateField('name', 'Alice'); // ✅ K='name', value=string
updateField('age', 30); // ✅ K='age', value=number
// updateField('name', 30); // ❌ value must be string for 'name'
// updateField('nope', 'x'); // ❌ 'nope' not a key of Form

// ─── One updater handles every field ─────────
return (
updateField('name', e.target.value)} /> updateField('email', e.target.value)} /> "number" value={form.age} onChange={e => updateField('age', Number(e.target.value))} />
); } // ─── Extract the helper for reuse ──────────── // Pull this into a custom hook: // // function useObjectState(init: T) { // const [state, setState] = useState(init); // function update(key: K, value: T[K]) { // setState(prev => ({ ...prev, [key]: value })); // } // return [state, update, setState] as const; // } // // Usage: // const [form, updateField] = useObjectState({ // name: '', email: '', age: 0 // }); export default Form;
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.

1. Forgetting the generic on useState(null)

useState(null) infers null. setUser(user) fails. Fix: useState(null).

2. Mutating state directly

form.name = 'X' doesn't trigger a re-render — React compares by reference. Fix: always return a new object from the setter.

3. Using the direct-spread form in async code

setForm({ ...form, name }) captures stale form if called twice in the same tick. Fix: setForm(prev => ({ ...prev, name })).

4. Forgetting to spread nested levels

{ ...prev, address: { city } } drops zip. Fix: { ...prev, address: { ...prev.address, city } }.

5. Using Object.assign

Object.assign(form, { name }) mutates. Same bug as direct mutation. Fix: { ...form, name } or functional setter.

6. Not annotating empty arrays

useState([]) infers never[]. Fix: useState([]). Same pattern as nullable objects — annotate when inference isn't enough.

7. Using object state for related fields

Three useState calls that always change together are a smell. Fix: one useState with an object, or useReducer if transitions matter.

8. Accessing nullable state without narrowing

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({ id: '1', name: 'Alice' }). If the state starts as null and the object comes later, use a union: useState(null). The generic is what tells TypeScript the full range of values the state can hold.

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(null).

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.

🔗 Related

← All TypeScript problems
Problem solved · React + TS