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 internally) and compatible with React.StrictMode.

typescript
import {
  KoraProvider,
  useQuery,
  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
appKoraAppYesThe app instance returned by createApp().
childrenReactNodeYesChild components.

Example

tsx
import { createApp, defineSchema, t } from 'korajs'
import { KoraProvider } from '@korajs/react'

const schema = defineSchema({
  version: 1,
  collections: {
    todos: {
      fields: {
        title: t.string(),
        completed: t.boolean().default(false),
      },
    },
  },
})

const app = createApp({ schema })

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.


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 extends CollectionRecord>(query: QueryBuilder<T>): T[]

Parameters

ParameterTypeDescription
queryQueryBuilder<T>A query built using collection methods (.where(), .orderBy(), etc.).

Returns

T[] -- An array of records matching the query. Returns an empty array if no records match.

Data is always returned synchronously from the local store. There is no loading state for local data.

Example

tsx
import { useQuery } from '@korajs/react'

function TodoList() {
  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).
  • The component only re-renders when the result set actually changes (deep comparison), not on every sync event.
  • Works correctly with React.StrictMode (double-mount safe).

useMutation()

Returns a mutation function for performing write operations. Mutations are optimistic by default -- the local store is updated immediately, and the operation is queued for sync.

Signature

typescript
function useMutation<TInput, TOutput>(
  fn: (input: TInput) => Promise<TOutput>
): {
  mutate: (input: TInput) => void
  mutateAsync: (input: TInput) => Promise<TOutput>
}

Parameters

ParameterTypeDescription
fn(input: TInput) => Promise<TOutput>A collection method such as app.todos.insert or a custom function that performs mutations.

Returns

PropertyTypeDescription
mutate(input: TInput) => voidFire-and-forget mutation. Does not return a promise.
mutateAsync(input: TInput) => Promise<TOutput>Awaitable mutation. Resolves when the operation is persisted locally.

Example

tsx
import { useMutation } from '@korajs/react'

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: Partial<Todo>) => app.todos.update(todo.id, data)
  )
  const { mutate: deleteTodo } = useMutation(
    () => app.todos.delete(todo.id)
  )

  return (
    <div>
      <input
        type="checkbox"
        checked={todo.completed}
        onChange={() => updateTodo({ completed: !todo.completed })}
      />
      <span>{todo.title}</span>
      <button onClick={() => deleteTodo()}>Delete</button>
    </div>
  )
}

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 () => {
    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>
  )
}

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(): SyncStatus

Returns

SyncStatus

PropertyTypeDescription
status'connected' | 'syncing' | 'synced' | 'offline' | 'error'Current connection state.
pendingOperationsnumberNumber of local operations waiting to be sent to the server.
lastSyncedAtnumber | nullTimestamp (milliseconds) of the last successful sync. null if never synced.

Status values

StatusDescription
'connected'WebSocket connection is open but initial sync has not completed.
'syncing'Actively exchanging operations with the server.
'synced'All local operations have been sent and acknowledged. Up to date.
'offline'No connection to the server. The app continues to work locally.
'error'A sync error occurred. Operations are queued and will retry.

Example

tsx
import { useSyncStatus } from '@korajs/react'

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

useCollection()

Returns a typed collection accessor for performing operations on a specific collection. This is a convenience hook when you need the collection reference inside a component without importing the app instance directly.

Signature

typescript
function useCollection<T extends CollectionRecord>(name: string): CollectionAccessor<T>

Parameters

ParameterTypeDescription
namestringCollection name as defined in the schema.

Returns

CollectionAccessor<T> -- A typed accessor with insert, update, delete, findById, where, and other query methods.

Example

tsx
import { useCollection } from '@korajs/react'

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(
  recordId: string,
  field: string
): RichTextBinding

Parameters

ParameterTypeDescription
recordIdstringID of the record containing the rich text field.
fieldstringName of the t.richtext() field on the record.

Returns

RichTextBinding

PropertyTypeDescription
yTextY.TextThe Yjs Y.Text instance for this field. Pass this to your editor's Yjs binding.
yDocY.DocThe parent Yjs document. Needed by some editor bindings.
isLoadingbooleantrue while the Yjs state is being loaded from storage.
isEmptybooleantrue if the rich text field has no content.

Example with TipTap

tsx
import { useRichText } from '@korajs/react'
import { useEditor, EditorContent } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Collaboration from '@tiptap/extension-collaboration'

function NoteEditor({ noteId }: { noteId: string }) {
  const { yText, yDoc, isLoading } = useRichText(noteId, 'content')

  const editor = useEditor({
    extensions: [
      StarterKit,
      Collaboration.configure({ document: yDoc, field: 'content' }),
    ],
  }, [yDoc])

  if (isLoading) 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.
  • 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 will see this user's presence information (name, color, and optional avatar). Presence is ephemeral -- it is not persisted, only shared with currently connected peers.

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
import { usePresence } from '@korajs/react'

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 returns remote peers. 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
import { useCollaborators } from '@korajs/react'

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
import { usePresence, useCollaborators } from '@korajs/react'

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>
      <Editor />
    </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).
  • 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).

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.com/kora' },
})

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: Partial<Todo> }) => app.todos.update(args.id, args.data)
  )

  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>
          <input
            type="checkbox"
            checked={todo.completed}
            onChange={() => updateTodo({ id: todo.id, data: { completed: true } })}
          />
          {todo.title}
        </li>
      ))}
    </ul>
  )
}