Skip to content

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.

typescript
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 ​

PropTypeRequiredDescription
appKoraAppOne of app / storeThe app instance returned by createApp(). Children render once app.ready resolves.
storeStoreOne of app / storeAdvanced: an opened store instead of an app. Children render immediately.
syncEngineSyncEngine | nullNoWith store: the sync engine for useSyncStatus and presence.
fallbackReactNodeNoRendered until app.ready resolves, and during a server render (where the app is inert).
childrenReactNodeYesChild components.

If app.ready rejects, the provider renders the error message in place of its children and logs it to the console.

Example ​

tsx
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 ​

typescript
function useQuery<T = CollectionRecord>(
  query: QueryBuilder<T>,
  options?: { enabled?: boolean; throwOnError?: boolean },
): readonly T[]

Parameters ​

ParameterTypeDescription
queryQueryBuilder<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.enabledbooleanWhen false, no subscription is made and the hook returns []. Defaults to true.
options.throwOnErrorbooleanWhen 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 ​

tsx
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 ​

tsx
function AssignedTodos({ userId }: { userId: string }) {
  const todos = useQuery(
    app.todos.where({ assignee: userId, completed: false }).orderBy('dueDate')
  )

  return <TodoTable todos={todos} />
}

With relations ​

tsx
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 useSyncExternalStore internally, so it is safe in React 18+ concurrent mode (no tearing).
  • subscribe and getSnapshot are 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.

typescript
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).

tsx
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 ​

typescript
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 ​

ParameterTypeDescription
fn(...args: TArgs) => Promise<TData>A collection method such as app.todos.insert or a custom function that performs mutations.
optionsUseMutationOptionsonMutate (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 ​

PropertyTypeDescription
mutate(...args: TArgs) => voidFire-and-forget mutation. Does not return a promise.
mutateAsync(...args: TArgs) => Promise<TData>Awaitable mutation. Resolves when the operation is persisted locally.
isLoadingbooleantrue while a mutation is running.
errorError | nullThe last mutation's error, or null.
rejectedMutationRejection | nullThe most recent server refusal of a write made by this hook, or null. See Rejected writes.
reset() => voidClears 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 ​

tsx
function AddTodo() {
  const { mutate: addTodo } = useMutation(app.todos.insert)

  return (
    <button onClick={() => addTodo({ title: 'New todo' })}>
      Add Todo
    </button>
  )
}

Update and delete ​

tsx
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:

tsx
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>
      )}
    </>
  )
}
typescript
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 an app.transaction() / app.mutation(). A refusal of any of them reports the write once. Writes the function starts after its first await are 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 for OUT_OF_UPLINK_SCOPE, NODE_ID_MISMATCH and SEQUENCE_CONFLICT, where the local value is kept but not synced. (Under experimental.legacyMerge the device does not re-fold refused writes, so reverted describes 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-rejected event and kept in app.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 bare store provider, or none, nothing is tracked.

Awaiting confirmation ​

When you need to confirm the operation was persisted before proceeding:

tsx
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.

tsx
<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 ​

typescript
function useSyncStatus(): SyncStatusInfo

Returns ​

SyncStatusInfo ​

PropertyTypeDescription
statusSyncStatusHigh-level state; see the table below.
reconnectingbooleanTrue while the engine is re-establishing a session.
phaseSyncPhaseDetailed suspended/offline/connecting/handshaking/uploading/receiving/applying/streaming/blocked phase.
pendingOperationsnumberNumber of local operations waiting to be sent to the server.
inFlightUploadOperationsnumberOperations sent but not yet acknowledged.
activeViewCompletebooleanWhether the active downlink view is applied through the accepted frontier.
blockedFailureActiveApplyFailure | nullCurrent delivery-blocking apply failure.
lastSyncedAtnumber | nullTimestamp (milliseconds) of the last successful sync. null if never synced.
lastSuccessfulPush / lastSuccessfulPullnumber | nullLast successful upload and download.
conflictsnumberMerge conflicts seen this session.
clockSkewMsnumber | nullServer time minus local time at the last handshake (negative: this device is fast).
initialSync{ complete, receivedBatches, totalBatches, progress }First-sync progress.
deliveryWatermark / serverFrontiernumber / number | nullDelivery applied without gaps, and the server's newest delivery sequence.
heldOperationsnumberUnsynced writes of another user who shared this local database. They wait (not counted in pendingOperations) until that user signs in again on this device.
heldNodesHeldNodeInfo[]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.
serverProtocolVersionnumber | nullSync protocol version of the server in the current or last session; null before any server answered.
protocolDeprecatedbooleantrue 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 ​

StatusDescription
'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 ​

tsx
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.

typescript
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
}
tsx
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 ​

typescript
function useCollection(name: string): CollectionAccessor

Parameters ​

ParameterTypeDescription
namestringCollection name as defined in the schema.

Returns ​

CollectionAccessor: insert, update, delete, findById and where, the same object while name is unchanged.

Example ​

tsx
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 ​

typescript
function useRichText(
  collection: string,
  recordId: string,
  field: string,
  options?: { user?: AwarenessUser; useDocChannel?: boolean },
): UseRichTextResult

Parameters ​

ParameterTypeDescription
collectionstringCollection containing the record.
recordIdstringID of the record containing the rich text field.
fieldstringName of the t.richtext() field on the record.
options.userAwarenessUserIdentity shown with this user's cursor.
options.useDocChannelbooleanForce the incremental document channel on or off.

Returns ​

UseRichTextResult ​

PropertyTypeDescription
docY.DocThe Yjs document. Pass it to your editor's Yjs binding.
textY.TextThe document's text (doc.getText('content')).
readybooleanfalse while the Yjs state is being loaded from storage.
errorError | nullLoad failure, or the last save's failure (cleared by the next successful save).
undo / redo() => voidLocal undo and redo. Stable identities.
canUndo / canRedobooleanWhether undo / redo is possible.
cursorsCursorInfo[]Remote collaborators' cursors in this field.
setCursor / clearCursorfunctionsPublish or clear the local cursor. Stable identities.
hasUnsavedChangesbooleantrue 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 | nullThe 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 ​

tsx
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_LARGE past the rich-text size limit) sets error and hasUnsavedChanges; the edits stay in the document, the next edit (or retrySave()) saves them again, and the first successful save clears error. While saves keep failing, the edits exist only in memory: keep getUnsavedState() 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 ​

typescript
function usePresence(
  user: { name: string; color: string; avatar?: string } | null,
): void

Parameters ​

ParameterTypeDescription
user{ name: string; color: string; avatar?: string } | nullUser identity for presence display. Pass null to clear presence.
User PropertyTypeRequiredDescription
namestringYesDisplay name shown to other collaborators.
colorstringYesHex color for cursor/avatar rendering (e.g., '#e91e63').
avatarstringNoURL to an avatar image.

Example ​

tsx
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:

tsx
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.url in createApp). 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 user properties 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 ​

typescript
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 ​

typescript
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 ​

tsx
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:

tsx
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 useSyncExternalStore internally 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 AwarenessManager on 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.

typescript
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 or cc-* autocomplete) and data-kora-sensitive inputs are never drafted, a field that becomes one loses its stored value at once, and it stays excluded.
  • The ref may 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 in safe.
  • sensitive takes 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) from korajs removes every draft on sign-out.
  • userId: the user's id, null signed out, undefined while the session loads (nothing is restored or saved until it is known; then the draft is restored and what was typed is kept). Another formKey or known user restarts from initial.

Full application example ​

A complete example combining all hooks:

tsx
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>
  )
}