Skip to main content

Command Palette

Search for a command to run...

useState Hook in React [part-3]

State Updates in React with Reliable Functional Transformations

Updated
8 min read
useState Hook in React [part-3]

State updates seem straightforward until they're not. Asynchronous operations, rapid user interactions, and event handlers create scenarios where your state updates reference stale values. Functional updates solve this elegantly, ensuring your state transformations always work with current data.

Understanding this small things can help in optimizing the code and creating bug less features. Everything matters at scale.

The Stale State Problem

Consider this seemingly innocent counter:

function BrokenCounter() {
  const [count, setCount] = useState(0);

  const incrementThreeTimes = () => {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={incrementThreeTimes}>Add 3</button>
    </div>
  );
}

You expect the count to increase by 3. Instead, it increases by 1. Why? All three setCount calls capture the same count value from the render when the button was clicked. Each reads count as 0, resulting in three calls of setCount(0 + 1).

Functional Updates: The Solution

Pass a function to your state setter instead of a value. React calls this function with the current state, guaranteeing you always work with up-to-date data:

function ReliableCounter() {
  const [count, setCount] = useState(0);

  const incrementThreeTimes = () => {
    setCount(prevCount => prevCount + 1);
    setCount(prevCount => prevCount + 1);
    setCount(prevCount => prevCount + 1);
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={incrementThreeTimes}>Add 3</button>
    </div>
  );
}

Now each update receives the result of the previous update. The first call gets 0 and returns 1. The second call gets 1 and returns 2. The third call gets 2 and returns 3. Perfect.


Real-World Example: Shopping Cart

import { useState } from 'react';

function ShoppingCart() {
  const [items, setItems] = useState([]);

  const addItem = (product) => {
    setItems(prevItems => {
      const existingItem = prevItems.find(item => item.id === product.id);

      if (existingItem) {
        // Increase quantity if item exists
        return prevItems.map(item =>
          item.id === product.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        );
      }

      // Add new item
      return [...prevItems, { ...product, quantity: 1 }];
    });
  };

  const removeItem = (productId) => {
    setItems(prevItems => prevItems.filter(item => item.id !== productId));
  };

  const updateQuantity = (productId, newQuantity) => {
    if (newQuantity <= 0) {
      removeItem(productId);
      return;
    }

    setItems(prevItems =>
      prevItems.map(item =>
        item.id === productId
          ? { ...item, quantity: newQuantity }
          : item
      )
    );
  };

  const getTotalPrice = () => {
    return items.reduce((total, item) => total + (item.price * item.quantity), 0);
  };

  return (
    <div className="shopping-cart">
      <h2>Shopping Cart</h2>
      {items.length === 0 ? (
        <p>Your cart is empty</p>
      ) : (
        <>
          <ul>
            {items.map(item => (
              <li key={item.id}>
                <span>{item.name}</span>
                <span>${item.price}</span>
                <input
                  type="number"
                  value={item.quantity}
                  onChange={(e) => updateQuantity(item.id, parseInt(e.target.value))}
                  min="0"
                />
                <button onClick={() => removeItem(item.id)}>Remove</button>
              </li>
            ))}
          </ul>
          <div className="cart-total">
            <strong>Total: ${getTotalPrice().toFixed(2)}</strong>
          </div>
        </>
      )}
    </div>
  );
}

This cart uses functional updates throughout. The addItem function reads the previous items array to check if a product exists. Without functional updates, rapidly clicking "Add to Cart" could result in duplicate entries instead of increased quantities.


Asynchronous Operations and Functional Updates

function AsyncCounter() {
  const [count, setCount] = useState(0);
  const [loading, setLoading] = useState(false);

  const incrementAfterDelay = async () => {
    setLoading(true);

    // Simulate API call
    await new Promise(resolve => setTimeout(resolve, 1000));

    // Using functional update ensures we increment the current count
    // even if the user triggered multiple async operations
    setCount(prevCount => prevCount + 1);
    setLoading(false);
  };

  const incrementMultipleTimes = () => {
    // Start 3 async operations simultaneously
    incrementAfterDelay();
    incrementAfterDelay();
    incrementAfterDelay();
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={incrementMultipleTimes} disabled={loading}>
        Increment 3 Times (Async)
      </button>
      {loading && <p>Loading...</p>}
    </div>
  );
}

Without functional updates, all three async operations would capture the initial count value, resulting in the counter only incrementing by 1 regardless of how many operations complete.


