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