Most editors keep the truth in local state and hope that the save, a second tab, and a remote collaborator all agree. This challenge inverts that: every change is an immutable event appended to a log, and the document you see is just a projection of it.

🧩 Overview

Build a collaborative notes editor as a tiny event-sourced + CQRS system. Edits are optimistic, the document is rebuilt from the event log, autosave debounces into a single request, and a simulated peer merges its events through the same rules.

https://www.reactchallenges.com/challenges/collaborative-editor

✅ Requirements

  • Implement the editor in App.tsx; the provided types.ts, data.ts, eventLog.ts, projection.ts, api.ts, and peer.ts are read-only, and the first render shows the document built from initialEvents.
  • Render one editable block per textarea in insertion order, with an aria-label on each; the seed blocks appear in that order.
  • Each block has a delete control that removes the block immediately.
  • An add control appends a new empty block to the end of the document.
  • Typing updates the visible text immediately and sets the save status to Dirty.
  • Several edits in quick succession result in a single save, not one save per keystroke.
  • While a save is pending the status reads Saving…; after a successful save it reads Saved.
  • When a save fails the status reads Error, the edited text stays visible, and a retry control appears.
  • Clicking retry saves the latest document immediately and moves from Saving… to Saved on success.
  • A pending autosave is cancelled on unmount, so leaving the editor does not start a save.
  • A remote update applied to a block replaces that block's text with the remote text.
  • A remote event that was already applied is ignored: it changes nothing and triggers no extra save.
  • A remote event that is older than the block's current version is ignored.
  • Remote edits never mark the document Dirty and never trigger an autosave.
  • The presence indicator reads Ana is editing while the simulated peer subscription is active; on unmount the subscription is removed and the peer reports no listeners.

💡 Notes

  • Treat the log as append-only and always return a new array from an append — mutate events in place and React never re-renders.
  • Idempotency is keyed on the event id, not its payload: a re-delivered event can carry a higher version and still has to be dropped before it reaches the projection.
  • Debounce the save with the timeout handle in a ref, reset it on every edit, and clear it in the effect cleanup so an unmount never fires a pending save.

🧪 Tests

  1. Renders the three initial blocks in order
  2. Updates the text immediately and marks the status dirty
  3. Debounces a burst of edits into a single save
  4. Moves from saving to saved and persists the latest text
  5. Keeps the edited text on failure and lets retry save the latest version
  6. Removes a block immediately when it is deleted
  7. Adds a new empty block
  8. Applies a remote update with a higher version
  9. Ignores a remote event whose id was already applied
  10. Ignores a stale remote event with a lower version
  11. Shows the peer presence and unsubscribes on unmount

You finish with a mental model you can reuse anywhere state has to be synced: derive the read model, write events instead of mutating objects, and make every incoming change idempotent. That's the shape of most real-time collaboration and offline-first code.

🔥 Start the Challenge Now