Production Pattern: Todo List with Optimistic Updates

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [inputValue, setInputValue] = useState('');

  const addTodo = async () => {
    if (!inputValue.trim()) return;

    const tempId = Date.now();
    const newTodo = {
      id: tempId,
      text: inputValue,
      completed: false,
      syncing: true
    };

    // Optimistic update
    setTodos(prevTodos => [...prevTodos, newTodo]);
    setInputValue('');

    try {
      // Simulate API call
      const response = await fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify({ text: newTodo.text }),
        headers: { 'Content-Type': 'application/json' }
      });

      const savedTodo = await response.json();

      // Update with real ID from server
      setTodos(prevTodos =>
        prevTodos.map(todo =>
          todo.id === tempId
            ? { ...savedTodo, syncing: false }
            : todo
        )
      );
    } catch (error) {
      // Rollback on error
      setTodos(prevTodos => prevTodos.filter(todo => todo.id !== tempId));
      alert('Failed to add todo');
    }
  };

  const toggleTodo = async (id) => {
    // Optimistic update
    setTodos(prevTodos =>
      prevTodos.map(todo =>
        todo.id === id
          ? { ...todo, completed: !todo.completed, syncing: true }
          : todo
      )
    );

    try {
      await fetch(`/api/todos/${id}`, {
        method: 'PATCH',
        body: JSON.stringify({ completed: true }),
        headers: { 'Content-Type': 'application/json' }
      });

      // Remove syncing flag
      setTodos(prevTodos =>
        prevTodos.map(todo =>
          todo.id === id
            ? { ...todo, syncing: false }
            : todo
        )
      );
    } catch (error) {
      // Rollback on error
      setTodos(prevTodos =>
        prevTodos.map(todo =>
          todo.id === id
            ? { ...todo, completed: !todo.completed, syncing: false }
            : todo
        )
      );
    }
  };

  const deleteTodo = async (id) => {
    const todoToDelete = todos.find(todo => todo.id === id);

    // Optimistic delete
    setTodos(prevTodos => prevTodos.filter(todo => todo.id !== id));

    try {
      await fetch(`/api/todos/${id}`, { method: 'DELETE' });
    } catch (error) {
      // Rollback: add the todo back
      setTodos(prevTodos => [...prevTodos, todoToDelete]);
      alert('Failed to delete todo');
    }
  };

  return (
    <div>
      <input
        type="text"
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
        onKeyPress={(e) => e.key === 'Enter' && addTodo()}
        placeholder="Add a todo..."
      />
      <button onClick={addTodo}>Add</button>

      <ul>
        {todos.map(todo => (
          <li key={todo.id} style={{ opacity: todo.syncing ? 0.5 : 1 }}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
              disabled={todo.syncing}
            />
            <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
              {todo.text}
            </span>
            <button onClick={() => deleteTodo(todo.id)} disabled={todo.syncing}>
              Delete
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
}

This code implements a React functional component for a To-Do List using a technique called Optimistic UI Updates. This pattern makes the app feel faster by updating the interface immediately before waiting for a server response.

Key Functionalities

  • Optimistic UI Strategy: For every action (add, toggle, delete), the app updates the local state first so the user sees the change instantly. It then makes the API call in the background and corrects the state only if the server fails.

  • Syncing State: While a network request is in progress, the code sets a syncing flag. This is used to:

    • Visually dim the item (setting opacity: 0.5).

    • Disable interactions (like clicking the checkbox or delete button) to prevent "race conditions" while the server is processing.

  • ID Management:

    • When adding a task, it uses a temporary ID (Date.now()) to render the item immediately.

    • Once the server responds, it replaces the temporary ID with the permanent ID returned by the database.

  • Error Handling & Rollbacks:

    • If an API call fails, the catch blocks contain logic to revert the UI.

    • For example, if deleting fails, the item is added back to the list; if toggling fails, the checkbox switches back to its previous state.

With functional update this code works with integrity and consistency.


When to use Functional Updates

When Functional Updates Are Critical

Multiple Updates in Single Event: Any time you update state multiple times in one function.

Async Operations: setTimeout, setInterval, fetch calls, or any promise-based code.

Event Handlers with Captured State: Callbacks that close over state values.

Computed Updates: When the new state depends on the old state mathematically or logically.

When Direct Updates Work Fine

Setting Absolute Values: When the new state doesn't depend on the old state:

const resetCount = () => setCount(0); // Direct update is fine
const setToValue = (value) => setCount(value); // Also fine

Single Update Per Event: If you only update state once per event and don't have async operations:

const handleClick = () => {
  setCount(count + 1); // Generally safe in isolation
};

Performance Characteristics

Functional updates don't add meaningful overhead. React must process the update regardless. The function call itself is negligible compared to the re-render cost.

React batches multiple state updates automatically in React 18+, whether using direct or functional updates. Batching optimizes renders, not individual state setter calls.


Common Patterns

Toggle Boolean State

const [isOpen, setIsOpen] = useState(false);

// Instead of: setIsOpen(!isOpen)
const toggle = () => setIsOpen(prev => !prev);

Increment/Decrement

const increment = () => setCount(prev => prev + 1);
const decrement = () => setCount(prev => prev - 1);
const incrementBy = (amount) => setCount(prev => prev + amount);

Array Operations

// Add item
setItems(prev => [...prev, newItem]);

// Remove item
setItems(prev => prev.filter(item => item.id !== idToRemove));

// Update item
setItems(prev => prev.map(item =>
  item.id === idToUpdate ? { ...item, ...updates } : item
));

// Sort
setItems(prev => [...prev].sort((a, b) => a.value - b.value));

Object Updates

// Merge properties
setUser(prev => ({ ...prev, name: 'John' }));

// Nested update
setSettings(prev => ({
  ...prev,
  preferences: {
    ...prev.preferences,
    theme: 'dark'
  }
}));

TypeScript Integration

interface Todo {
  id: number;
  text: string;
  completed: boolean;
}

function TypedTodoList() {
  const [todos, setTodos] = useState<Todo[]>([]);

  const addTodo = (text: string) => {
    setTodos((prevTodos: Todo[]) => [
      ...prevTodos,
      { id: Date.now(), text, completed: false }
    ]);
  };

  return <div>{/* UI */}</div>;
}

TypeScript infers the parameter type automatically in most cases, but explicit typing clarifies intent.


Key Notes

Use functional updates when new state depends on previous state. This pattern prevents bugs in async operations, multiple updates, and event handlers with captured state.

The pattern is simple: pass a function instead of a value. React guarantees your function receives the current state, eliminating race conditions and stale closures.

Direct updates work perfectly when setting absolute values or making single updates without dependencies on previous state.