A collaborative todo app where every todo item's text is a CRDT — two users can edit the same todo simultaneously and both changes merge at the character level.
Built with React, Yjs, and useText — kyneta's hook for binding a CRDT text field to an <input> or <textarea>.
# From the kyneta root
pnpm install
# Start the server (Vite HMR + WebSocket sync, single process)
cd examples/todo-react
pnpm run devOpen http://localhost:5173 in two browser tabs. Add a todo in one tab, then edit its text in both tabs at the same time — edits merge without conflict.
Note: This example uses port 5173 (Vite's default), the same port as the Cast todo. Stop one before starting the other.
Most todo apps use Schema.string() for the todo text — last-writer-wins. If two users edit the same todo simultaneously, one edit is lost.
This example uses Schema.text() — a character-level CRDT. Each todo's text field is bound to an <input> via useText, which handles:
- Local edits → diffed against the CRDT and applied as insert/delete operations
- Remote edits → surgically patched into the
<input>viasetRangeText, preserving the local user's cursor position - Concurrent edits → merged by the CRDT engine (Yjs) at the character level
- IME composition → safely deferred until the composition commits
- Echo suppression → local mutations don't bounce back through the changefeed
// The key pattern: useText returns a ref callback for the <input>
function TodoItem({ todoRef }) {
const textInputRef = useText(todoRef.text)
return <input ref={textInputRef} type="text" />
}todo-react/
├── index.html # HTML shell
├── vite.config.ts # @vitejs/plugin-react
├── src/
│ ├── schema.ts # Schema.text() + yjs.bind
│ ├── app.tsx # React components with useText
│ ├── main.tsx # Client (ExchangeProvider + mount)
│ └── server.ts # Server (Vite middleware + Exchange + ws)
├── style.css
├── package.json
├── tsconfig.json
└── README.md
import { Schema } from "@kyneta/schema"
import { yjs } from "@kyneta/yjs-schema"
export const TodoItemSchema = Schema.struct({
id: Schema.string(), // ← stable identity (keys the list, never the index)
text: Schema.text(), // ← CRDT text, not Schema.string()
done: Schema.boolean(),
})
export const TodoSchema = Schema.struct({
todos: Schema.list(TodoItemSchema),
})
export const TodoDoc = yjs.bind(TodoSchema)import { useDocument, useValue, useText } from "@kyneta/react"
import { remove } from "@kyneta/schema"
import { useState } from "react"
// Self-contained: the row owns its done/text/remove through `todoRef`.
function TodoItem({ todoRef, autoFocus, onEnter }) {
const done = useValue(todoRef.done)
return (
<li>
<input
type="checkbox"
checked={done}
onChange={() => todoRef.done.set(!done)}
/>
<input
ref={useText(todoRef.text)}
type="text"
autoFocus={autoFocus}
onKeyDown={e => e.key === "Enter" && onEnter()}
/>
<button onClick={() => remove(todoRef)}>×</button>
</li>
)
}
function App() {
const doc = useDocument("todos", TodoDoc)
const todos = useValue(doc.todos) // reactive snapshot (add/remove + empty-state)
const [newId, setNewId] = useState(null) // id of the row to autofocus
const addTodo = () => {
const id = crypto.randomUUID()
setNewId(id)
doc.todos.push({ id, text: "", done: false })
}
// Map the child refs (stable identity) and key by the todo's stable id.
return (
<ul>
{[...doc.todos].map(todoRef => (
<TodoItem
key={todoRef.id()}
todoRef={todoRef}
autoFocus={todoRef.id() === newId}
onEnter={addTodo}
/>
))}
</ul>
)
}Key details:
useText(todoRef.text)returns a React ref callback — pass it asrefon the<input>- The
<input>is uncontrolled —useTextmanages its value imperatively, not through React state - Rows are keyed by
todo.id(a stable identity) and mapped from the child refs ([...doc.todos]) — neverkey={index}, which is unsafe for a collaborative list where a peer's concurrent insert/remove shifts positions and would mis-associate focus/cursor with the wrong row - Each row is self-contained: it toggles via
todoRef.done.set(...)and removes itself via theremove(todoRef)facade — no index threading - Adding a todo pushes
{ id, text: "", done: false }; the new row autofocuses so you can type immediately, and Enter adds the next one
Browser Tab A Browser Tab B
┌─────────────────────┐ ┌─────────────────────┐
│ <input> │ │ <input> │
│ ↕ useText() │ │ ↕ useText() │
│ Yjs Y.Text │ │ Yjs Y.Text │
│ ↕ changefeed │ │ ↕ changefeed │
│ Exchange │ │ Exchange │
│ ↕ WebSocket │ │ ↕ WebSocket │
└─────────┬───────────┘ └─────────┬───────────┘
│ │
└──────────┐ ┌──────────────────────┘
↓ ↓
┌──────────────────┐
│ Server Exchange │
│ (sync hub) │
└──────────────────┘
When Alice types in Tab A:
inputevent fires →diffTextcomputes the delta →batch(ref, fn, { source: ownToken })applies it to Yjs.ownTokenis an identity-typedSymbolminted perattach()call.- Yjs changefeed fires with the same
sourceidentity →attach()skips it (echo suppression viacs.source === ownToken) - Exchange sends the Yjs update to the server via WebSocket
- Server relays to Tab B's Exchange
- Tab B's Yjs applies the remote update → changefeed fires; substrate replay path drops
source, so Tab B'sattach()seescs.source === undefined attach()applies surgicalsetRangeTextpatches → Bob's cursor stays in place
| Concern | Schema.string() (LWW) |
Schema.text() (CRDT) |
|---|---|---|
| Concurrent edits | Last write wins — one edit is lost | Character-level merge — both edits preserved |
| UI binding | Controlled input with value + onChange |
Uncontrolled input with useText ref callback |
| Re-renders | Every keystroke triggers React re-render | Zero re-renders — useText is imperative |
| Cursor | React re-render can reset cursor position | Cursor preserved through remote edits via transformIndex |
| IME | Must handle carefully in controlled inputs | Built-in composition handling in useText |
This example uses Yjs:
import { yjs } from "@kyneta/yjs-schema"
export const TodoDoc = yjs.bind(TodoSchema)Swap to Loro:
import { loro } from "@kyneta/loro-schema"
export const TodoDoc = loro.bind(TodoSchema)Same schema. Same useText. Same sync protocol. The Exchange doesn't know or care which CRDT engine is underneath.