← Writing

State should explain what changed

A lot of state bugs feel spooky for the same reason: the transition is hidden.

Something changes in one place. The symptom shows up somewhere else. The path between them exists only at runtime, in a chain of listeners, side effects, and shared references you have to reconstruct after the fact.

That is why people describe these bugs as “weird.” The behavior is real, but the causality is buried.

Undo systems are a good example because they are easy to get wrong in exactly this way.

Here is a version built on editor events:

class UndoManager {
  private stack: EditorState[] = [];
  private pointer = -1;

  constructor(private editor: Editor) {
    editor.on('stateChanged', () => {
      // Is this the state before the change or after it?
      // That depends on when listeners run.
      this.stack.length = this.pointer + 1;
      this.stack.push(editor.getState());
      this.pointer++;
    });
  }

  undo(): void {
    if (this.pointer > 0) {
      this.pointer--;
      // Does this trigger stateChanged again?
      // That depends on the editor implementation.
      this.editor.setState(this.stack[this.pointer]);
    }
  }
}

The code is short. It is also sitting on top of several invisible assumptions.

Does editor.getState() return the old state or the new one inside the listener? That depends on event timing.

Does setState() inside undo() emit stateChanged and accidentally record the undo action as a new history entry? That depends on somebody remembering a guard flag or special case elsewhere.

This is the core problem with observer-driven state. The real rules are not in the model. They are smeared across the execution order of callbacks.

The first repair is to pull the undo model out into an explicit data structure.

interface UndoStack<S> {
  readonly past: readonly S[];
  readonly present: S;
  readonly future: readonly S[];
}

function undoStackCreate<S>(initial: S): UndoStack<S> {
  return {
    past: [],
    present: initial,
    future: [],
  };
}

That change matters because the undo logic no longer depends on an editor object to exist. It has its own state now.

Once the state is explicit, the transitions become plain functions:

function undoStackPush<S>(stack: UndoStack<S>, state: S): UndoStack<S> {
  return {
    past: [...stack.past, stack.present],
    present: state,
    future: [],
  };
}

function undoStackUndo<S>(stack: UndoStack<S>): UndoStack<S> | null {
  if (stack.past.length === 0) return null;

  const previous = stack.past[stack.past.length - 1];
  return {
    past: stack.past.slice(0, -1),
    present: previous,
    future: [stack.present, ...stack.future],
  };
}

function undoStackRedo<S>(stack: UndoStack<S>): UndoStack<S> | null {
  if (stack.future.length === 0) return null;

  const next = stack.future[0];
  return {
    past: [...stack.past, stack.present],
    present: next,
    future: stack.future.slice(1),
  };
}

That is the whole model. No listeners. No timing assumptions. No reentrancy bug hiding behind setState().

A caller can use it however they want:

let history = undoStackCreate(initialEditorState);

function applyEditorChange(nextState: EditorState): void {
  history = undoStackPush(history, nextState);
}

function undo(): void {
  const next = undoStackUndo(history);
  if (next !== null) {
    history = next;
  }
}

function redo(): void {
  const next = undoStackRedo(history);
  if (next !== null) {
    history = next;
  }
}

Notice what disappeared. The model no longer needs to know about event timing or framework callbacks. It only knows about state and transitions.

That is not an aesthetic win. It is a debugging win.

You can now answer the two questions that matter in a state bug:

What changed?

Why did it change?

Each operation has a direct answer. undoStackPush() moved present into past, replaced present, and cleared future. undoStackUndo() moved one item out of past, made it present, and pushed the old present into future.

That clarity also changes how you test. Instead of firing events and peeking through framework state, you can test the transition directly.

test('undo moves current state into future', () => {
  const initial = undoStackCreate('A');
  const pushed = undoStackPush(initial, 'B');
  const undone = undoStackUndo(pushed)!;

  expect(undone.present).toBe('A');
  expect(undone.future).toEqual(['B']);
});

The test is small because the world is small.

None of this means “events are always bad” or “mutation is forbidden.” The point is narrower than that. A state model gets healthier when the transition itself is visible and testable. A design gets riskier when the transition is distributed across listeners, callbacks, and shared mutable objects that all have to fire in just the right order.

A good state model should explain itself. You should be able to look at an operation and see what it changed and why.

When you cannot, the state is not only hard to debug. It is hard to trust.

View on Bearblog