Kizaki
Guides

Realtime

Build live React screens with query objects instead of hand-written subscription plumbing.

The recommended browser stack is:

  • @kizaki/schema for typed entities
  • @kizaki/sdk/browser for query builders
  • @kizaki/react for useQuery() and useMutation()

The approach is query-centered. You build a query that describes the data your screen needs, and the platform keeps that data current over a WebSocket connection. There is no separate subscription schema, no channel configuration, and no duplication of your authorization rules.

Live Queries

Build a query, pass it to useQuery(), and the result stays current as the underlying data changes.

import { select } from "@kizaki/sdk/browser";
import { useQuery, useMutation } from "@kizaki/react";
import { sendMessage } from "@kizaki/client";
import { Message } from "@kizaki/schema";

const messagesQuery = select(Message)
  .fields(Message.id, Message.text, Message.authorId, Message.createdAt)
  .orderBy(Message.createdAt, "desc")
  .limit(50);

export function MessageList({ channelId }: { channelId: string }) {
  const { data: messages, status, error } = useQuery(messagesQuery);
  const send = useMutation(sendMessage, { invalidate: [messagesQuery] });

  if (status === "loading") return <p>Loading messages...</p>;
  if (error) return <p>{error.toUserMessage()}</p>;

  return (
    <div>
      <ul>
        {messages.map((m) => (
          <li key={m.id}>
            <span>{m.text}</span>
          </li>
        ))}
      </ul>
      <button onClick={() => send.mutate({ channelId, text: "Hello" })}>
        Send
      </button>
    </div>
  );
}

The same authorization model applies to one-shot reads and live queries. If a user can read a row, they receive updates about it. If they lose access, the row disappears from the result.

Presence

usePresence() tracks connected users on a channel or entity instance. It returns a { count } object that updates as users join and leave.

import { usePresence } from "@kizaki/react";

function ViewerCount({ docId }: { docId: string }) {
  const { count } = usePresence("document", docId);
  return <span>{count} viewing</span>;
}

Scope presence to either a named channel (usePresence("dashboard")) or an entity and ID pair (usePresence("document", docId)). The count reflects the number of distinct browser connections currently subscribed to that scope.

Broadcast

useBroadcast() sends and receives ephemeral messages on a channel or entity instance. Broadcasts are not persisted and do not flow through the database. Use them for cursors, typing indicators, and other transient signals.

import { useBroadcast } from "@kizaki/react";

function CursorSync({ docId }: { docId: string }) {
  const { send, lastMessage } = useBroadcast("document", docId);
  // send({ type: "cursor", x, y }) on mousemove
  // lastMessage contains the most recent broadcast from any peer
}

Like presence, broadcast scopes to a named channel or an entity-ID pair. Messages are delivered to all other connected clients on the same scope.

Optimistic Updates

useMutation accepts an optimistic callback that applies changes to the local query cache before the server confirms the write. If the server rejects the mutation, the optimistic update rolls back automatically.

const send = useMutation(sendMessage, {
  invalidate: [messagesQuery],
  optimistic: (cache, { channelId, text }) => {
    cache.updateQuery(messagesQuery, (prev) => [
      { id: crypto.randomUUID(), text, authorId: "me", createdAt: new Date() },
      ...prev,
    ]);
  },
});

cache.updateQuery() receives the current cached data and returns the new shape. On success, the server response replaces the optimistic data. On failure, the cache reverts to its previous state.

useQuery vs. useSubscription

Both hooks provide live data. They serve different purposes.

useQuery is the default choice. It returns { data, status, error, refetch } and manages the full lifecycle: fetching, caching, and live updates. Most screens should use useQuery.

useSubscription is always live and exposes an unsubscribe() function for explicit lifecycle control. Use it when you need to start and stop listening based on user actions rather than component mount/unmount.

const { data, unsubscribe } = useSubscription(messagesQuery);
// call unsubscribe() when the user navigates away or pauses the feed

For the majority of screens, useQuery is the right tool.

Performance Guidance

Live queries work best when they are narrow and intentional.

  • Use .fields() to select only the columns your screen renders. Fewer fields mean smaller payloads on every update.
  • Use .limit() for feeds and activity lists. A live query over the last 50 messages is efficient. A live query over an unbounded table is not.
  • Reserve live queries for screens that change frequently: chat interfaces, activity feeds, collaborative dashboards, notification lists.
  • For static or rarely-changing pages, use { live: false } on useQuery or a one-shot fetch() instead. This avoids holding a WebSocket subscription for data that does not benefit from it.
// One-shot fetch, no live updates
const { data } = useQuery(projectQuery, { live: false });

Keep live queries focused on the data your users actually watch change. Fetch everything else once.

Why This Is The Default

  • Your read model is reused across initial fetch and live updates.
  • Authorization stays in the schema, not in client-side subscription code.
  • Query invalidation stays explicit in UI code.
  • The same query object describes what the UI wants, what the user is allowed to see, and what should stay current over time.

On this page