---
title: Collaborative Notes Example
description: "Build collaborative notes with Kora.js: CRDT rich text with Yjs, presence, and conflict-free multi-user editing that works offline."
---

# Collaborative Notes

Build a collaborative note-taking app where multiple users can edit the same document simultaneously. This example demonstrates Kora's `t.richtext()` field type backed by Yjs CRDTs and custom conflict resolvers for computed fields.

## Define Your Schema

```typescript
// schema.ts
import { defineSchema, t } from 'korajs'

export const schema = defineSchema({
  version: 1,
  collections: {
    notes: {
      fields: {
        title: t.string(),
        content: t.richtext(),
        wordCount: t.number().default(0),
        tags: t.array(t.string()).default([]),
        lastEditedBy: t.string().optional(),
        createdAt: t.timestamp().auto(),
        updatedAt: t.timestamp().auto(),
      },
      indexes: ['tags', 'updatedAt'],
      resolve: {
        wordCount: (local: number, remote: number, base: number): number => {
          // Additive merge: apply both deltas to the base value.
          // If base was 50, local changed it to 55 (+5), and remote
          // changed it to 48 (-2), the merged result is 50 + 5 + (-2) = 53.
          const localDelta = local - base
          const remoteDelta = remote - base
          return Math.max(0, base + localDelta + remoteDelta)
        },
      },
    },
  },
})
```

Two things to notice here:

- **`t.richtext()`** declares a field backed by a Yjs `Y.Text` CRDT. Concurrent character-level edits merge automatically without any conflict resolution code.
- **`resolve.wordCount`** is a custom Tier 3 resolver. Because word count is derived from content, a simple last-write-wins strategy would lose one user's contribution. The additive merge preserves both deltas relative to the shared base value.

## Create the App

```typescript
// app.ts
import { createApp } from 'korajs'
import { schema } from './schema'

export const app = createApp({
  schema,
  sync: {
    url: 'wss://my-server.com/kora',
  },
})
```

## React Components

### App Root

```tsx
// main.tsx
import { KoraProvider } from '@korajs/react'
import { app } from './app'
import { NotesApp } from './NotesApp'

function Main() {
  return (
    <KoraProvider app={app}>
      <NotesApp />
    </KoraProvider>
  )
}
```

### Notes List and Editor Layout

```tsx
// NotesApp.tsx
import { useState } from 'react'
import { useQuery, useMutation, useSyncStatus } from '@korajs/react'
import { app } from './app'
import { NoteEditor } from './NoteEditor'

export function NotesApp() {
  const [selectedId, setSelectedId] = useState<string | null>(null)
  const notes = useQuery(app.notes.orderBy('updatedAt', 'desc'))
  const createNote = useMutation(app.notes.insert)
  const deleteNote = useMutation(app.notes.delete)
  const status = useSyncStatus()

  const handleNewNote = async () => {
    const note = await createNote.mutateAsync({ title: 'Untitled', content: '' })
    setSelectedId(note.id)
  }

  return (
    <div style={{ display: 'flex', height: '100vh' }}>
      <aside style={{ width: 260, borderRight: '1px solid #eee', padding: 16 }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
          <h2>Notes</h2>
          <span>{status.status === 'offline' ? 'Offline' : 'Synced'}</span>
        </div>
        <button onClick={handleNewNote}>New Note</button>
        <ul style={{ listStyle: 'none', padding: 0 }}>
          {notes.map((note) => (
            <li
              key={note.id}
              onClick={() => setSelectedId(note.id)}
              style={{
                padding: 8,
                cursor: 'pointer',
                background: note.id === selectedId ? '#f0f0f0' : 'transparent',
              }}
            >
              <strong>{note.title || 'Untitled'}</strong>
              <br />
              <small>{note.wordCount} words</small>
              <button
                onClick={(e) => { e.stopPropagation(); deleteNote.mutate(note.id) }}
                style={{ float: 'right' }}
              >
                Delete
              </button>
            </li>
          ))}
        </ul>
      </aside>
      <main style={{ flex: 1, padding: 16 }}>
        {selectedId ? <NoteEditor noteId={selectedId} /> : <p>Select a note or create a new one.</p>}
      </main>
    </div>
  )
}
```

### Rich Text Editor

The `useRichText` hook connects a Kora `t.richtext()` field to a text editor. It returns the shared Yjs document (`doc`) and its bound `Y.Text` (`text`), which any Yjs-compatible editor (Tiptap, ProseMirror, Quill, etc.) can consume through its collaboration plugin.

