To implement a stack data structure in React, keep the items in state as an array and treat it as immutable: add an item by creating a new array with the item at the end, remove the top by copying the array without its last item, and read the top as the last element. In code that’s [...stack, item], stack.slice(0, -1) and stack.at(-1). Never call push() or pop() on the array that’s already in state, because React only re-renders when state is replaced.
This guide builds a stack visualizer and a reusable useStack hook with React 19, then uses two stacks for a practical undo/redo feature. I tested every example in a headless browser. If stacks are new to you, read the introduction to the stack data structure first.
Looking for a “Stack” layout component instead? UI libraries such as MUI and Chakra UI have a Stack component that arranges children vertically or horizontally with spacing. That’s a layout helper, unrelated to the stack data structure covered here.
What Is a Stack?
A stack is a Last-In-First-Out (LIFO) collection: the last item added is the first one removed, like a pile of plates. It has a small set of operations, all of which take constant time with a JavaScript array:
| Operation | What it does | JavaScript array |
|---|---|---|
| push | Add an item to the top | stack.push(x) (mutating) or [...stack, x] |
| pop | Remove the top item | stack.pop() (mutating) or stack.slice(0, -1) |
| peek | Read the top item without removing it | stack.at(-1) or stack[stack.length - 1] |
| isEmpty / size | Check how many items there are | stack.length |
You meet stacks everywhere in software: the browser’s back button, undo in editors, the JavaScript call stack, and checking that brackets in code are balanced.
Why You Can’t Just Mutate the Array in React
The 2023 version of this post stored a Stack class instance in state, called stack.push(value) on it and then setState({ stack }) with the same object. That only appeared to work because class components re-render on every setState call. With hooks, setting state to the same array is ignored, so the screen doesn’t update:
const [stack, setStack] = useState([]);
// Wrong: mutates the existing array, then sets the same reference
stack.push('A');
setStack(stack); // React sees no change and skips the render
// Right: create a new array
setStack((s) => [...s, 'A']);The old version also used string refs (ref="input"), which React 19 removed. Keeping the operations as small pure functions avoids both problems:
// Immutable stack helpers: each returns a new array, which is what React state needs
export const push = (stack, item) => [...stack, item];
export const pop = (stack) => stack.slice(0, -1);
export const peek = (stack) => stack[stack.length - 1];
export const isEmpty = (stack) => stack.length === 0;// checked in Node.js
const a = [];
const b = push(a, 'A');
const c = push(b, 'B');
// a: [] b: ['A'] c: ['A', 'B']
// peek(c): 'B' pop(c): ['A'] c is unchanged: ['A', 'B']A Reusable useStack Hook
import { useState } from 'react';
import { push, pop, peek } from './stack';
export function useStack(initial = []) {
const [items, setItems] = useState(initial);
return {
items,
top: peek(items), // read the top before popping if you need it
size: items.length,
push: (item) => setItems((s) => push(s, item)),
pop: () => setItems((s) => pop(s)),
clear: () => setItems([]),
};
}Note that pop doesn’t return the removed item. React runs state updater functions later, during the next render, so a value captured inside one isn’t available when pop() returns. Read top before popping instead, as the visualizer does.
Building the Stack Visualizer
import { useState } from 'react';
import { useStack } from './useStack';
export default function StackVisualizer({ limit = 5 }) {
const stack = useStack();
const [value, setValue] = useState('');
const [message, setMessage] = useState('');
function handlePush(e) {
e.preventDefault();
if (!value.trim()) return;
if (stack.size >= limit) return setMessage(`Stack overflow: limit is ${limit}`);
stack.push(value.trim());
setValue('');
setMessage('');
}
function handlePop() {
if (stack.size === 0) return setMessage('Stack underflow: nothing to pop');
setMessage(`Popped "${stack.top}"`);
stack.pop();
}
return (
<section>
<form onSubmit={handlePush}>
<input value={value} onChange={(e) => setValue(e.target.value)} placeholder="Value" />
<button>Push</button>
<button type="button" onClick={handlePop}>Pop</button>
</form>
<p id="stack-info">Top: {stack.top ?? 'none'} | Size: {stack.size}</p>
<p role="status">{message}</p>
{/* reversed so the top of the stack is drawn at the top */}
<ol id="stack-items" className="stack">
{[...stack.items].reverse().map((item, i) => (
<li key={stack.size - i}>{item}</li>
))}
</ol>
</section>
);
}The list is drawn in reverse so the top of the stack appears at the top of the screen, and the stack has a size limit to demonstrate overflow. In my test:
| Action | Top | Size | Items (top first) | Message |
|---|---|---|---|---|
| Push A, B, C | C | 3 | C, B, A | |
| Pop | B | 2 | B, A | Popped “C” |
| Push D, E, F, G (limit 5) | F | 5 | F, E, D, B, A | Stack overflow: limit is 5 |
| Pop six times | none | 0 | Stack underflow: nothing to pop |

