Undo/redo is a beloved feature and a fiddly one. The core is a useState that doesn't forget: every value it's held is recorded, and you can walk back and forward through that timeline. useStateWithHistory is that primitive — it behaves like useState, but also tracks a history array and a pointer into it, with back, forward, and go to move around.
Implement useStateWithHistory(initialValue, { capacity = 10 }). Return [value, set, controls] where controls has history, pointer, back(steps), forward(steps), and go(index). Setting a new value after going back truncates the forward history (a new branch, like a browser's back-then-navigate), and history never grows past capacity.
function useStateWithHistory(initialValue, { capacity = 10 }) {
// returns [value, set, { history, pointer, back, forward, go }]
}
const [text, setText, { back, forward }] = useStateWithHistory('');
setText('h'); setText('hi'); // history: ['', 'h', 'hi']
back(); // text === 'h' (undo)
forward(); // text === 'hi' (redo)
const [v, set, { go, history }] = useStateWithHistory(0);
set(1); set(2);
go(0); // back to 0
set(9); // branch: history is now [0, 9], the 1/2 future is discarded
value is history[pointer]; back/forward move the pointer (clamped), go(i) jumps to it.set while the pointer is behind the end drops everything after it, then appends — you can't redo into the old future.set to the same value shouldn't add a duplicate history entry.You'll keep the current value in state but the history array and pointer in refs, so navigation mutates the timeline without the render churn of storing it all in state — then reconcile value to history[pointer] on every move.
This is useState plus a tape recorder. The value is what the component renders; the history is the tape of every value it's held, and the pointer is the play-head. set records a new value at the head; back/forward move the head; go seeks to a position. Two rules give it "browser history" behavior: recording a new value while the head is not at the end erases the future (you branched), and the tape has a fixed length (capacity) — past that, the oldest entries fall off. Getting the pointer bookkeeping right through all of that is the whole exercise.
The tape (history) and the play-head (pointer) are bookkeeping, not render data — the component only cares about value. So keep the tape and head in refs (mutable, no re-render on their own) and keep value in state. Every operation does the ref bookkeeping, then calls setValue(history[pointer]) to sync what the component sees. back and forward slide the head, clamped to [0, length-1]. set writes at the head — truncating anything after it first, then trimming to capacity.
The naive version keeps everything in state and just appends:
function useStateWithHistoryNaive(initial) {
const [value, setValue] = useState(initial);
const [history, setHistory] = useState([initial]);
const set = (v) => {
setValue(v);
setHistory((h) => [...h, v]); // always appends to the END
};
const back = () => {
/* ...no pointer, so where is "current"? */
};
return [value, set, { history, back }];
}
Without a pointer, there's no notion of "where we are" in the history — back has nothing to decrement. And appending on every set means going back and then setting a new value leaves the old "future" entries stranded at the end of the array (no branch truncation), so a later forward/go walks into values that should have been discarded. You need an explicit pointer, and set has to slice off everything after it before appending.
const { useState, useRef, useCallback } = require('react');
function useStateWithHistory(initialValue, { capacity = 10 } = {}) {
const [value, setValue] = useState(initialValue);
const history = useRef([initialValue]);
const pointer = useRef(0);
const set = useCallback(
(v) => {
const resolved = typeof v === 'function' ? v(history.current[pointer.current]) : v;
if (resolved === history.current[pointer.current]) return; // no real change
// Branch: drop any "future" after the current pointer.
if (pointer.current < history.current.length - 1) {
history.current = history.current.slice(0, pointer.current + 1);
}
history.current.push(resolved);
// Enforce capacity by dropping the oldest.
while (history.current.length > capacity) history.current.shift();
pointer.current = history.current.length - 1;
setValue(resolved);
},
[capacity],
);
const back = useCallback((steps = 1) => {
if (pointer.current <= 0) return;
pointer.current = Math.max(pointer.current - steps, 0);
setValue(history.current[pointer.current]);
}, []);
const forward = useCallback((steps = 1) => {
if (pointer.current >= history.current.length - 1) return;
pointer.current = Math.min(pointer.current + steps, history.current.length - 1);
setValue(history.current[pointer.current]);
}, []);
const go = useCallback((index) => {
const target = index < 0 ? history.current.length + index : index;
if (target < 0 || target > history.current.length - 1) return;
pointer.current = target;
setValue(history.current[target]);
}, []);
return [value, set, { history: history.current, pointer: pointer.current, back, forward, go }];
}
module.exports = { useStateWithHistory };
value is the only thing in state; history and pointer live in refs so sliding the head or rewriting the tape doesn't trigger renders on its own — the single setValue at the end of each op is what re-renders, and it's always fed history[pointer] so the view matches the head. set resolves updaters, ignores no-op writes, truncates the future when the head is behind the end (the branch), appends, trims to capacity from the front, and parks the pointer at the new end. back/forward clamp; go supports negative indices from the end. All the navigation callbacks are stable because they read the refs rather than closing over the arrays.
useStateWithHistory('a'), then set('b'), set('c'), back(), set('d'):
set('b') — resolved 'b' ≠ 'a'; pointer is at end, no truncation; push → ['a','b']; pointer 1. value = 'b'.set('c') — push → ['a','b','c']; pointer 2. value = 'c'.back() — pointer 2 → 1; value = history[1] = 'b'. The 'c' future still exists (a forward would return to it).set('d') — pointer 1 is behind the end (length - 1 = 2), so truncate slice(0, 2) → ['a','b'], then push 'd' → ['a','b','d']; pointer 2. value = 'd'. The 'c' is gone — you branched.back/forward have nothing to move and the "current" position is ambiguous. Track a pointer ref.back strands stale future entries; slice off everything past the pointer before pushing.setValue to repaint.shift the oldest once you exceed capacity.sets (e.g. per keystroke) into one history entry via a debounce makes undo jump by word, not letter.canUndo / canRedo — deriving booleans from the pointer (pointer > 0, pointer < length - 1) drives disabled states on undo/redo buttons.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Undo/redo is a beloved feature and a fiddly one. The core is a useState that doesn't forget: every value it's held is recorded, and you can walk back and forward through that timeline. useStateWithHistory is that primitive — it behaves like useState, but also tracks a history array and a pointer into it, with back, forward, and go to move around.
Implement useStateWithHistory(initialValue, { capacity = 10 }). Return [value, set, controls] where controls has history, pointer, back(steps), forward(steps), and go(index). Setting a new value after going back truncates the forward history (a new branch, like a browser's back-then-navigate), and history never grows past capacity.
function useStateWithHistory(initialValue, { capacity = 10 }) {
// returns [value, set, { history, pointer, back, forward, go }]
}
const [text, setText, { back, forward }] = useStateWithHistory('');
setText('h'); setText('hi'); // history: ['', 'h', 'hi']
back(); // text === 'h' (undo)
forward(); // text === 'hi' (redo)
const [v, set, { go, history }] = useStateWithHistory(0);
set(1); set(2);
go(0); // back to 0
set(9); // branch: history is now [0, 9], the 1/2 future is discarded
value is history[pointer]; back/forward move the pointer (clamped), go(i) jumps to it.set while the pointer is behind the end drops everything after it, then appends — you can't redo into the old future.set to the same value shouldn't add a duplicate history entry.You'll keep the current value in state but the history array and pointer in refs, so navigation mutates the timeline without the render churn of storing it all in state — then reconcile value to history[pointer] on every move.
This is useState plus a tape recorder. The value is what the component renders; the history is the tape of every value it's held, and the pointer is the play-head. set records a new value at the head; back/forward move the head; go seeks to a position. Two rules give it "browser history" behavior: recording a new value while the head is not at the end erases the future (you branched), and the tape has a fixed length (capacity) — past that, the oldest entries fall off. Getting the pointer bookkeeping right through all of that is the whole exercise.
The tape (history) and the play-head (pointer) are bookkeeping, not render data — the component only cares about value. So keep the tape and head in refs (mutable, no re-render on their own) and keep value in state. Every operation does the ref bookkeeping, then calls setValue(history[pointer]) to sync what the component sees. back and forward slide the head, clamped to [0, length-1]. set writes at the head — truncating anything after it first, then trimming to capacity.
The naive version keeps everything in state and just appends:
function useStateWithHistoryNaive(initial) {
const [value, setValue] = useState(initial);
const [history, setHistory] = useState([initial]);
const set = (v) => {
setValue(v);
setHistory((h) => [...h, v]); // always appends to the END
};
const back = () => {
/* ...no pointer, so where is "current"? */
};
return [value, set, { history, back }];
}
Without a pointer, there's no notion of "where we are" in the history — back has nothing to decrement. And appending on every set means going back and then setting a new value leaves the old "future" entries stranded at the end of the array (no branch truncation), so a later forward/go walks into values that should have been discarded. You need an explicit pointer, and set has to slice off everything after it before appending.
const { useState, useRef, useCallback } = require('react');
function useStateWithHistory(initialValue, { capacity = 10 } = {}) {
const [value, setValue] = useState(initialValue);
const history = useRef([initialValue]);
const pointer = useRef(0);
const set = useCallback(
(v) => {
const resolved = typeof v === 'function' ? v(history.current[pointer.current]) : v;
if (resolved === history.current[pointer.current]) return; // no real change
// Branch: drop any "future" after the current pointer.
if (pointer.current < history.current.length - 1) {
history.current = history.current.slice(0, pointer.current + 1);
}
history.current.push(resolved);
// Enforce capacity by dropping the oldest.
while (history.current.length > capacity) history.current.shift();
pointer.current = history.current.length - 1;
setValue(resolved);
},
[capacity],
);
const back = useCallback((steps = 1) => {
if (pointer.current <= 0) return;
pointer.current = Math.max(pointer.current - steps, 0);
setValue(history.current[pointer.current]);
}, []);
const forward = useCallback((steps = 1) => {
if (pointer.current >= history.current.length - 1) return;
pointer.current = Math.min(pointer.current + steps, history.current.length - 1);
setValue(history.current[pointer.current]);
}, []);
const go = useCallback((index) => {
const target = index < 0 ? history.current.length + index : index;
if (target < 0 || target > history.current.length - 1) return;
pointer.current = target;
setValue(history.current[target]);
}, []);
return [value, set, { history: history.current, pointer: pointer.current, back, forward, go }];
}
module.exports = { useStateWithHistory };
value is the only thing in state; history and pointer live in refs so sliding the head or rewriting the tape doesn't trigger renders on its own — the single setValue at the end of each op is what re-renders, and it's always fed history[pointer] so the view matches the head. set resolves updaters, ignores no-op writes, truncates the future when the head is behind the end (the branch), appends, trims to capacity from the front, and parks the pointer at the new end. back/forward clamp; go supports negative indices from the end. All the navigation callbacks are stable because they read the refs rather than closing over the arrays.
useStateWithHistory('a'), then set('b'), set('c'), back(), set('d'):
set('b') — resolved 'b' ≠ 'a'; pointer is at end, no truncation; push → ['a','b']; pointer 1. value = 'b'.set('c') — push → ['a','b','c']; pointer 2. value = 'c'.back() — pointer 2 → 1; value = history[1] = 'b'. The 'c' future still exists (a forward would return to it).set('d') — pointer 1 is behind the end (length - 1 = 2), so truncate slice(0, 2) → ['a','b'], then push 'd' → ['a','b','d']; pointer 2. value = 'd'. The 'c' is gone — you branched.back/forward have nothing to move and the "current" position is ambiguous. Track a pointer ref.back strands stale future entries; slice off everything past the pointer before pushing.setValue to repaint.shift the oldest once you exceed capacity.sets (e.g. per keystroke) into one history entry via a debounce makes undo jump by word, not letter.canUndo / canRedo — deriving booleans from the pointer (pointer > 0, pointer < length - 1) drives disabled states on undo/redo buttons.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.