Event Sourcing in Frontend

TL;DR / Event sourcing stores every state change as an immutable event in an ordered log, deriving the current state by replaying all events -- enabling undo/redo, audit trails, and time-travel debugging for free.

How It Works

 ╭──────────╮        ╭─────────────╮
 │  Action  │───────→│  Event Log  │   [add, update, delete, add]
 ╰──────────╯        ╰─────────────╯   ^append-only, immutable
                            │
                           ┌┘
                           │
                           ↓
                 ╭──────────────────╮          ╭────────╮
                 │  reduce(events)  │          │        │
                 │ = current state  │─────────→│   UI   │
                 ╰──────────────────╯          ╰────────╯

Edit diagram

In traditional state management, you store the current state and mutate it in place. When the user adds an item, you modify the list. The previous state is gone. Event sourcing inverts this: you store the sequence of events that produced the state, and derive the current state by folding over the event log. The events are the source of truth, not the derived state.

The Event Log

An event log is an ordered, append-only collection of immutable event objects. Each event has a type (ITEM_ADDED, ITEM_UPDATED, ITEM_DELETED), a timestamp, a payload (the data associated with the change), and optionally a user ID or correlation ID. Events describe what happened, not what the state should be. {type: 'ITEM_PRICE_CHANGED', id: 5, oldPrice: 10, newPrice: 15} is more informative than {type: 'SET_ITEM', id: 5, price: 15}.

The log is immutable -- events are never modified or deleted. To correct a mistake, you append a compensating event (ITEM_PRICE_CHANGE_REVERSED) rather than editing the original event. This preserves the complete history and makes the log an audit trail.

Deriving State

Current state is computed by applying a reducer function to the event log: state = events.reduce(reducer, initialState). This is structurally identical to Redux's reducer pattern, but with a crucial difference: Redux typically dispatches actions and stores the resulting state. Event sourcing stores the actions (events) and derives the state on demand.

For performance, you do not replay the entire log on every render. Materialized views (cached snapshots) store the derived state at a point in time. When new events arrive, the reducer applies only the new events to the latest snapshot. Periodically, the snapshot is persisted, and events before the snapshot can be archived (though keeping them enables full history replay).

Frontend Use Cases

Undo/redo: Because the event log preserves every change, undo is simply removing the last event (or maintaining a pointer that moves backward through the log). Redo moves the pointer forward. Selective undo (undoing event N while keeping events N+1 through M) is possible by replaying all events except the one being undone. This is fundamentally more powerful than the memento pattern (storing full state snapshots), which consumes more memory and cannot do selective undo.

Collaborative editing: Operational Transformation (OT) and CRDTs are specialized forms of event sourcing. Each user's edits are events. The system merges event logs from multiple users, resolving conflicts through transformation rules. The final document state is derived by replaying the merged log.

Offline-first applications: Events generated while offline are stored locally. When connectivity returns, the local event log is synchronized with the server. Conflict resolution happens at the event level -- the server and client merge their event logs rather than trying to reconcile divergent state snapshots.

Time-travel debugging: Redux DevTools' time-travel feature is event sourcing in action. Every dispatched action is stored. You can move backward and forward through the action log, and the state is recomputed at each point. This is only possible because the action log exists and the reducer is pure.

Projections and Read Models

Event sourcing separates write and read concerns. The event log is the write model. Read models (projections) are optimized views derived from the event log for specific query patterns. Different components can maintain their own projections of the same event log -- a sidebar counts unread items, a main view renders details, a search component maintains an index. All derive from the same events but optimize for their specific rendering needs.

Practical Constraints

Event logs grow indefinitely. Strategies include snapshotting and discarding old events, compacting redundant events (multiple consecutive CURSOR_MOVED events become one), and limiting the log size with a sliding window. Event schema evolution requires upcast functions that transform old event shapes to the current schema during replay.

Gotchas

  • Replaying the entire log on every render is a performance trap. Always maintain a materialized snapshot and apply only new events incrementally.
  • Event ordering matters. ADD_ITEM followed by DELETE_ITEM is different from DELETE_ITEM followed by ADD_ITEM. In distributed or offline contexts, establishing a consistent order requires vector clocks or a centralized sequencer.
  • Reducers must be pure functions. If the reducer reads Date.now() or has side effects, replaying the same events produces different state. All non-deterministic values must be captured in the event payload.
  • Storage limits in the browser (5-10MB for localStorage, more for IndexedDB) constrain log size. Implement compaction and snapshotting strategies from the start, not retroactively.
  • Event sourcing adds complexity that is not justified for simple CRUD. If you do not need undo, audit trails, or offline sync, direct state management is simpler and more appropriate.