typescript
56 lines · 7 steps
Undo/redo with two stacks in TypeScript
A three-part state model — past, present, future — powers bounded undo and redo for an editor.
Explained by
highlit
1type EditorState = {
2 content: string;
3 cursor: number;
4};
5
6export class UndoRedoHistory {
7 private past: EditorState[] = [];
8 private future: EditorState[] = [];
9 private present: EditorState;
10 private readonly limit: number;
11
12 constructor(initial: EditorState, limit = 100) {
13 this.present = initial;
14 this.limit = limit;
15 }
16
17 get current(): EditorState {
18 return this.present;
19 }
20
21 get canUndo(): boolean {
22 return this.past.length > 0;
23 }
24
25 get canRedo(): boolean {
26 return this.future.length > 0;
27 }
28
29 push(next: EditorState): void {
30 if (next.content === this.present.content && next.cursor === this.present.cursor) {
31 return;
32 }
33 this.past.push(this.present);
34 if (this.past.length > this.limit) {
35 this.past.shift();
36 }
37 this.present = next;
38 this.future = [];
39 }
40
41 undo(): EditorState {
42 const previous = this.past.pop();
43 if (!previous) return this.present;
44 this.future.unshift(this.present);
45 this.present = previous;
46 return this.present;
47 }
48
49 redo(): EditorState {
50 const next = this.future.shift();
51 if (!next) return this.present;
52 this.past.push(this.present);
53 this.present = next;
54 return this.present;
55 }
56}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Modeling history as past/present/future stacks makes undo and redo symmetric, mirror-image operations.
- 2Capping the past stack bounds memory so long editing sessions don't grow history without limit.
- 3Pushing a new state must clear the redo future, since diverging from history invalidates the old redo path.
Related explainers
ruby
class Order class InvalidTransition < StandardError; end TRANSITIONS = {
A state machine for order transitions in Ruby
state-machine
data-driven
error-handling
Intermediate
8 steps
typescript
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { ConfigService } from '@nestjs/config';
How a JWT strategy authenticates in NestJS
authentication
jwt
passport
Intermediate
7 steps
typescript
import { Injectable } from '@angular/core'; import { HttpInterceptor, HttpRequest, HttpHandler, HttpEvent, HttpErrorResponse } from '@angular/common/http'; import { BehaviorSubject, Observable, throwError } from 'rxjs'; import { catchError, filter, take, switchMap } from 'rxjs/operators';
Refreshing auth tokens in an Angular interceptor
http-interceptor
token-refresh
rxjs
Advanced
8 steps
typescript
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; import { GqlExecutionContext } from '@nestjs/graphql'; import { Reflector } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt';
How a GraphQL auth guard works in NestJS
authentication
authorization
jwt
Intermediate
7 steps
typescript
type ResizeCallback = (size: { width: number; height: number }) => void; export function observeResize(callback: ResizeCallback, delay = 150) { let timeoutId: ReturnType<typeof setTimeout> | undefined;
Debouncing window resize in TypeScript
debounce
closures
event-listeners
Intermediate
6 steps
java
public record FixedWidthField(String name, int start, int end) { public String extract(String line) { int from = Math.min(start, line.length());
Parsing fixed-width text in Java
records
parsing
streams
Intermediate
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/undo-redo-with-two-stacks-in-typescript-explained-typescript-26a0/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.