React Hooks
Kora provides first-class React bindings through the @korajs/react package. All hooks are designed for offline-first: data loads synchronously from the local store, mutations are optimistic, and reactive queries update in real time.
Installation
pnpm add @korajs/reactKoraProvider
Wrap your app with KoraProvider to make the Kora app instance available to all hooks:
import { KoraProvider } from '@korajs/react'
import { app } from './app'
function App() {
return (
<KoraProvider app={app}>
<YourApp />
</KoraProvider>
)
}KoraProvider must be placed above any component that uses Kora hooks. It accepts a single app prop -- the instance returned by createApp.
useQuery
useQuery subscribes to a reactive query and re-renders the component when the results change.
import { useQuery } from '@korajs/react'
import { app } from './app'
function TodoList() {
const todos = useQuery(
app.todos.where({ completed: false }).orderBy('createdAt')
)
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}Key Behaviors
- Synchronous return.
useQueryreturns data immediately from the local store. There is no loading state for local data. - Reactive. When the underlying data changes (local mutation or incoming sync), the component re-renders with the updated results.
- Efficient. The subscription only triggers a re-render when the query results actually change, not on every mutation. Results are diffed internally.
- Concurrent mode safe. Uses
useSyncExternalStoreunder the hood for React 18+ compatibility. No tearing in concurrent rendering. - Cleanup on unmount. The subscription is automatically removed when the component unmounts. No memory leaks.
- StrictMode safe. Works correctly with
React.StrictModedouble-mount behavior.
Query Variations
// All records in a collection
const allTodos = useQuery(app.todos)
// Filtered
const active = useQuery(app.todos.where({ completed: false }))
// Sorted
const sorted = useQuery(app.todos.orderBy('createdAt', 'desc'))
// Limited
const recent = useQuery(
app.todos.orderBy('createdAt', 'desc').limit(5)
)
// Combined
const topActive = useQuery(
app.todos
.where({ completed: false })
.orderBy('priority', 'desc')
.limit(10)
)
// With relations
const todosWithProject = useQuery(
app.todos.where({ completed: false }).include('project')
)Avoiding Unnecessary Re-renders
The query object passed to useQuery should be stable across renders. If you construct a new query object on every render, wrap it in useMemo:
function TodoList({ userId }: { userId: string }) {
const query = useMemo(
() => app.todos.where({ assignee: userId }),
[userId]
)
const todos = useQuery(query)
return /* ... */
}useMutation
useMutation returns a function that performs an optimistic mutation.
import { useMutation } from '@korajs/react'
import { app } from './app'
function AddTodo() {
const addTodo = useMutation(app.todos.insert)
return (
<button onClick={() => addTodo({ title: 'New task' })}>
Add Task
</button>
)
}Key Behaviors
- Fire-and-forget. The mutation function does not return a promise by default. The local store updates instantly and any reactive queries re-render.
- Optimistic. The data appears in the UI before it syncs to the server.
- Offline safe. Mutations work regardless of network state. Operations queue for sync.
Mutation Types
// Insert
const addTodo = useMutation(app.todos.insert)
addTodo({ title: 'New task', completed: false })
// Update
const updateTodo = useMutation(app.todos.update)
updateTodo('record-id', { completed: true })
// Delete
const deleteTodo = useMutation(app.todos.delete)
deleteTodo('record-id')Awaiting Mutations
If you need to wait for the local write to complete (e.g., to get the generated ID):
const addTodo = useMutation(app.todos.insert)
async function handleAdd() {
const todo = await addTodo({ title: 'New task' })
console.log(todo.id) // the generated UUID
}useSyncStatus
useSyncStatus provides real-time sync state for building status indicators.
import { useSyncStatus } from '@korajs/react'
function SyncIndicator() {
const status = useSyncStatus()
switch (status.state) {
case 'synced':
return <span>All changes saved</span>
case 'syncing':
return <span>Syncing...</span>
case 'offline':
return <span>Working offline</span>
case 'error':
return <span>Sync error - retrying</span>
case 'connected':
return <span>Connected</span>
}
}Status Properties
| Property | Type | Description |
|---|---|---|
state | 'connected' | 'syncing' | 'synced' | 'offline' | 'error' | Current sync state |
pendingOperations | number | Number of operations not yet sent to server |
lastSyncedAt | number | null | Timestamp of last successful sync |
useSyncStatus only re-renders when the status object changes, not on every sync event. This keeps the component efficient.
Pending Operations Counter
Show users how many changes are waiting to sync:
function PendingBadge() {
const { pendingOperations } = useSyncStatus()
if (pendingOperations === 0) return null
return (
<span className="badge">
{pendingOperations} pending
</span>
)
}useCollection
useCollection provides direct access to a collection's API within a component. This is useful when you need multiple operations on the same collection:
import { useCollection } from '@korajs/react'
function TodoManager() {
const todos = useCollection(app.todos)
async function handleAdd() {
await todos.insert({ title: 'New task' })
}
async function handleComplete(id: string) {
await todos.update(id, { completed: true })
}
async function handleDelete(id: string) {
await todos.delete(id)
}
return /* ... */
}useRichText
useRichText binds a t.richtext() field to a rich text editor. It returns the Yjs document and a binding helper.
import { useRichText } from '@korajs/react'
function NoteEditor({ todoId }: { todoId: string }) {
const { doc, provider } = useRichText(app.todos, todoId, 'notes')
// Pass `doc` to your editor (e.g., TipTap, Slate, ProseMirror)
// The `provider` handles syncing the Yjs document
return <YourEditor doc={doc} />
}With TipTap
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Collaboration from '@tiptap/extension-collaboration'
import { useRichText } from '@korajs/react'
function NoteEditor({ todoId }: { todoId: string }) {
const { doc } = useRichText(app.todos, todoId, 'notes')
const editor = useEditor({
extensions: [
StarterKit,
Collaboration.configure({ document: doc }),
],
})
return <EditorContent editor={editor} />
}Complete Example
Here is a full todo app using all the hooks together:
import {
KoraProvider,
useQuery,
useMutation,
useSyncStatus,
} from '@korajs/react'
import { app } from './app'
function App() {
return (
<KoraProvider app={app}>
<header>
<h1>Todos</h1>
<SyncIndicator />
</header>
<AddTodo />
<TodoList />
</KoraProvider>
)
}
function SyncIndicator() {
const status = useSyncStatus()
if (status.state === 'offline') {
return <span>Offline - changes will sync later</span>
}
if (status.pendingOperations > 0) {
return <span>Syncing {status.pendingOperations} changes...</span>
}
return <span>Synced</span>
}
function AddTodo() {
const addTodo = useMutation(app.todos.insert)
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const form = e.currentTarget
const title = new FormData(form).get('title') as string
if (title.trim()) {
addTodo({ title: title.trim() })
form.reset()
}
}
return (
<form onSubmit={handleSubmit}>
<input name="title" placeholder="What needs to be done?" />
<button type="submit">Add</button>
</form>
)
}
function TodoList() {
const todos = useQuery(
app.todos.where({ completed: false }).orderBy('createdAt')
)
const updateTodo = useMutation(app.todos.update)
const deleteTodo = useMutation(app.todos.delete)
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() =>
updateTodo(todo.id, { completed: !todo.completed })
}
/>
<span>{todo.title}</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
)
}Every piece of this example works offline. Data loads instantly, mutations are optimistic, and sync happens automatically in the background.
Presence Hooks
For collaborative features, Kora provides hooks that share ephemeral user state (presence) across connected clients.
usePresence(user)
Broadcasts the current user's presence to all connected clients. Cleans up automatically on unmount.
import { usePresence } from '@korajs/react'
function Editor() {
usePresence({
name: 'Alice',
color: '#e91e63',
avatar: 'https://example.com/alice.png', // optional
})
return <div>...</div>
}Pass null to clear presence (e.g., when the user is idle).
useCollaborators()
Returns a reactive list of all connected collaborators and their presence state:
import { useCollaborators } from '@korajs/react'
function ActiveUsers() {
const collaborators = useCollaborators()
return (
<div className="avatars">
{collaborators.map((c) => (
<span
key={c.clientId}
style={{ borderColor: c.user.color }}
title={c.user.name}
>
{c.user.name[0]}
</span>
))}
</div>
)
}Each collaborator includes:
| Property | Type | Description |
|---|---|---|
clientId | number | Unique identifier for the connection |
user | AwarenessUser | Name, color, avatar |
cursor | CursorInfo | undefined | Cursor position (if set) |
lastSeen | number | Timestamp of last update |
See the Presence guide for a full walkthrough.