useState Hook in React [part-3]
State Updates in React with Reliable Functional Transformations
![useState Hook in React [part-3]](/_next/image?url=https%3A%2F%2Fcdn.hashnode.com%2Fres%2Fhashnode%2Fimage%2Fupload%2Fv1768136231791%2Ffb1f14b0-beed-4a9c-bde8-b7b14ef678de.png&w=3840&q=75)
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
syncingflag. 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
catchblocks 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.






