javascript
51 lines · 7 steps
Building an undo/redo history stack
A three-bucket past/present/future model gives any app clean undo, redo, and debounced state capture.
Explained by
highlit
1class HistoryStack {
2 constructor(initial = '', limit = 100) {
3 this.past = [];
4 this.present = initial;
5 this.future = [];
6 this.limit = limit;
7 this.pending = null;
8 }
9
10 push(next) {
11 if (next === this.present) return;
12 this.past.push(this.present);
13 if (this.past.length > this.limit) this.past.shift();
14 this.present = next;
15 this.future = [];
16 }
17
18 pushDebounced(next, delay = 400) {
19 clearTimeout(this.pending);
20 this.pending = setTimeout(() => this.push(next), delay);
21 }
22
23 undo() {
24 if (!this.canUndo) return this.present;
25 clearTimeout(this.pending);
26 this.future.unshift(this.present);
27 this.present = this.past.pop();
28 return this.present;
29 }
30
31 redo() {
32 if (!this.canRedo) return this.present;
33 this.past.push(this.present);
34 this.present = this.future.shift();
35 return this.present;
36 }
37
38 get canUndo() {
39 return this.past.length > 0;
40 }
41
42 get canRedo() {
43 return this.future.length > 0;
44 }
45
46 clear() {
47 clearTimeout(this.pending);
48 this.past = [];
49 this.future = [];
50 }
51}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Splitting history into past, present, and future arrays makes undo and redo just push/pop between three buckets.
- 2Any new edit must invalidate the redo future, since branching forward from an old state is meaningless.
- 3Capping the past array bounds memory so long editing sessions never grow the history without limit.
Related explainers
javascript
const express = require('express'); const router = express.Router(); const { pool } = require('../db'); const redis = require('../redis');
Building a health check endpoint in Express
health-check
timeouts
promise-race
Intermediate
9 steps
javascript
'use client'; import { useEffect } from 'react'; import * as Sentry from '@sentry/nextjs';
How a Next.js error boundary recovers
error-boundary
error-handling
observability
Intermediate
8 steps
python
from django.contrib.postgres.search import SearchQuery, SearchRank, SearchVector from django.core.cache import cache from django.db.models import F from django.http import JsonResponse
Ranked full-text search in Django
full-text-search
debouncing
caching
Advanced
9 steps
javascript
const MAX_BATCH_SIZE = 20; const FLUSH_INTERVAL = 5000; const ENDPOINT = '/api/analytics';
Batching analytics events in the browser
batching
buffering
sendbeacon
Intermediate
10 steps
javascript
export function diffIds(current, desired) { const currentSet = new Set(current); const desiredSet = new Set(desired);
Diffing sets to sync group membership
set-operations
diffing
transactions
Intermediate
8 steps
javascript
import { useState, useCallback, useRef } from 'react'; function LikeButton({ postId, initialLiked, initialCount }) { const [liked, setLiked] = useState(initialLiked);
Optimistic like button with React
optimistic-ui
abortcontroller
error-handling
Advanced
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/building-an-undo-redo-history-stack-explained-javascript-7e3e/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.