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.
// 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
// 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.
// 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
// 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
// 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({ 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 is optimistic by default. The todo appears in the list instantly, before sync confirms it. If you need confirmation, you can await the result.
TodoItem
// 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(todo.id, { completed: !todo.completed })}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.title}
</span>
<span>{todo.priority}</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
)
}Sync Status Indicator
// 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:
updateTodocreates an Operation with only the changed field ({ completed: true }).- The operation is written to the local SQLite store immediately. The UI updates.
- The operation enters the outbound sync queue.
- When connected, Kora sends the operation to the server, which fans it out to other clients.
- 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.