State Update Patterns and Immutability
1. Object State Updates with Spread Operator
| Operation | Mutable (❌ Wrong) | Immutable (✅ Correct) |
|---|---|---|
| Update Property | state.name = 'John' |
setState(prev => ({ ...prev, name: 'John' })) |
| Add Property | state.newProp = 'value' |
setState(prev => ({ ...prev, newProp: 'value' })) |
| Delete Property | delete state.prop |
const {prop, ...rest} = state; setState(rest) |
| Multiple Properties | state.a = 1; state.b = 2 |
setState(prev => ({ ...prev, a: 1, b: 2 })) |
| Pattern | Syntax | Use Case |
|---|---|---|
| Shallow Spread | { ...state, key: value } |
Update top-level properties |
| Computed Property Name | { ...state, [key]: value } |
Dynamic property names from variables |
| Merge Objects | { ...state, ...updates } |
Apply multiple changes at once |
| Remove Property | const {removed, ...rest} = state |
Destructure to exclude properties |
| Conditional Update | { ...state, ...(condition && {key: value}) } |
Conditionally add properties |
Example: Object state updates
function UserProfile() {
const [user, setUser] = useState({
name: 'John',
age: 30,
email: 'john@example.com',
address: { city: 'NYC', country: 'USA' }
});
// ❌ Wrong: Direct mutation doesn't trigger re-render
const updateNameBad = () => {
user.name = 'Jane';
setUser(user); // Same reference, React won't re-render!
};
// ✅ Correct: Create new object with spread
const updateName = (name) => {
setUser(prev => ({ ...prev, name }));
};
// ✅ Update multiple properties
const updateProfile = () => {
setUser(prev => ({
...prev,
name: 'Jane',
age: 31,
email: 'jane@example.com'
}));
};
// ✅ Dynamic property name
const updateField = (field, value) => {
setUser(prev => ({ ...prev, [field]: value }));
};
// ✅ Merge with another object
const applyUpdates = (updates) => {
setUser(prev => ({ ...prev, ...updates }));
};
// ✅ Remove property
const removeEmail = () => {
setUser(prev => {
const { email, ...rest } = prev;
return rest;
});
};
// ✅ Conditional property
const updateWithOptionalField = (includeEmail) => {
setUser(prev => ({
...prev,
name: 'Updated',
...(includeEmail && { email: 'new@example.com' })
}));
};
return <div>{user.name}</div>;
}
Critical: Spread operator creates a shallow copy. Nested
objects/arrays are still referenced. For nested updates, see section 5.3.
2. Array State Manipulation (add, remove, update)
| Operation | Mutable (❌ Wrong) | Immutable (✅ Correct) |
|---|---|---|
| Add to End | arr.push(item) |
[...arr, item] |
| Add to Start | arr.unshift(item) |
[item, ...arr] |
| Remove from End | arr.pop() |
arr.slice(0, -1) |
| Remove from Start | arr.shift() |
arr.slice(1) |
| Remove by Index | arr.splice(index, 1) |
arr.filter((_, i) => i !== index) |
| Remove by Value | arr.splice(arr.indexOf(val), 1) |
arr.filter(item => item !== val) |
| Update by Index | arr[index] = newValue |
arr.map((item, i) => i === index ? newValue : item) |
| Sort | arr.sort() |
[...arr].sort() or arr.toSorted() |
| Reverse | arr.reverse() |
[...arr].reverse() or arr.toReversed() |
| Pattern | Code | Use Case |
|---|---|---|
| Add Item | setItems(prev => [...prev, newItem]) |
Append to end of array |
| Prepend Item | setItems(prev => [newItem, ...prev]) |
Add to beginning |
| Insert at Position | setItems(prev => [...prev.slice(0, i), item, ...prev.slice(i)]) |
Insert at specific index |
| Remove by ID | setItems(prev => prev.filter(item => item.id !== id)) |
Delete item by unique identifier |
| Update by ID | setItems(prev => prev.map(item => item.id === id ? {...item, ...updates} : item)) |
Modify specific item immutably |
| Toggle Property | setItems(prev => prev.map(item => item.id === id ? {...item, done: !item.done} : item))
|
Toggle boolean property |
| Replace Array | setItems(newArray) |
Complete replacement |
Example: Array state operations
function TodoList() {
const [todos, setTodos] = useState([
{ id: 1, text: 'Task 1', completed: false },
{ id: 2, text: 'Task 2', completed: true }
]);
// ✅ Add new todo
const addTodo = (text) => {
const newTodo = { id: Date.now(), text, completed: false };
setTodos(prev => [...prev, newTodo]);
};
// ✅ Remove todo by id
const removeTodo = (id) => {
setTodos(prev => prev.filter(todo => todo.id !== id));
};
// ✅ Update todo text
const updateTodoText = (id, newText) => {
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, text: newText } : todo
));
};
// ✅ Toggle completed status
const toggleTodo = (id) => {
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
// ✅ Insert at beginning
const prependTodo = (text) => {
const newTodo = { id: Date.now(), text, completed: false };
setTodos(prev => [newTodo, ...prev]);
};
// ✅ Insert at specific position
const insertTodoAt = (index, text) => {
const newTodo = { id: Date.now(), text, completed: false };
setTodos(prev => [
...prev.slice(0, index),
newTodo,
...prev.slice(index)
]);
};
// ✅ Sort immutably
const sortByText = () => {
setTodos(prev => [...prev].sort((a, b) => a.text.localeCompare(b.text)));
};
// ✅ Clear completed
const clearCompleted = () => {
setTodos(prev => prev.filter(todo => !todo.completed));
};
// ✅ Mark all as completed
const completeAll = () => {
setTodos(prev => prev.map(todo => ({ ...todo, completed: true })));
};
return <ul>{todos.map(todo => <li key={todo.id}>{todo.text}</li>)}</ul>;
}
Modern Array Methods: ES2023 introduced immutable array methods:
toSorted(),
toReversed(), toSpliced(), and with(). These create copies without
mutating the original. NEW
3. Nested Object State Updates with Immer Integration
| Nesting Level | Manual Immutable Update | Complexity |
|---|---|---|
| 1 Level | { ...state, prop: value } |
✅ Simple, manageable |
| 2 Levels | { ...state, nested: { ...state.nested, prop: value } } |
⚠️ Verbose but doable |
| 3+ Levels | { ...state, a: { ...state.a, b: { ...state.a.b, c: value } } } |
❌ Error-prone, use Immer |
| Approach | Code Style | Pros/Cons |
|---|---|---|
| Manual Spread | Nested spread operators | ✅ No dependencies ❌ Verbose, error-prone for deep nesting |
| Immer RECOMMENDED | Write mutable-style code, produces immutable updates | ✅ Clean, intuitive ✅ Safe for deep nesting ❌ Extra dependency |
| Immutability-helper | MongoDB-style update syntax | ✅ Declarative ❌ Unfamiliar syntax |
Example: Manual nested updates (verbose)
function UserProfile() {
const [user, setUser] = useState({
name: 'John',
profile: {
bio: 'Developer',
social: {
twitter: '@john',
github: 'john'
}
},
settings: {
theme: 'light',
notifications: {
email: true,
push: false
}
}
});
// Update deeply nested property (manual spread - verbose!)
const updateTwitter = (newHandle) => {
setUser(prev => ({
...prev,
profile: {
...prev.profile,
social: {
...prev.profile.social,
twitter: newHandle
}
}
}));
};
// Update multiple nested properties
const updateNotifications = (email, push) => {
setUser(prev => ({
...prev,
settings: {
...prev.settings,
notifications: {
...prev.settings.notifications,
email,
push
}
}
}));
};
return <div>{user.name}</div>;
}
Example: Immer for cleaner nested updates
import { useState } from 'react';
import { useImmer } from 'use-immer';
// Option 1: use-immer hook (recommended)
function UserProfileWithImmer() {
const [user, updateUser] = useImmer({
name: 'John',
profile: {
bio: 'Developer',
social: { twitter: '@john', github: 'john' }
},
settings: {
theme: 'light',
notifications: { email: true, push: false }
}
});
// ✅ Write "mutating" code, Immer makes it immutable
const updateTwitter = (newHandle) => {
updateUser(draft => {
draft.profile.social.twitter = newHandle;
});
};
const updateNotifications = (email, push) => {
updateUser(draft => {
draft.settings.notifications.email = email;
draft.settings.notifications.push = push;
});
};
// Complex update with array manipulation
const addHobby = (hobby) => {
updateUser(draft => {
if (!draft.profile.hobbies) {
draft.profile.hobbies = [];
}
draft.profile.hobbies.push(hobby);
});
};
return <div>{user.name}</div>;
}
// Option 2: Manual Immer with produce
import produce from 'immer';
function UserProfileManualImmer() {
const [user, setUser] = useState({...});
const updateTwitter = (newHandle) => {
setUser(prev => produce(prev, draft => {
draft.profile.social.twitter = newHandle;
}));
};
return <div>{user.name}</div>;
}
Example: Array of objects with nested updates
import { useImmer } from 'use-immer';
function TeamManager() {
const [teams, updateTeams] = useImmer([
{
id: 1,
name: 'Engineering',
members: [
{ id: 101, name: 'Alice', role: 'Lead', skills: ['React', 'Node'] },
{ id: 102, name: 'Bob', role: 'Dev', skills: ['Python'] }
]
}
]);
// Update nested member's skill
const addSkill = (teamId, memberId, skill) => {
updateTeams(draft => {
const team = draft.find(t => t.id === teamId);
if (team) {
const member = team.members.find(m => m.id === memberId);
if (member) {
member.skills.push(skill);
}
}
});
};
// Update member role
const updateMemberRole = (teamId, memberId, newRole) => {
updateTeams(draft => {
const team = draft.find(t => t.id === teamId);
if (team) {
const member = team.members.find(m => m.id === memberId);
if (member) {
member.role = newRole;
}
}
});
};
// Without Immer (compare complexity):
const addSkillManual = (teamId, memberId, skill) => {
setTeams(prev => prev.map(team =>
team.id === teamId
? {
...team,
members: team.members.map(member =>
member.id === memberId
? { ...member, skills: [...member.skills, skill] }
: member
)
}
: team
));
};
return <div>Teams</div>;
}
When to Use Immer: If you have 3+ levels of nesting, multiple nested arrays, or complex update
logic, Immer significantly improves code readability and reduces bugs. Install:
npm install immer use-immer
4. State Normalization for Complex Data Structures
| Structure Type | Problem | Solution |
|---|---|---|
| Nested Arrays | Slow lookups, duplicate data, update complexity | Normalize to { byId: {}, allIds: [] } |
| Deeply Nested Objects | Hard to update, performance issues | Flatten to separate entities with references |
| Relational Data | Duplication, inconsistency | Normalize like a database (entities + IDs) |
| Pattern | Structure | Benefit |
|---|---|---|
| byId + allIds | { byId: { 1: {...}, 2: {...} }, allIds: [1, 2] } |
O(1) lookups, easy updates, preserve order |
| Entity Slices | { users: {byId, allIds}, posts: {byId, allIds} } |
Separate concerns, Redux-style normalization |
| References | Store IDs instead of full objects | Single source of truth, no duplication |
Example: Denormalized vs Normalized state
// ❌ Denormalized: Nested, hard to update
const denormalizedState = {
posts: [
{
id: 1,
title: 'Post 1',
author: { id: 10, name: 'Alice', email: 'alice@example.com' },
comments: [
{ id: 101, text: 'Nice!', author: { id: 10, name: 'Alice', email: 'alice@example.com' } },
{ id: 102, text: 'Cool', author: { id: 11, name: 'Bob', email: 'bob@example.com' } }
]
},
{
id: 2,
title: 'Post 2',
author: { id: 11, name: 'Bob', email: 'bob@example.com' },
comments: []
}
]
};
// Problem: To update Alice's email, must find and update in multiple places!
// ✅ Normalized: Flat, single source of truth
const normalizedState = {
users: {
byId: {
10: { id: 10, name: 'Alice', email: 'alice@example.com' },
11: { id: 11, name: 'Bob', email: 'bob@example.com' }
},
allIds: [10, 11]
},
posts: {
byId: {
1: { id: 1, title: 'Post 1', authorId: 10, commentIds: [101, 102] },
2: { id: 2, title: 'Post 2', authorId: 11, commentIds: [] }
},
allIds: [1, 2]
},
comments: {
byId: {
101: { id: 101, text: 'Nice!', authorId: 10, postId: 1 },
102: { id: 102, text: 'Cool', authorId: 11, postId: 1 }
},
allIds: [101, 102]
}
};
// Easy update: Change Alice's email in ONE place
const updateUserEmail = (userId, newEmail) => {
setState(prev => ({
...prev,
users: {
...prev.users,
byId: {
...prev.users.byId,
[userId]: { ...prev.users.byId[userId], email: newEmail }
}
}
}));
};
Example: Normalized state operations
function BlogApp() {
const [state, setState] = useState({
posts: { byId: {}, allIds: [] },
comments: { byId: {}, allIds: [] },
users: { byId: {}, allIds: [] }
});
// Add post
const addPost = (post) => {
setState(prev => ({
...prev,
posts: {
byId: { ...prev.posts.byId, [post.id]: post },
allIds: [...prev.posts.allIds, post.id]
}
}));
};
// Remove post
const removePost = (postId) => {
setState(prev => {
const { [postId]: removed, ...remainingPosts } = prev.posts.byId;
return {
...prev,
posts: {
byId: remainingPosts,
allIds: prev.posts.allIds.filter(id => id !== postId)
}
};
});
};
// Update post
const updatePost = (postId, updates) => {
setState(prev => ({
...prev,
posts: {
...prev.posts,
byId: {
...prev.posts.byId,
[postId]: { ...prev.posts.byId[postId], ...updates }
}
}
}));
};
// Get post with author (selector pattern)
const getPostWithAuthor = (postId) => {
const post = state.posts.byId[postId];
const author = state.users.byId[post.authorId];
return { ...post, author };
};
return <div>Blog</div>;
}
Normalization Libraries: For complex normalization, consider
normalizr library or
Redux Toolkit's createEntityAdapter which provides utilities for normalized state management.
5. Avoiding State Mutation and Side Effects
| Mutation Type | Example (❌ Wrong) | Why It Fails |
|---|---|---|
| Direct Assignment | state.value = 123; setState(state) |
Same reference, React won't detect change |
| Array Methods | arr.push(item); setArr(arr) |
Mutates original array, same reference |
| Object Methods | Object.assign(obj, updates); setObj(obj) |
Mutates original object |
| Nested Assignment | state.nested.value = 1; setState({...state}) |
Shallow copy doesn't protect nested objects |
| Side Effect Type | Problem | Solution |
|---|---|---|
| Render-time Mutations | Changing state during render causes inconsistencies | Only update state in event handlers or useEffect |
| External Mutations | Modifying objects outside component | Clone before modifying, keep state local |
| Closure Stale State | Async operations use old state values | Use functional updates: setState(prev => ...) |
Example: Common mutation mistakes and fixes
function MutationExamples() {
const [items, setItems] = useState([1, 2, 3]);
const [user, setUser] = useState({ name: 'John', age: 30 });
// ❌ WRONG: Mutations that don't trigger re-render
const bad1 = () => {
items.push(4);
setItems(items); // Same reference!
};
const bad2 = () => {
user.name = 'Jane';
setUser(user); // Same reference!
};
const bad3 = () => {
items.sort();
setItems(items); // Mutated in place!
};
const bad4 = () => {
const copy = user;
copy.name = 'Jane';
setUser(copy); // Shallow copy - same reference!
};
// ✅ CORRECT: Immutable updates
const good1 = () => {
setItems(prev => [...prev, 4]); // New array
};
const good2 = () => {
setUser(prev => ({ ...prev, name: 'Jane' })); // New object
};
const good3 = () => {
setItems(prev => [...prev].sort()); // Copy first, then sort
};
const good4 = () => {
setUser(prev => ({ ...prev })); // Proper copy
};
return <div>Items: {items.length}</div>;
}
Example: Avoiding side effects in render
// ❌ WRONG: Mutation during render
function BadComponent({ data }) {
const [processed, setProcessed] = useState([]);
// BAD: Setting state during render!
if (data.length > 0 && processed.length === 0) {
setProcessed(data.map(transform)); // Causes infinite loop or warnings
}
return <div>{processed.length}</div>;
}
// ✅ CORRECT: Side effects in useEffect
function GoodComponent({ data }) {
const [processed, setProcessed] = useState([]);
useEffect(() => {
setProcessed(data.map(transform));
}, [data]);
return <div>{processed.length}</div>;
}
// ✅ BETTER: Compute during render without state
function BestComponent({ data }) {
const processed = useMemo(() => data.map(transform), [data]);
return <div>{processed.length}</div>;
}
React's Assumption: React assumes state updates are immutable. Mutating state directly breaks
React's diffing algorithm, can cause stale UI, missed re-renders, and hard-to-debug issues. Always create new
references.
6. State Update Optimization and Performance
| Optimization | Technique | Benefit |
|---|---|---|
| Bail Out | Return same state object if no changes | React skips re-render if same reference |
| Structural Sharing | Reuse unchanged parts of state object | Reduces memory, faster comparisons |
| Batch Updates | Multiple setState calls batched into one render | Fewer re-renders (automatic in React 18) |
| Lazy Updates | Defer non-critical state updates | Prioritize important UI updates |
| Split State | Separate frequently/infrequently changing state | Localize re-renders to affected components |
| Anti-pattern | Problem | Solution |
|---|---|---|
| Unnecessary Copying | Always creating new object even when unchanged | Check if update is needed, return same state |
| Large State Objects | Updating small part causes full re-render | Split into smaller, focused state pieces |
| Derived State | Storing computed values in state | Compute during render or use useMemo |
| Deep Copying | JSON.parse(JSON.stringify()) for deep copy | Slow, use Immer or selective copying |
Example: Bail out of updates
function OptimizedComponent() {
const [count, setCount] = useState(0);
const [user, setUser] = useState({ name: 'John', age: 30 });
// ❌ Always creates new object, even if unchanged
const updateUserBad = (newName) => {
setUser(prev => ({ ...prev, name: newName }));
// Even if newName === prev.name, creates new object!
};
// ✅ Bail out if unchanged
const updateUserGood = (newName) => {
setUser(prev => {
if (prev.name === newName) {
return prev; // Same reference, React skips re-render
}
return { ...prev, name: newName };
});
};
// ✅ Bail out for primitive values (automatic)
const incrementIfEven = () => {
setCount(prev => {
if (prev % 2 === 0) {
return prev + 1;
}
return prev; // Same value, React bails out
});
};
return <div>{user.name}: {count}</div>;
}
Example: Structural sharing
function TodoApp() {
const [state, setState] = useState({
todos: [],
filter: 'all',
sortBy: 'date',
ui: { sidebarOpen: true, theme: 'light' }
});
// ❌ Creates entirely new object
const toggleSidebarBad = () => {
setState(prev => ({
todos: [...prev.todos], // Unnecessary copy
filter: prev.filter,
sortBy: prev.sortBy,
ui: { ...prev.ui, sidebarOpen: !prev.ui.sidebarOpen }
}));
};
// ✅ Structural sharing - reuse unchanged parts
const toggleSidebarGood = () => {
setState(prev => ({
...prev, // Reuse todos, filter, sortBy references
ui: { ...prev.ui, sidebarOpen: !prev.ui.sidebarOpen }
}));
};
// ✅ Update only affected slice
const updateFilter = (newFilter) => {
setState(prev => ({ ...prev, filter: newFilter }));
// todos, sortBy, ui maintain same references
};
return <div>Todos</div>;
}
Example: State splitting for performance
// ❌ Monolithic state - sidebar toggle re-renders entire app
function BadApp() {
const [state, setState] = useState({
todos: [], // Rarely changes
user: {}, // Rarely changes
sidebarOpen: true, // Changes frequently
currentView: 'list' // Changes frequently
});
// Every state change re-renders everything
return <div>...</div>;
}
// ✅ Split state - localized re-renders
function GoodApp() {
// Separate state by update frequency
const [todos, setTodos] = useState([]);
const [user, setUser] = useState({});
const [sidebarOpen, setSidebarOpen] = useState(true);
const [currentView, setCurrentView] = useState('list');
// Or split into contexts for different concerns
return (
<UserContext.Provider value={user}>
<TodoContext.Provider value={todos}>
<UIContext.Provider value={{ sidebarOpen, currentView }}>
<Content />
</UIContext.Provider>
</TodoContext.Provider>
</UserContext.Provider>
);
}
// Components using only UI state won't re-render when todos change
function Sidebar() {
const { sidebarOpen } = useContext(UIContext);
return <aside>{sidebarOpen && 'Sidebar'}</aside>;
}
Immutability Best Practices Summary
- Always create new references for objects and arrays when updating
- Use spread operators for shallow updates, Immer for deep nesting
- Normalize state for relational data (byId + allIds pattern)
- Never mutate state directly - use functional updates
- Bail out of updates by returning same reference when unchanged
- Apply structural sharing - only copy changed parts
- Split state by update frequency to minimize re-renders
- Avoid deep cloning (JSON stringify/parse) - use selective copying