Build a counter with undo and redo. The trick isn't the counting — it's remembering history: keep every value the counter has been in an array, plus a pointer (index) to the current one. Undo moves the pointer back, redo moves it forward, and a new change after an undo discards the redo "future."
// A self-contained component. No props.
function App(): JSX.Element;
A count, +1 / -1 buttons, and Undo / Redo.
+1, +1, +1 → history [0,1,2,3], index 3, count 3
undo, undo → index 1, count 1 (history unchanged)
redo → index 2, count 2
after undo to index 1, press +1 → the redo future is dropped:
history becomes [0,1,2], index 2, count 2
history is every value seen; index points at the current; count = history[index].index, then appends — no orphaned redo branch.index within bounds.index 0; redo off at the last index.Undo/redo is a history of values plus a pointer. Keep an array of every value the counter has held and an index into it; the displayed count is history[index]. Undo and redo just move the pointer; a fresh change truncates the redo future and appends.
A plain counter forgets its past, so it can't undo. To go back, you must remember where you've been — that's an array of past values. But undo isn't "delete the last value": you might undo, then redo, so the values must stay put. The clean model separates what happened (the history array, which only grows on real changes) from where you are now (an index). Undo/redo slide the index; only an actual +1/−1 edits the array — and it throws away anything ahead of the index, because once you branch off, the old future is gone.
Two state values: history (e.g. [0, 1, 2]) and index (a position in it). count = history[index]. A change computes the next value, takes history.slice(0, index + 1) (everything up to and including the current point), appends the new value, and advances index to the new end. Undo is index - 1, redo is index + 1, both clamped. Undo is enabled when index > 0; redo when index < history.length - 1.
A first attempt keeps two stacks (undo and redo) and pushes/pops between them:
function undo() {
redoStack.push(count);
setCount(undoStack.pop());
}
This works but it's more moving parts: two arrays to keep consistent, plus the current value, and easy to mishandle on a new change (you must remember to clear the redo stack). A single history array with one index captures the same thing with less to synchronize — and "clear the redo future" becomes a single slice.
import { useState } from 'react';
import './styles.css';
export default function App() {
const [history, setHistory] = useState<number[]>([0]);
const [index, setIndex] = useState(0);
const count = history[index];
const canUndo = index > 0;
const canRedo = index < history.length - 1;
function record(next: number) {
const kept = history.slice(0, index + 1); // drop the redo future
setHistory([...kept, next]);
setIndex(kept.length); // new value sits at the end
}
return (
<main className="container">
<h1>Undoable Counter</h1>
<p className="count">{count}</p>
<div className="row">
<button className="primary" onClick={() => record(count - 1)}>
-1
</button>
<button className="primary" onClick={() => record(count + 1)}>
+1
</button>
</div>
<div className="row">
<button onClick={() => setIndex(index - 1)} disabled={!canUndo}>
Undo
</button>
<button onClick={() => setIndex(index + 1)} disabled={!canRedo}>
Redo
</button>
</div>
<p className="hint">History lets you step back and forward through changes.</p>
</main>
);
}
count is derived (history[index]), never stored separately. record(next) is the only thing that grows the array: slice(0, index + 1) keeps the past up to the current point and drops any redo future, then appends next and points index at it (kept.length). Undo and redo are one-liners that nudge index, and canUndo/canRedo derive the button-disabled state from where the pointer sits — no separate "can I undo?" flags.
history = [0], index = 0.
record: [0]→[0,1] (index 1), →[0,1,2] (index 2), →[0,1,2,3] (index 3). count = 3.setIndex(2) then setIndex(1). count = 1. The array is untouched ([0,1,2,3]), so redo is available.setIndex(2); count = 2.record(3): slice(0, 3) = [0,1,2] (drops the old 3 at index 3), append → [0,1,2,3], index 3. The redo future was discarded — there's no way back to the old branch, which is exactly how undo/redo should behave.canUndo is false → Undo disabled; at the last index canRedo is false → Redo disabled.count separately. It can drift from history[index]. Fix: derive count from the array + pointer.slice(0, index + 1) before appending.index < history.length - 1.history. Push/splice in place won't re-render. Fix: build a new array.One reducer makes branching, undo, and redo atomic transitions over the same timeline state.
import { useReducer } from 'react';
import './styles.css';
type State = { history: number[]; index: number };
type Action = { type: 'change'; delta: number } | { type: 'undo' } | { type: 'redo' };
function reducer(state: State, action: Action): State {
if (action.type === 'undo') {
return { ...state, index: Math.max(0, state.index - 1) };
}
if (action.type === 'redo') {
return { ...state, index: Math.min(state.history.length - 1, state.index + 1) };
}
const history = [
...state.history.slice(0, state.index + 1),
state.history[state.index] + action.delta,
];
return { history, index: history.length - 1 };
}
export default function App() {
const [state, dispatch] = useReducer(reducer, { history: [0], index: 0 });
const count = state.history[state.index];
const canUndo = state.index > 0;
const canRedo = state.index < state.history.length - 1;
return (
<main className="container">
<h1>Undoable Counter</h1>
<p className="count">{count}</p>
<div className="row">
<button className="primary" onClick={() => dispatch({ type: 'change', delta: -1 })}>-1</button>
<button className="primary" onClick={() => dispatch({ type: 'change', delta: 1 })}>+1</button>
</div>
<div className="row">
<button onClick={() => dispatch({ type: 'undo' })} disabled={!canUndo}>Undo</button>
<button onClick={() => dispatch({ type: 'redo' })} disabled={!canRedo}>Redo</button>
</div>
<p className="hint">History lets you step back and forward through changes.</p>
</main>
);
}Two stacks are a good alternative when commands need direct access to both directions of travel.
import { useState } from 'react';
import './styles.css';
type Timeline = { past: number[]; present: number; future: number[] };
export default function App() {
const [timeline, setTimeline] = useState<Timeline>({
past: [],
present: 0,
future: [],
});
function change(delta: number) {
setTimeline(({ past, present }) => ({
past: [...past, present],
present: present + delta,
future: [],
}));
}
function undo() {
setTimeline(({ past, present, future }) => ({
past: past.slice(0, -1),
present: past[past.length - 1],
future: [present, ...future],
}));
}
function redo() {
setTimeline(({ past, present, future }) => ({
past: [...past, present],
present: future[0],
future: future.slice(1),
}));
}
return (
<main className="container">
<h1>Undoable Counter</h1>
<p className="count">{timeline.present}</p>
<div className="row">
<button className="primary" onClick={() => change(-1)}>-1</button>
<button className="primary" onClick={() => change(1)}>+1</button>
</div>
<div className="row">
<button onClick={undo} disabled={timeline.past.length === 0}>Undo</button>
<button onClick={redo} disabled={timeline.future.length === 0}>Redo</button>
</div>
<p className="hint">History lets you step back and forward through changes.</p>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a counter with undo and redo. The trick isn't the counting — it's remembering history: keep every value the counter has been in an array, plus a pointer (index) to the current one. Undo moves the pointer back, redo moves it forward, and a new change after an undo discards the redo "future."
// A self-contained component. No props.
function App(): JSX.Element;
A count, +1 / -1 buttons, and Undo / Redo.
+1, +1, +1 → history [0,1,2,3], index 3, count 3
undo, undo → index 1, count 1 (history unchanged)
redo → index 2, count 2
after undo to index 1, press +1 → the redo future is dropped:
history becomes [0,1,2], index 2, count 2
history is every value seen; index points at the current; count = history[index].index, then appends — no orphaned redo branch.index within bounds.index 0; redo off at the last index.Undo/redo is a history of values plus a pointer. Keep an array of every value the counter has held and an index into it; the displayed count is history[index]. Undo and redo just move the pointer; a fresh change truncates the redo future and appends.
A plain counter forgets its past, so it can't undo. To go back, you must remember where you've been — that's an array of past values. But undo isn't "delete the last value": you might undo, then redo, so the values must stay put. The clean model separates what happened (the history array, which only grows on real changes) from where you are now (an index). Undo/redo slide the index; only an actual +1/−1 edits the array — and it throws away anything ahead of the index, because once you branch off, the old future is gone.
Two state values: history (e.g. [0, 1, 2]) and index (a position in it). count = history[index]. A change computes the next value, takes history.slice(0, index + 1) (everything up to and including the current point), appends the new value, and advances index to the new end. Undo is index - 1, redo is index + 1, both clamped. Undo is enabled when index > 0; redo when index < history.length - 1.
A first attempt keeps two stacks (undo and redo) and pushes/pops between them:
function undo() {
redoStack.push(count);
setCount(undoStack.pop());
}
This works but it's more moving parts: two arrays to keep consistent, plus the current value, and easy to mishandle on a new change (you must remember to clear the redo stack). A single history array with one index captures the same thing with less to synchronize — and "clear the redo future" becomes a single slice.
import { useState } from 'react';
import './styles.css';
export default function App() {
const [history, setHistory] = useState<number[]>([0]);
const [index, setIndex] = useState(0);
const count = history[index];
const canUndo = index > 0;
const canRedo = index < history.length - 1;
function record(next: number) {
const kept = history.slice(0, index + 1); // drop the redo future
setHistory([...kept, next]);
setIndex(kept.length); // new value sits at the end
}
return (
<main className="container">
<h1>Undoable Counter</h1>
<p className="count">{count}</p>
<div className="row">
<button className="primary" onClick={() => record(count - 1)}>
-1
</button>
<button className="primary" onClick={() => record(count + 1)}>
+1
</button>
</div>
<div className="row">
<button onClick={() => setIndex(index - 1)} disabled={!canUndo}>
Undo
</button>
<button onClick={() => setIndex(index + 1)} disabled={!canRedo}>
Redo
</button>
</div>
<p className="hint">History lets you step back and forward through changes.</p>
</main>
);
}
count is derived (history[index]), never stored separately. record(next) is the only thing that grows the array: slice(0, index + 1) keeps the past up to the current point and drops any redo future, then appends next and points index at it (kept.length). Undo and redo are one-liners that nudge index, and canUndo/canRedo derive the button-disabled state from where the pointer sits — no separate "can I undo?" flags.
history = [0], index = 0.
record: [0]→[0,1] (index 1), →[0,1,2] (index 2), →[0,1,2,3] (index 3). count = 3.setIndex(2) then setIndex(1). count = 1. The array is untouched ([0,1,2,3]), so redo is available.setIndex(2); count = 2.record(3): slice(0, 3) = [0,1,2] (drops the old 3 at index 3), append → [0,1,2,3], index 3. The redo future was discarded — there's no way back to the old branch, which is exactly how undo/redo should behave.canUndo is false → Undo disabled; at the last index canRedo is false → Redo disabled.count separately. It can drift from history[index]. Fix: derive count from the array + pointer.slice(0, index + 1) before appending.index < history.length - 1.history. Push/splice in place won't re-render. Fix: build a new array.One reducer makes branching, undo, and redo atomic transitions over the same timeline state.
import { useReducer } from 'react';
import './styles.css';
type State = { history: number[]; index: number };
type Action = { type: 'change'; delta: number } | { type: 'undo' } | { type: 'redo' };
function reducer(state: State, action: Action): State {
if (action.type === 'undo') {
return { ...state, index: Math.max(0, state.index - 1) };
}
if (action.type === 'redo') {
return { ...state, index: Math.min(state.history.length - 1, state.index + 1) };
}
const history = [
...state.history.slice(0, state.index + 1),
state.history[state.index] + action.delta,
];
return { history, index: history.length - 1 };
}
export default function App() {
const [state, dispatch] = useReducer(reducer, { history: [0], index: 0 });
const count = state.history[state.index];
const canUndo = state.index > 0;
const canRedo = state.index < state.history.length - 1;
return (
<main className="container">
<h1>Undoable Counter</h1>
<p className="count">{count}</p>
<div className="row">
<button className="primary" onClick={() => dispatch({ type: 'change', delta: -1 })}>-1</button>
<button className="primary" onClick={() => dispatch({ type: 'change', delta: 1 })}>+1</button>
</div>
<div className="row">
<button onClick={() => dispatch({ type: 'undo' })} disabled={!canUndo}>Undo</button>
<button onClick={() => dispatch({ type: 'redo' })} disabled={!canRedo}>Redo</button>
</div>
<p className="hint">History lets you step back and forward through changes.</p>
</main>
);
}Two stacks are a good alternative when commands need direct access to both directions of travel.
import { useState } from 'react';
import './styles.css';
type Timeline = { past: number[]; present: number; future: number[] };
export default function App() {
const [timeline, setTimeline] = useState<Timeline>({
past: [],
present: 0,
future: [],
});
function change(delta: number) {
setTimeline(({ past, present }) => ({
past: [...past, present],
present: present + delta,
future: [],
}));
}
function undo() {
setTimeline(({ past, present, future }) => ({
past: past.slice(0, -1),
present: past[past.length - 1],
future: [present, ...future],
}));
}
function redo() {
setTimeline(({ past, present, future }) => ({
past: [...past, present],
present: future[0],
future: future.slice(1),
}));
}
return (
<main className="container">
<h1>Undoable Counter</h1>
<p className="count">{timeline.present}</p>
<div className="row">
<button className="primary" onClick={() => change(-1)}>-1</button>
<button className="primary" onClick={() => change(1)}>+1</button>
</div>
<div className="row">
<button onClick={undo} disabled={timeline.past.length === 0}>Undo</button>
<button onClick={redo} disabled={timeline.future.length === 0}>Redo</button>
</div>
<p className="hint">History lets you step back and forward through changes.</p>
</main>
);
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.