---
title: Todo App Example
description: "Build an offline-first todo app with Kora.js and React: schema, reactive queries, mutations, and optional multi-device sync."
---

# Todo App

Build a fully offline-capable todo app with real-time sync in under 100 lines of application code. This example covers schema definition, CRUD operations, filtering, and live sync status.

## Define Your Schema

Start by describing your data. Kora infers all TypeScript types from this definition, so your IDE autocompletes field names and type-checks values everywhere.

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

export const schema = defineSchema({
  version: 1,
  collections: {
    todos: {
      fields: {
        title: t.string(),
        completed: t.boolean().default(false),
        priority: t.enum(['low', 'medium', 'high']).default('medium'),
        createdAt: t.timestamp().auto(),
      },
      indexes: ['completed', 'priority', 'createdAt'],
    },
  },
})
```

`t.timestamp().auto()` means `createdAt` is set automatically on insert -- the developer never provides it. The `indexes` array tells Kora to create database indexes for fast filtering and sorting.

## 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',
  },
})
```

That single `sync` line is all it takes to enable real-time synchronization. Without it, the app works fully offline with local persistence. With it, every mutation syncs to the server and fans out to other connected clients.

## React Components

### App Root

Wrap your application in `KoraProvider` to make the app instance available to all hooks.

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

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

### TodoApp with Filtering

```tsx
// TodoApp.tsx
import { useState } from 'react'
import { useQuery, useSyncStatus } from '@korajs/react'
import { app } from './app'
import { AddTodo } from './AddTodo'
import { TodoItem } from './TodoItem'

type Filter = 'all' | 'active' | 'completed'

export function TodoApp() {
  const [filter, setFilter] = useState<Filter>('all')

  return (
    <div>
      <h1>Todos</h1>
      <SyncIndicator />
      <AddTodo />
      <FilterBar current={filter} onChange={setFilter} />
      <TodoList filter={filter} />
    </div>
  )
}

function FilterBar({ current, onChange }: { current: Filter; onChange: (f: Filter) => void }) {
  return (
    <div>
      {(['all', 'active', 'completed'] as const).map((f) => (
        <button key={f} onClick={() => onChange(f)} disabled={current === f}>
          {f}
        </button>
      ))}
    </div>
  )
}

function TodoList({ filter }: { filter: Filter }) {
  const query = filter === 'all'
    ? app.todos.orderBy('createdAt', 'desc')
    : app.todos.where({ completed: filter === 'completed' }).orderBy('createdAt', 'desc')

  const todos = useQuery(query)

  if (todos.length === 0) {
    return <p>No {filter === 'all' ? '' : filter} todos.</p>
  }

  return (
    <ul>
      {todos.map((todo) => (
        <TodoItem key={todo.id} todo={todo} />
      ))}
    </ul>
  )
}
```

`useQuery` returns data synchronously from the local store. There is no loading spinner for local data. The hook re-renders the component whenever the query result changes -- whether from a local mutation or an incoming sync.

### AddTodo

```tsx
// AddTodo.tsx
import { useState } from 'react'
import { useMutation } from '@korajs/react'
import { app } from './app'

export function AddTodo() {
  const [title, setTitle] = useState('')
  const addTodo = useMutation(app.todos.insert)

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    if (!title.trim()) return
    addTodo.mutate({ title: title.trim(), priority: 'medium' })
    setTitle('')
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="What needs to be done?"
      />
      <button type="submit">Add</button>
    </form>
  )
}
```

`useMutation` returns an object with `mutate` (fire-and-forget) and `mutateAsync` (awaitable) methods, plus `reset`, `isLoading`, and `error`. It is optimistic by default: calling `addTodo.mutate(...)` makes the todo appear in the list instantly, before sync confirms it. If you need confirmation, call `await addTodo.mutateAsync(...)` instead.

### TodoItem

```tsx
// TodoItem.tsx
import { useMutation } from '@korajs/react'
import { app } from './app'

interface Todo {
  id: string
  title: string
  completed: boolean
  priority: 'low' | 'medium' | 'high'
  createdAt: number
}

export function TodoItem({ todo }: { todo: Todo }) {
  const updateTodo = useMutation(app.todos.update)
  const deleteTodo = useMutation(app.todos.delete)

  return (
    <li>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => updateTodo.mutate(todo.id, { completed: !todo.completed })}
      />
      <span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
        {todo.title}
      </span>
      <span>{todo.priority}</span>
      <button onClick={() => deleteTodo.mutate(todo.id)}>Delete</button>
    </li>
  )
}
```

### Sync Status Indicator

```tsx
// SyncIndicator.tsx
import { useSyncStatus } from '@korajs/react'

function SyncIndicator() {
  const status = useSyncStatus()

  const labels: Record<string, string> = {
    connected: 'Connected',
    syncing: 'Syncing...',
    synced: 'All changes saved',
    offline: 'Offline',
    error: 'Sync error',
  }

  return (
    <div>
      <span>{labels[status.status]}</span>
      {status.pendingOperations > 0 && (
        <span> ({status.pendingOperations} pending)</span>
      )}
    </div>
  )
}
```

`useSyncStatus` only re-renders when the status actually changes, not on every sync event. The `pendingOperations` count tells users how many local changes are waiting to be synced.

## How It Works

When a user checks off a todo:

1. `updateTodo` creates an **Operation** with only the changed field (`{ completed: true }`).
2. The operation is written to the local SQLite store immediately. The UI updates.
3. The operation enters the outbound sync queue.
4. When connected, Kora sends the operation to the server, which fans it out to other clients.
5. If two users edit the same todo concurrently, the merge engine resolves the conflict automatically using last-write-wins (ordered by hybrid logical clock, not wall-clock time).

All of this happens with zero sync or conflict code from the developer.