```tsx
// NoteEditor.tsx
import { useEffect } from 'react'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Collaboration from '@tiptap/extension-collaboration'
import { useQuery, useMutation, useRichText } from '@korajs/react'
import { app } from './app'

export function NoteEditor({ noteId }: { noteId: string }) {
  const [note] = useQuery(app.notes.where({ id: noteId }))
  const updateNote = useMutation(app.notes.update)
  const { doc, text, ready } = useRichText('notes', noteId, 'content')

  const editor = useEditor(
    {
      extensions: [StarterKit, Collaboration.configure({ document: doc })],
    },
    [doc],
  )

  // Recompute the word count whenever the shared text changes.
  useEffect(() => {
    if (!text) return
    const recount = () => {
      const value = text.toString()
      const count = value.trim() ? value.trim().split(/\s+/).length : 0
      updateNote.mutate(noteId, { wordCount: count, lastEditedBy: 'current-user' })
    }
    text.observe(recount)
    return () => text.unobserve(recount)
  }, [text, noteId, updateNote])

  if (!note) return <p>Note not found.</p>
  if (!ready) return <p>Loading...</p>

  const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    updateNote.mutate(noteId, { title: e.target.value })
  }

  return (
    <div>
      <input
        value={note.title}
        onChange={handleTitleChange}
        style={{ fontSize: 24, border: 'none', width: '100%' }}
      />
      <p style={{ color: '#888' }}>{note.wordCount} words</p>
      <EditorContent editor={editor} />
    </div>
  )
}
```

`useRichText` returns, among other fields:

- **`doc`** is the shared Yjs `Y.Doc`. Hand it to your editor's collaboration plugin (here, Tiptap's `Collaboration` extension, or `y-prosemirror` for a bare ProseMirror view).
- **`text`** is the bound `Y.Text` for the field. Read `text.toString()` for the current plain text, or call `text.observe(...)` to react to edits.
- **`ready`** turns `true` once the document has loaded from the local store.

Edits made through the editor are automatically captured as Kora operations and synced to other clients.

## How Rich Text Sync Works

When two users edit the same note at the same time, here is what happens:

1. **User A** types "Hello" at position 0. Kora records this as a Yjs operation on the `content` field.
2. **User B** types "World" at position 0 in the same note, at the same time. Kora records a separate Yjs operation.
3. Both operations sync to the server and fan out to the other client.
4. The Yjs CRDT merges the operations at the character level. The result deterministically becomes "HelloWorld" or "WorldHello" depending on the node IDs (used for tie-breaking), but both users always see the same result.

There is no last-write-wins for rich text. No content is ever lost. Each keystroke is preserved independently, and the CRDT guarantees convergence across all clients.

This is different from the `title` field, which uses last-write-wins (LWW) via the hybrid logical clock. For short scalar values like titles, LWW is sufficient. For long-form text where users expect character-level merging, `t.richtext()` provides a CRDT.

## Custom Resolver: Additive Word Count

The `wordCount` field uses a custom Tier 3 resolver. Here is why it matters.

Consider this scenario:

| State | Base | User A | User B |
|-------|------|--------|--------|
| Word count | 50 | 55 (added 5 words) | 48 (deleted 2 words) |

With default last-write-wins, the later write would overwrite the earlier one. If User A's edit arrives last, the count becomes 55, ignoring User B's deletion. The count would be wrong.

The additive resolver fixes this:

```typescript
wordCount: (local: number, remote: number, base: number): number => {
  const localDelta = local - base    // +5
  const remoteDelta = remote - base  // -2
  return Math.max(0, base + localDelta + remoteDelta) // 50 + 5 + (-2) = 53
}
```

Both contributions are preserved. The `Math.max(0, ...)` guard prevents the count from going negative in edge cases.

## Tags: Add-Wins Set

The `tags` field is declared as `t.array(t.string())`. Kora merges arrays using an add-wins set strategy by default: if User A adds the tag "work" and User B adds the tag "urgent" concurrently, the merged result contains both tags. If both users add the same tag, it appears once.

```typescript
// User A
updateNote.mutate(noteId, { tags: [...note.tags, 'work'] })

// User B (concurrently)
updateNote.mutate(noteId, { tags: [...note.tags, 'urgent'] })

// After sync, both users see: ['work', 'urgent']
```

No duplicates, no lost tags, no conflict resolution code required.