The keys use each item’s position counted from the bottom, which stays the same for an item while it’s on the stack, because a stack only changes at the top. Add some CSS (a column of boxes with a transition on new items) to make it look like the demo above.
A Practical Example: Undo and Redo with Two Stacks
Undo history is a stack of past states; redo is a second stack of states you undid. Typing pushes onto the past stack and clears the future stack:
import { useState } from 'react';
// Two stacks: past states for undo, future states for redo
export default function UndoEditor() {
const [history, setHistory] = useState({ past: [], present: '', future: [] });
function type(text) {
setHistory(({ past, present }) => ({ past: [...past, present], present: text, future: [] }));
}
function undo() {
setHistory(({ past, present, future }) =>
past.length === 0 ? { past, present, future }
: { past: past.slice(0, -1), present: past[past.length - 1], future: [present, ...future] });
}
function redo() {
setHistory(({ past, present, future }) =>
future.length === 0 ? { past, present, future }
: { past: [...past, present], present: future[0], future: future.slice(1) });
}
return (
<div>
<input id="editor" value={history.present} onChange={(e) => type(e.target.value)} />
<button onClick={undo} disabled={history.past.length === 0}>Undo</button>
<button onClick={redo} disabled={history.future.length === 0}>Redo</button>
</div>
);
}In my test, typing “cat” (one history entry per keystroke) and pressing Undo twice gave “ca” and then “c”; Redo restored “ca”; typing “r” produced “car” and disabled Redo, because a new edit clears the redo stack. Real editors group keystrokes into larger steps, for example by pausing for a moment before pushing a new entry.
Another Classic: Checking Balanced Brackets
function isBalanced(text) {
const pairs = { ')': '(', ']': '[', '}': '{' };
const stack = [];
for (const ch of text) {
if ('([{'.includes(ch)) stack.push(ch);
else if (ch in pairs && stack.pop() !== pairs[ch]) return false;
}
return stack.length === 0;
}isBalanced('{ a: [1, 2] }') // true
isBalanced('fn(a[0)]') // false: ) closes [
isBalanced('((') // false: never closed
isBalanced('if (x) { y[0] = (1); }') // trueEach opening bracket is pushed; each closing bracket must match the item on top. Linters, parsers and code editors use the same idea.
Common Mistakes
- Calling
push()orpop()on the array in state, then setting the same array. - Using the value returned from inside a state updater function, which isn’t available yet.
- Forgetting to handle an empty stack:
peek()on an empty array returnsundefined. - Storing a class instance with internal state in React state; plain arrays and pure functions are simpler to update and debug.
The same approach works for a first-in-first-out structure; see how to implement a queue in React.
Conclusion
A stack in React is just an array in state with immutable push and pop: [...stack, item] to add, stack.slice(0, -1) to remove and the last element to peek. Wrap those in a useStack hook, never mutate the array React already has, and you can build visualizers, navigation history or undo/redo on top of it.
FAQs
How do I implement a stack in React?
Keep an array in useState. Push with setStack(s => […s, item]), pop with setStack(s => s.slice(0, -1)), and read the top with stack[stack.length – 1]. Never mutate the array that is already in state.
Why does my stack not update in React when I use push()?
push() mutates the existing array, and setting state to the same array reference tells React nothing changed, so it skips the render. Create a new array instead.
What is a stack used for in a React app?
Undo and redo history, navigation history, nested modals or dialogs, and step-by-step wizards where the user can go back. Parsers also use stacks to match brackets and tags.
Is the MUI Stack component a stack data structure?
No. MUI’s Stack is a layout component that places children in a row or column with spacing. The stack data structure is a last-in-first-out collection.
What is the time complexity of stack operations with a JavaScript array?
push, pop and peek on the end of an array are constant time. The immutable versions used in React state copy the array, which is fine for the small stacks UIs usually need.








