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 providedtypes.ts,data.ts,eventLog.ts,projection.ts,api.ts, andpeer.tsare read-only, and the first render shows the document built frominitialEvents. - Render one editable block per textarea in insertion order, with an
aria-labelon 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 readsSaved. - 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…toSavedon 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
Dirtyand never trigger an autosave. - The presence indicator reads
Ana is editingwhile 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
eventsin 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
- Renders the three initial blocks in order
- Updates the text immediately and marks the status dirty
- Debounces a burst of edits into a single save
- Moves from saving to saved and persists the latest text
- Keeps the edited text on failure and lets retry save the latest version
- Removes a block immediately when it is deleted
- Adds a new empty block
- Applies a remote update with a higher version
- Ignores a remote event whose id was already applied
- Ignores a stale remote event with a lower version
- 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.
