Event Sourcing in the Frontend: Building UI State From a Stream of Events

Security & Architecture

In traditional state management, you store current state. When something changes, you mutate or replace the state. The previous state is gone. You know what things are, but not how they got there.

Event sourcing inverts this. Instead of storing state, you store a log of events. Current state is derived by replaying those events from the beginning. You always know exactly what happened, when, and in what order — and you can reconstruct any past state by replaying up to a certain point.

The Core Idea

// Traditional approach: store current state
let state = { count: 5, items: ['a', 'b', 'c'] }

// Event sourcing: store what happened
const events = [
  { type: 'ITEM_ADDED', payload: 'a', timestamp: 1 },
  { type: 'ITEM_ADDED', payload: 'b', timestamp: 2 },
  { type: 'COUNT_SET', payload: 3, timestamp: 3 },
  { type: 'ITEM_ADDED', payload: 'c', timestamp: 4 },
  { type: 'COUNT_SET', payload: 5, timestamp: 5 },
]

// Current state is derived by applying events
const state = events.reduce(reducer, initialState)

useReducer in React is literally event sourcing at the component level: dispatch an event, the reducer produces the next state. The difference is that React's useReducer only keeps the latest state, not the full event log.

Adding the Event Log

Keeping the log requires storing events:

function useEventSourcedState(initialState, reducer) {
  const eventsRef = useRef([])
  const [state, dispatch] = useReducer(reducer, initialState)

  function dispatchWithLog(event) {
    eventsRef.current = [
      ...eventsRef.current,
      {
        ...event,
        timestamp: Date.now(),
      },
    ]
    dispatch(event)
  }

  function replayTo(timestamp) {
    return eventsRef.current
      .filter((e) => e.timestamp <= timestamp)
      .reduce(reducer, initialState)
  }

  return [state, dispatchWithLog, eventsRef.current, replayTo]
}

Now you have the full history. Want to know what the state was 30 seconds ago? Call replayTo(Date.now() - 30000).

Time-Travel Debugging

Redux DevTools popularized time-travel debugging by storing the action log. Every action in Redux corresponds to an event in event sourcing. You can jump back to any point in the application's history by replaying actions up to that point — this is exactly event sourcing applied to application-level state.

This is why complex Redux stores feel like event sourcing: the store is derived state, the actions are events, and dispatch is the append operation.

Undo / Redo

Undo/redo is trivial with an event log:

function useUndoable(initialState, reducer) {
  const [events, setEvents] = useState([])
  const [cursor, setCursor] = useState(-1)

  const state = events.slice(0, cursor + 1).reduce(reducer, initialState)

  function dispatch(event) {
    const newEvents = [...events.slice(0, cursor + 1), event]
    setEvents(newEvents)
    setCursor(newEvents.length - 1)
  }

  const undo = () => setCursor((c) => Math.max(-1, c - 1))
  const redo = () => setCursor((c) => Math.min(events.length - 1, c + 1))

  return { state, dispatch, undo, redo }
}

No special undo state. No cloning of previous states. Just moving the cursor backward through the event log.

Collaborative Features

Event sourcing is the natural model for real-time collaboration (think Google Docs). Multiple users generate events independently. The server receives and orders them. Each client replays the full ordered log to produce the same state.

Conflict resolution becomes explicit: when two users make conflicting edits simultaneously, the server chooses an order. Both clients replay that order and arrive at the same result. This is Operational Transformation (OT) or CRDT territory, but the foundation is an event log shared across clients.

Persistence and Synchronization

Because state is derived from events, persisting state means persisting the event log:

// Persist to localStorage
const events = JSON.parse(localStorage.getItem('events') || '[]')
// On new event:
const updated = [...events, newEvent]
localStorage.setItem('events', JSON.stringify(updated))
// Replay to get current state:
const currentState = updated.reduce(reducer, initialState)

Syncing to a server means sending events, not snapshots. The server applies the same events and maintains the same state. This is far more bandwidth-efficient for small changes to large data structures.

When Event Sourcing Is Not Worth It

Event sourcing adds complexity. It is appropriate when:

  • Your application benefits from full audit history (document editors, accounting, booking systems)
  • You need undo/redo
  • Real-time collaboration is a requirement
  • Server synchronization happens incrementally

It is overkill when:

  • The UI state is purely ephemeral (form values, dropdown open/closed)
  • The data is always fetched fresh from the server
  • There is no meaningful history to preserve

Conclusion

Event sourcing reframes state management from "what is the current state?" to "what has happened?". The current state is always derivable from the event log, which means you never lose information about how you got there. In the frontend, this unlocks undo/redo, time-travel debugging, and collaborative features that are difficult or impossible to build on top of mutable state. The tradeoff is that you must manage the log, and efficiency requires snapshotting for long-lived sessions.