How to Add Items to a useState Array in React
Advertisement
🧩 Scenario
Architecture Walkthrough
Why Mutation Does Not Work
React's reconciler determines whether state changed by comparing the new state value to the previous one using Object.is (reference equality). If you call arr.push(item) on the state array and then pass that same array to the setter, the reference is unchanged. React sees the same reference it already has and skips the re-render. No update occurs even though the array contents changed.
This is intentional. Immutability gives React a fast, simple equality check. The tradeoff is that you must always produce a new reference for state that contains objects or arrays.
Producing a New Array
The idiomatic way to add an item is to use the spread operator: [...previousArray, newItem]. This creates a new array with all the previous items plus the new one. The new reference triggers a re-render. Array.prototype.concat works identically and is sometimes preferred for its readability: previousArray.concat(newItem).
For operations like inserting at a specific index, you split the array with slice and spread the two halves around the new item. For removing an item, use filter to produce a new array without the item. For updating an item, use map to produce a new array where one element is replaced.
Functional Updater Form
When the new state depends on the previous state and the update may occur inside an async context (a setTimeout, setInterval, or a Promise callback), always use the functional updater form: setItems(prev => [...prev, newItem]). The prev argument is always the latest queued state, not the value captured at the time the effect or callback was created. Using the direct value (setItems([...items, newItem])) in a stale closure reads the captured snapshot of items rather than the current state.
Key Code Explained
interface Item {
id: string;
name: string;
}
function ItemManager() {
const [items, setItems] = useState<Item[]>([]);
// Add to end — most common case
const addToEnd = (item: Item) => {
setItems((prev) => [...prev, item]);
};
// Add to beginning
const addToStart = (item: Item) => {
setItems((prev) => [item, ...prev]);
};
// Insert at specific index (without splice)
const insertAt = (item: Item, index: number) => {
setItems((prev) => [
...prev.slice(0, index),
item,
...prev.slice(index),
]);
};
// Remove by id
const remove = (id: string) => {
setItems((prev) => prev.filter((item) => item.id !== id));
};
// Update a specific item
const update = (id: string, updates: Partial<Item>) => {
setItems((prev) =>
prev.map((item) => (item.id === id ? { ...item, ...updates } : item)),
);
};
return (
<ul>
{items.map((item) => (
// Use stable item.id as key, never array index
<li key={item.id}>
{item.name}
<button onClick={() => remove(item.id)}>Remove</button>
</li>
))}
<button onClick={() => addToEnd({ id: crypto.randomUUID(), name: 'New Item' })}>
Add
</button>
</ul>
);
}
// Common bug: mutation + same reference
const buggyAdd = (item: Item) => {
items.push(item); // mutates original array
setItems(items); // same reference — React skips re-render
};
// Why functional updater matters in async contexts
function LiveFeed() {
const [messages, setMessages] = useState<string[]>([]);
useEffect(() => {
const interval = setInterval(() => {
const newMessage = `Message at ${Date.now()}`;
// Bug: 'messages' is captured at mount time and never updates
// setMessages([...messages, newMessage]);
// Correct: always receives the latest state
setMessages((prev) => [...prev, newMessage]);
}, 1000);
return () => clearInterval(interval);
}, []); // empty deps — 'messages' would be stale if used directly
}
The crypto.randomUUID() call for generating item IDs is preferred over Math.random() as a key because it produces a cryptographically unique string rather than a floating-point number. Both are stable within a session, but randomUUID has a guaranteed collision-free format and is available in all modern browsers and Node.js 15+.
Tradeoffs
| Method | New reference | Readable | Handles index insert | Use when |
|---|---|---|---|---|
spread [...a, x] | Yes | High | With slice | Most array additions |
concat | Yes | High | No | Adding one or more items at end |
slice + spread | Yes | Medium | Yes | Inserting at specific index |
filter | Yes | High | N/A | Removing items |
map | Yes | High | N/A | Updating items by condition |
What Interviewers Actually Check
- Whether you know that
pushon a state array does not trigger a re-render - Whether you can produce a new array reference using spread or concat
- Whether you know the functional updater form and when it is required
- Whether you use stable IDs as keys rather than array index
- Whether you know how to update or remove items from a state array without mutation
Follow-Up Questions
- How does Immer's
producefunction let you write mutating code that is actually immutable under the hood? - How does Redux Toolkit use Immer for state updates and how does that compare to the spread pattern?
- If a state array contains objects, what mistake leads to all items re-rendering even when only one changed?
- How does
useReducerchange the pattern for complex array state with many operation types? - How would you implement undo/redo for an array-based state by storing a history of states?
Common Candidate Mistakes
- Calling
items.push(newItem)and passing the same reference to the setter, then wondering why the component does not re-render - Using the direct state value in an async callback that was created before the latest state update
- Using array index as key when adding to the beginning or middle of the array causes all subsequent items to unmount and remount
- Mutating a nested object inside an array item (
items[0].name = 'new') without creating a new object reference for that item - Using
splicewhich mutates the array in place and returns the removed elements, not the remaining array
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you add an item to the end and beginning of a state array using spread?
- Can you explain why
pushon a state array does not trigger a re-render? - Can you use the functional updater form to safely add items in async callbacks?
- Can you remove and update items in a state array using filter and map?
- Can you explain why array index is a problematic key when adding items to the beginning or middle?
Summary
React detects state changes by comparing the new value to the previous value by reference. Arrays mutated in place (using push, splice, or direct index assignment) keep the same reference, so React sees no change and skips the re-render. The fix is always to produce a new array: use the spread operator, concat, filter, or map depending on the operation.
The functional updater form (setState(prev => ...)) is essential when the update happens inside an async context such as a setInterval, setTimeout, or Promise callback. These callbacks close over the state value at the time they are created. If new items are added while the component renders again, the closed-over value is stale. The functional updater always receives the latest queued state as prev, making it safe regardless of when the callback runs.
The same immutability requirement applies to nested objects inside array items: updating a property on an item requires producing a new object for that item ({ ...item, name: 'new' }) and a new array containing it. Mutating the item in place, even if you create a new array reference, leaves React unable to detect what changed inside the item, which can prevent child components that receive the item as a prop from re-rendering correctly.
Can I push directly into a useState array?
No. Pushing mutates the existing array reference. React uses reference equality to detect state changes, so mutating the array in place and calling the setter with the same reference does not trigger a re-render.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement