React API Reference
@korajs/react provides React hooks and components for building reactive offline-first UIs. All hooks are concurrent-mode safe (using useSyncExternalStore with stable subscribe/getSnapshot, so renders never tear), compatible with React.StrictMode, re-render only when their value changes, and render on the server (renderToString, Next.js App Router) with empty values. See Server rendering and Next.js.
import {
KoraProvider,
createKoraHooks,
useQuery,
useQueryState,
useMutation,
useSyncStatus,
useCollection,
useRichText,
usePresence,
useCollaborators,
} from '@korajs/react'KoraProvider
Context provider that makes the Kora app instance available to all hooks in the component tree. Must wrap any component that uses Kora hooks.
Props
| Prop | Type | Required | Description |
|---|---|---|---|
app | KoraApp | One of app / store | The app instance returned by createApp(). Children render once app.ready resolves. |
store | Store | One of app / store | Advanced: an opened store instead of an app. Children render immediately. |
syncEngine | SyncEngine | null | No | With store: the sync engine for useSyncStatus and presence. |
fallback | ReactNode | No | Rendered until app.ready resolves, and during a server render (where the app is inert). |
children | ReactNode | Yes | Child components. |
If app.ready rejects, the provider renders the error message in place of its children and logs it to the console.
Example
function App() {
return (
<KoraProvider app={app}>
<TodoList />
</KoraProvider>
)
}WARNING
Create the app instance outside of your component tree (e.g., in a module-level variable). Creating it inside a component would reinitialize the database on every render.
Server rendering
A module-scope createApp evaluated during a server render (no window) is inert: it opens no database and <KoraProvider app={app}> renders its fallback. The client then hydrates the same fallback and fills in local data. See Server rendering and Next.js.
useQuery()
Returns a reactive array of records matching a query. The component re-renders automatically whenever the result set changes due to local mutations or incoming sync operations.
Signature
function useQuery<T = CollectionRecord>(
query: QueryBuilder<T>,
options?: { enabled?: boolean; throwOnError?: boolean },
): readonly T[]Parameters
| Parameter | Type | Description |
|---|---|---|
query | QueryBuilder<T> | A query built using collection methods (.where(), .orderBy(), etc.). A new builder with the same descriptor on each render is fine: the hook keys on the descriptor. |
options.enabled | boolean | When false, no subscription is made and the hook returns []. Defaults to true. |
options.throwOnError | boolean | When true (default), a failed query is thrown to the nearest error boundary. When false, the hook keeps returning the last good rows; read the error with useQueryState. |
Returns
readonly T[]: the records matching the query, the same array until the result changes. Returns an empty array if no records match, and during a server render or hydration.
The local query runs right after the component mounts, so the first render returns [] and the rows follow immediately. Use useQueryState to distinguish "not loaded yet" (ready: false) from "no rows".
Errors
A query that fails (for example a where or orderBy on a field that does not exist) is not swallowed into an empty list. useQuery throws it to the nearest error boundary; the store also emits it as a query:error event for DevTools. To handle it inline, use useQueryState or pass throwOnError: false.
Example
function OpenTodos() {
const todos = useQuery(
app.todos.where({ completed: false }).orderBy('createdAt', 'desc')
)
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
}With filtering
function AssignedTodos({ userId }: { userId: string }) {
const todos = useQuery(
app.todos.where({ assignee: userId, completed: false }).orderBy('dueDate')
)
return <TodoTable todos={todos} />
}With relations
function TodosWithProjects() {
const todos = useQuery(
app.todos.where({ completed: false }).include('project')
)
return todos.map((todo) => (
<div key={todo.id}>
{todo.title}: {todo.project?.name}
</div>
))
}Behavior
- The callback is subscribed on mount and unsubscribed on unmount. No manual cleanup is needed.
- Uses
useSyncExternalStoreinternally, so it is safe in React 18+ concurrent mode (no tearing). subscribeandgetSnapshotare stable: React subscribes once per query, not once per render, and the component re-renders only when the result set changes, not on every sync event.- Identical queries in different components share one subscription.
- Works correctly with React.StrictMode (double-mount safe).
- On the server and during hydration it returns
[], so server HTML and the first client render agree; the rows arrive right after hydration.
useQueryState()
Like useQuery, but returns the query's error instead of throwing it, and whether the first result has arrived.
function useQueryState<T = CollectionRecord>(
query: QueryBuilder<T>,
options?: { enabled?: boolean },
): { data: readonly T[]; error: Error | null; ready: boolean }The returned object keeps its identity until data, error or ready changes. error clears when results flow again; data keeps the last good rows meanwhile. ready is false until the first result of the current query (always false on the server).
function SafeTodoList() {
const { data: todos, error } = useQueryState(app.todos.where({ completed: false }))
if (error) return <p role="alert">Could not load todos: {error.message}</p>
return <ul>{todos.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
}useMutation()
Returns a mutation object for performing write operations. Writes are local first: the local store is updated immediately, and the operation is queued for sync.
Signature
function useMutation<TData, TArgs extends unknown[], TContext = void>(
fn: (...args: TArgs) => Promise<TData>,
options?: {
onMutate?: (...args: TArgs) => TContext | Promise<TContext>
onRollback?: (context: TContext, ...args: TArgs) => void | Promise<void>
onSuccess?: (data: TData, ...args: TArgs) => void
onError?: (error: Error, ...args: TArgs) => void
onSettled?: (data: TData | undefined, error: Error | null, ...args: TArgs) => void
onRejected?: (rejection: MutationRejection<TArgs>) => void
},
): {
mutate: (...args: TArgs) => void
mutateAsync: (...args: TArgs) => Promise<TData>
isLoading: boolean
error: Error | null
rejected: MutationRejection<TArgs> | null
reset: () => void
}Parameters
| Parameter | Type | Description |
|---|---|---|
fn | (...args: TArgs) => Promise<TData> | A collection method such as app.todos.insert or a custom function that performs mutations. |
options | UseMutationOptions | onMutate (its return value is the context), onRollback(context, ...args) when the write fails, onSuccess, onError, onSettled, and onRejected(rejection) when the sync server later refuses the write. |
Returns
| Property | Type | Description |
|---|---|---|
mutate | (...args: TArgs) => void | Fire-and-forget mutation. Does not return a promise. |
mutateAsync | (...args: TArgs) => Promise<TData> | Awaitable mutation. Resolves when the operation is persisted locally. |
isLoading | boolean | true while a mutation is running. |
error | Error | null | The last mutation's error, or null. |
rejected | MutationRejection | null | The most recent server refusal of a write made by this hook, or null. See Rejected writes. |
reset | () => void | Clears error, rejected and isLoading. |
mutate, mutateAsync and reset keep their identity for the component's lifetime, so they are safe in effect dependency arrays and as props of memoized children. The result object only changes when isLoading, error or rejected changes. The latest fn and options are always used, even though the callbacks are stable.
Example
function AddTodo() {
const { mutate: addTodo } = useMutation(app.todos.insert)
return (
<button onClick={() => addTodo({ title: 'New todo' })}>
Add Todo
</button>
)
}Update and delete
function TodoItem({ todo }: { todo: Todo }) {
const { mutate: updateTodo } = useMutation(
(data: { completed: boolean }) => app.todos.update(todo.id, data)
)
const { mutate: deleteTodo } = useMutation(
() => app.todos.delete(todo.id)
)
return (
<div>
<input
type="checkbox"
checked={todo.completed ?? false}
onChange={() => updateTodo({ completed: !todo.completed })}
/>
<span>{todo.title}</span>
<button onClick={() => deleteTodo()}>Delete</button>
</div>
)
}Rejected writes
A write resolves as soon as it is saved on the device. The sync server may refuse it later (a validator, an access rule, a constraint, a record that left the user's scope), sometimes hours later when an offline device reconnects. rejected and onRejected report that refusal to the hook that made the write, so the component can say "this change was undone, and why" without listening to sync events:
function AddTodo() {
const createTodo = useMutation(app.todos.insert, {
onRejected: (r) => console.warn(`"${r.args[0].title}" was not saved: ${r.message}`),
})
return (
<>
<button onClick={() => createTodo.mutate({ title: 'Ship it' })}>Add</button>
{createTodo.rejected && (
<p role="alert">
{createTodo.rejected.reverted ? 'Undone: ' : 'Not synced: '}
{createTodo.rejected.message} ({createTodo.rejected.code})
<button onClick={createTodo.reset}>Dismiss</button>
</p>
)}
</>
)
}interface MutationRejection<TArgs> {
code: string // WINDOW_CLOSED, ACCESS_DENIED, SCOPE_RETRACTED, ...
message: string // the server's reason
collection: string // of the refused operation
recordId: string
operationId: string // the refused operation
operationIds: readonly string[] // every operation the write produced
reverted: boolean // the device already undid it
args: TArgs // what mutate() was called with
}- What one write is. Everything one
mutate()/mutateAsync()call commits: a single insert, update or delete with its referential cascades, or every operation of anapp.transaction()/app.mutation(). A refusal of any of them reports the write once. Writes the function starts after its firstawaitare not attributed to the call: start them synchronously or put them in one transaction. reverted. True for every permanent refusal: the device re-folds the record without the operation, so it shows what the server holds again. False forOUT_OF_UPLINK_SCOPE,NODE_ID_MISMATCHandSEQUENCE_CONFLICT, where the local value is kept but not synced. (Underexperimental.legacyMergethe device does not re-fold refused writes, soreverteddescribes the default fold only.) Retriable rejections are not reported: the write is uploaded again.- Unmounted components. Only a mounted hook is told. A refusal that arrives after the component unmounted, after a reload, or in another tab of the app is still reported by the app-level
sync:operation-rejectedevent and kept inapp.sync.getRejectedOperations(); use those for a global "some changes were not saved" notice. - Bounds. A hook forgets a write once the server stored all of it (
sync:operations-acknowledged) or refused it, and keeps at most 500 unsynced writes (the oldest are dropped first; the app-level event still reports them). - Requires
<KoraProvider app={app}>. With a barestoreprovider, or none, nothing is tracked.
Awaiting confirmation
When you need to confirm the operation was persisted before proceeding:
function AddTodoForm() {
const { mutateAsync: addTodo } = useMutation(app.todos.insert)
const [title, setTitle] = useState('')
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault()
const todo = await addTodo({ title })
setTitle('')
console.log('Created todo:', todo.id)
}
return (
<form onSubmit={handleSubmit}>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<button type="submit">Add</button>
</form>
)
}AuthBoundKoraProvider
Owns authenticated app creation and teardown on shared browsers. It removes the previous provider tree and awaits app.close() before creating a different user's app. Same-user token refreshes do not replace the app.
<AuthBoundKoraProvider
authClient={binding}
createApp={createAppForSession}
signedOut={<SignIn />}
fallback={<OpeningWorkspace />}
error={({ error, retry, session }) => (
<WorkspaceOpenError code={error.code} userId={session.userId} onRetry={retry} />
)}
>
<AuthenticatedApp />
</AuthBoundKoraProvider>The renderer receives a stable code, sanitized primitive metadata, and a token-free session with only userId. retry() uses the provider's serialized transition queue and closes partial resources before opening exactly one replacement. Omitting error retains the default framework view.
useSyncStatus()
Returns the current sync connection status and metadata. Re-renders only when the status changes, not on every sync event.
Signature
function useSyncStatus(): SyncStatusInfoReturns
SyncStatusInfo
| Property | Type | Description |
|---|---|---|
status | SyncStatus | High-level state; see the table below. |
reconnecting | boolean | True while the engine is re-establishing a session. |
phase | SyncPhase | Detailed suspended/offline/connecting/handshaking/uploading/receiving/applying/streaming/blocked phase. |
pendingOperations | number | Number of local operations waiting to be sent to the server. |
inFlightUploadOperations | number | Operations sent but not yet acknowledged. |
activeViewComplete | boolean | Whether the active downlink view is applied through the accepted frontier. |
blockedFailure | ActiveApplyFailure | null | Current delivery-blocking apply failure. |
lastSyncedAt | number | null | Timestamp (milliseconds) of the last successful sync. null if never synced. |
lastSuccessfulPush / lastSuccessfulPull | number | null | Last successful upload and download. |
conflicts | number | Merge conflicts seen this session. |
clockSkewMs | number | null | Server time minus local time at the last handshake (negative: this device is fast). |
initialSync | { complete, receivedBatches, totalBatches, progress } | First-sync progress. |
deliveryWatermark / serverFrontier | number / number | null | Delivery applied without gaps, and the server's newest delivery sequence. |
heldOperations | number | Unsynced writes of another user who shared this local database. They wait (not counted in pendingOperations) until that user signs in again on this device. |
heldNodes | HeldNodeInfo[] | The local nodes holding those writes, with operationCount, reason ('other-user' or 'unassigned') and principal. Resolve unassigned ones with app.sync.assignHeld or app.sync.discardHeld. |
localDurability | 'durable' | 'degraded' | 'degraded' when the local database could not be persisted several times in a row (storage quota, broken IndexedDB). Warn the user to free up storage and stay online. |
serverProtocolVersion | number | null | Sync protocol version of the server in the current or last session; null before any server answered. |
protocolDeprecated | boolean | true when the server speaks an older sync protocol than this client (a pre-beta.13 server). Sync still works, but upgrade the server: a later release refuses that protocol. |
The returned object keeps its identity while the status is unchanged, and so do its nested heldNodes, initialSync and blockedFailure values when only other fields change, so all of them are safe as effect or memo dependencies. Before the component mounts, and on the server, the status is 'offline'.
Status values
| Status | Description |
|---|---|
'connected' | The session is open but the initial exchange has not completed. |
'reconnecting' | The connection dropped; the engine is reconnecting with backoff. |
'syncing' | Actively exchanging operations with the server. |
'synced' | Every local operation is acknowledged and the active view is complete. |
'offline' | No connection to the server (or sync is not configured). The app keeps working locally. |
'auth-required' | Sync is suspended until a user signs in (or the credential is refreshed). |
'encryption-locked' | End-to-end encryption is on and the keyring is locked: sync pauses until app.encryption.unlock(passphrase). Local reads and writes go on. |
'clock-error' | This device's clock is too far ahead of the server: sync pauses until it is fixed. Local writes go on and queue. |
'schema-mismatch' | The server does not accept this client's schema version; upgrade the app. |
'error' | A sync error occurred. Operations stay queued and retry. |
Example
function StorageWarning() {
const { localDurability, heldOperations } = useSyncStatus()
if (localDurability === 'degraded') return <p role="alert">Storage is full: stay online until it syncs.</p>
if (heldOperations) return <p>{heldOperations} changes from another account wait on this device.</p>
return null
}
function SyncIndicator() {
const { status, pendingOperations, lastSyncedAt } = useSyncStatus()
return (
<div>
<span className={`status-${status}`}>
{status === 'synced' && 'All changes saved'}
{status === 'syncing' && 'Syncing...'}
{status === 'offline' && 'Working offline'}
{status === 'error' && 'Sync error'}
{status === 'connected' && 'Connecting...'}
</span>
{pendingOperations > 0 && (
<span>{pendingOperations} pending</span>
)}
</div>
)
}createKoraHooks()
Creates hooks typed for your app, so components get schema-checked collection names, inserts, updates and query rows without passing generics around. Call it once next to createApp; nothing runs at call time.
function createKoraHooks<TApp extends KoraAppLike>(): {
useApp: () => TApp
useCollection: <N extends AppCollectionName<TApp>>(name: N) => AppCollections<TApp>[N]
useQuery: typeof useQuery
useQueryState: typeof useQueryState
useMutation: typeof useMutation
useSyncStatus: typeof useSyncStatus
}import { createKoraHooks } from 'korajs/react'
// kora.ts: next to `export const app = createApp({ schema })`
export const { useCollection, useQuery, useMutation } = createKoraHooks<typeof app>()
// TodoList.tsx
function TodoList() {
const todos = useCollection('todos') // 'todoz' is a type error
const rows = useQuery(todos.where({ completed: false })) // rows: readonly Todo[]
const { mutate: add } = useMutation(todos.insert) // add({ title: 1 }) is a type error
return <button onClick={() => add({ title: 'New' })}>Add ({rows.length})</button>
}The typed useCollection returns the app's own accessor (app.collections[name]), the same object across renders. The types are read from typeof app, so they follow whatever createApp infers from your schema. The helper types AppCollectionName<TApp>, AppCollections<TApp> and AppRecord<TApp, Name> are exported too.
useCollection()
Returns the store's collection accessor for a collection name. This plain hook is untyped (records are CollectionRecord); the useCollection from createKoraHooks returns the typed accessor.
Signature
function useCollection(name: string): CollectionAccessorParameters
| Parameter | Type | Description |
|---|---|---|
name | string | Collection name as defined in the schema. |
Returns
CollectionAccessor: insert, update, delete, findById and where, the same object while name is unchanged.
Example
function TodoActions() {
const todos = useCollection('todos')
const addTodo = async () => {
await todos.insert({ title: 'New todo' })
}
const clearCompleted = async () => {
const completed = await todos.where({ completed: true }).exec()
for (const todo of completed) {
await todos.delete(todo.id)
}
}
return (
<div>
<button onClick={addTodo}>Add</button>
<button onClick={clearCompleted}>Clear completed</button>
</div>
)
}useRichText()
Provides binding helpers for rich text fields backed by Yjs CRDTs. Returns the Yjs document and utility functions for integrating with rich text editors (e.g., TipTap, ProseMirror, Quill).
Signature
function useRichText(
collection: string,
recordId: string,
field: string,
options?: { user?: AwarenessUser; useDocChannel?: boolean },
): UseRichTextResultParameters
| Parameter | Type | Description |
|---|---|---|
collection | string | Collection containing the record. |
recordId | string | ID of the record containing the rich text field. |
field | string | Name of the t.richtext() field on the record. |
options.user | AwarenessUser | Identity shown with this user's cursor. |
options.useDocChannel | boolean | Force the incremental document channel on or off. |
Returns
UseRichTextResult
| Property | Type | Description |
|---|---|---|
doc | Y.Doc | The Yjs document. Pass it to your editor's Yjs binding. |
text | Y.Text | The document's text (doc.getText('content')). |
ready | boolean | false while the Yjs state is being loaded from storage. |
error | Error | null | Load failure, or the last save's failure (cleared by the next successful save). |
undo / redo | () => void | Local undo and redo. Stable identities. |
canUndo / canRedo | boolean | Whether undo / redo is possible. |
cursors | CursorInfo[] | Remote collaborators' cursors in this field. |
setCursor / clearCursor | functions | Publish or clear the local cursor. Stable identities. |
hasUnsavedChanges | boolean | true while local edits are not saved yet: a save is pending or was refused (see error). |
retrySave | () => Promise<void> | Save the document now, for example after a refused save. |
getUnsavedState | () => Uint8Array | null | The full Yjs state while edits are unsaved, for a recovery copy; otherwise null. |
The result object keeps its identity until one of its values changes.
Example with TipTap
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Collaboration from '@tiptap/extension-collaboration'
function NoteEditor({ noteId }: { noteId: string }) {
const { doc, ready } = useRichText('notes', noteId, 'content')
const editor = useEditor({
extensions: [
StarterKit,
Collaboration.configure({ document: doc, field: 'content' }),
],
}, [doc])
if (!ready) return <div>Loading editor...</div>
return <EditorContent editor={editor} />
}Behavior
- The Yjs state is loaded from the local store on mount.
- Changes to the Yjs document are automatically persisted and synced. Each save writes the full live document, so a stored change (a collaborator's save, another field's update) arriving while a save waits never drops local typing. Edits still waiting when the editor unmounts are saved.
- A refused save (for example
OPERATION_TOO_LARGEpast the rich-text size limit) setserrorandhasUnsavedChanges; the edits stay in the document, the next edit (orretrySave()) saves them again, and the first successful save clearserror. While saves keep failing, the edits exist only in memory: keepgetUnsavedState()somewhere durable (IndexedDB) if losing a tab must not lose them. - When multiple devices edit the same rich text field concurrently, Yjs handles character-level merging automatically.
- The hook cleans up the Yjs binding on unmount.
usePresence()
Sets the local user's collaborative presence state. When this hook is active, other connected clients see this user's presence information (name, color, and optional avatar). Presence is ephemeral: it is not persisted, only shared with currently connected peers. usePresence sets no cursor, so the state reaches only peers with the same download scope; the rich-text editor's cursor reaches every peer that can read the record (see Presence).
Automatically clears presence on unmount.
Signature
function usePresence(
user: { name: string; color: string; avatar?: string } | null,
): voidParameters
| Parameter | Type | Description |
|---|---|---|
user | { name: string; color: string; avatar?: string } | null | User identity for presence display. Pass null to clear presence. |
| User Property | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Display name shown to other collaborators. |
color | string | Yes | Hex color for cursor/avatar rendering (e.g., '#e91e63'). |
avatar | string | No | URL to an avatar image. |
Example
function Editor({ currentUser }: { currentUser: { name: string; color: string } }) {
// Set presence when this component mounts, clear on unmount
usePresence({ name: currentUser.name, color: currentUser.color })
return <div>Editing document...</div>
}Conditional presence
Pass null to disable presence broadcasting without unmounting the component:
function CollaborativeEditor({ user, isActive }: { user: User; isActive: boolean }) {
usePresence(isActive ? { name: user.name, color: user.color } : null)
return <div>...</div>
}Behavior
- Requires a sync engine to be configured (via
sync.urlincreateApp). If no sync engine is available, the hook is a no-op. - Presence state is set on the sync engine's
AwarenessManager, which broadcasts it to all connected peers. - Presence is automatically cleared when the component unmounts.
- Changing the
userproperties causes the presence state to be updated.
useCollaborators()
Returns all currently connected collaborators' awareness states. Excludes the local user: only remote peers are returned. Re-renders only when the set of collaborators or their states change.
Signature
function useCollaborators(): AwarenessState[]Returns
AwarenessState[]: an array of awareness states for all connected remote users. Returns an empty array if no peers are connected or sync is not configured.
AwarenessState
interface AwarenessState {
/** User identity information */
user: {
/** Display name */
name: string
/** Hex color for cursor/selection rendering */
color: string
/** Optional avatar URL */
avatar?: string
}
/** Current cursor position, if any */
cursor?: {
/** Collection containing the record being edited */
collection: string
/** ID of the record being edited */
recordId: string
/** Field name of the richtext field */
field: string
/** Cursor anchor position (start of selection) */
anchor: number
/** Cursor head position (end of selection) */
head: number
}
}Example
function CollaboratorList() {
const collaborators = useCollaborators()
if (collaborators.length === 0) {
return <span>No one else is here</span>
}
return (
<div className="collaborators">
{collaborators.map((c) => (
<span
key={c.user.name}
className="collaborator-badge"
style={{ backgroundColor: c.user.color }}
title={c.user.name}
>
{c.user.avatar ? (
<img src={c.user.avatar} alt={c.user.name} />
) : (
c.user.name[0]
)}
</span>
))}
</div>
)
}Combined presence and collaborators
A typical pattern uses both hooks together:
function CollaborativeDocument({ currentUser }: { currentUser: User }) {
// Announce our presence
usePresence({ name: currentUser.name, color: currentUser.color })
// See who else is here
const collaborators = useCollaborators()
return (
<div>
<header>
<span>{collaborators.length} other editor{collaborators.length !== 1 ? 's' : ''} online</span>
<div className="avatars">
{collaborators.map((c) => (
<span key={c.user.name} style={{ color: c.user.color }}>
{c.user.name}
</span>
))}
</div>
</header>
<DocumentEditor />
</div>
)
}Behavior
- Uses
useSyncExternalStoreinternally for concurrent-mode safety (no tearing). - Only re-renders when the collaborator list actually changes (deep comparison via JSON serialization).
- Renders
[]on the server and during hydration. - Returns an empty array if the sync engine is not configured or not connected.
- Automatically subscribes to the sync engine's
AwarenessManageron mount and unsubscribes on unmount. - Works correctly with React.StrictMode (double-mount safe).
useFormDraft()
Keeps a form's input across the reload a new version of the app asks for. Opt-in per form; see App updates and multiple tabs for the five privacy rules.
function useFormDraft<V extends Record<string, unknown>>(
formKey: string,
initial: V,
options: {
fields: readonly (keyof V & string)[] // allow-list
safe?: readonly string[] // bound fields with no resolvable form control, drafted anyway
collection?: string // its t.secret() fields are never drafted
sensitive?: readonly string[] // never drafted
allowSensitive?: readonly string[] // drafted anyway (discouraged)
userId?: string | null // a draft of another user is never restored
},
): {
values: V
setValue<K extends keyof V>(field: K, value: V[K]): void
setValues(update: Partial<V>): void
bind<K extends keyof V & string>(field: K): { name: K; value: V[K]; onChange: (eventOrValue: unknown) => void; ref: (element: Element | null) => void }
bindChecked<K extends keyof V & string>(field: K): { name: K; checked: boolean; onChange: (eventOrValue: unknown) => void; ref: (element: Element | null) => void } // checkboxes
clear(): void // after a successful submit
restored: boolean
}- Inputs bound with
bind(field)are checked on every change: password inputs (type="password"or a password, one-time-code orcc-*autocomplete) anddata-kora-sensitiveinputs are never drafted, a field that becomes one loses its stored value at once, and it stays excluded. - The
refmay land on a wrapper (component libraries): the<input>/<textarea>/<select>inside it is read. A listed field with no resolvable control is not drafted unless insafe. sensitivetakes top-level fields or dotted paths into object fields ('answers.ssn').- With encryption enabled (
app.encryption), nothing is stored. - The stored draft is removed as soon as it is restored;
clearFormDrafts(app)fromkorajsremoves every draft on sign-out. userId: the user's id,nullsigned out,undefinedwhile the session loads (nothing is restored or saved until it is known; then the draft is restored and what was typed is kept). AnotherformKeyor known user restarts frominitial.
Full application example
A complete example combining all hooks:
import { createApp, defineSchema, t } from 'korajs'
import { KoraProvider, useQuery, useMutation, useSyncStatus } from '@korajs/react'
const schema = defineSchema({
version: 1,
collections: {
todos: {
fields: {
title: t.string(),
completed: t.boolean().default(false),
createdAt: t.timestamp().auto(),
},
},
},
})
const app = createApp({
schema,
sync: { url: 'wss://my-server.example.com/kora-sync', autoConnect: true },
})
function App() {
return (
<KoraProvider app={app}>
<SyncIndicator />
<AddTodo />
<TodoList />
</KoraProvider>
)
}
function SyncIndicator() {
const { status, pendingOperations } = useSyncStatus()
return (
<header>
{status === 'offline' ? 'Working offline' : 'Connected'}
{pendingOperations > 0 && ` (${pendingOperations} pending)`}
</header>
)
}
function AddTodo() {
const { mutate: addTodo } = useMutation(app.todos.insert)
const [title, setTitle] = useState('')
return (
<form onSubmit={(e) => { e.preventDefault(); addTodo({ title }); setTitle('') }}>
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="What needs to be done?" />
<button type="submit">Add</button>
</form>
)
}
function TodoList() {
const todos = useQuery(
app.todos.where({ completed: false }).orderBy('createdAt', 'desc')
)
const { mutate: updateTodo } = useMutation(
(args: { id: string; data: { completed: boolean } }) => app.todos.update(args.id, args.data)
)
return (
<ul>
{todos.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed ?? false}
onChange={() => updateTodo({ id: todo.id, data: { completed: true } })}
/>
{todo.title}
</li>
))}
</ul>
)
}
