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

Walkthrough

Space play step click any line
Three takeaways
  1. 1Modeling history as past/present/future stacks makes undo and redo symmetric, mirror-image operations.
  2. 2Capping the past stack bounds memory so long editing sessions don't grow history without limit.
  3. 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.

Undo/redo with two stacks in TypeScript — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code