Reading Data

Queries

One-shot queries, subscriptions, framework hooks, Suspense integration, and read durability options.

One-shot queries

A one-shot query runs once against the database without subscribing to changes.

export async function readTodosOneshot(db: Db) {
  return db.all(app.todos.where({ done: false }));
}
pub async fn read_todos_oneshot(client: &JazzClient) -> jazz_tools::Result<usize> {
    let query = QueryBuilder::new("todos").build();
    let rows = client.query(query, None).await?;
    Ok(rows.len())
}

Subscriptions

Subscribe to a query to receive updates whenever the underlying data changes.

export function subscribeTodos(db: Db, onCount: (count: number) => void) {
  return db.subscribeAll(app.todos.where({ done: false }), ({ all }) => onCount(all.length));
}
pub async fn subscribe_todos(
    client: &JazzClient,
) -> jazz_tools::Result<jazz_tools::SubscriptionStream> {
    let query = QueryBuilder::new("todos").build();
    client.subscribe(query).await
}

Composing queries

Queries are immutable and chainable. Each method returns a new query, so you can store a base and reuse it for different views without side effects.

// Store a base query and reuse it for different views.
const openTodos = app.todos.where({ done: false });

const byNewest = openTodos.orderBy("id", "desc");
const byTitle = openTodos.orderBy("title", "asc").limit(20);
const urgent = openTodos.where({ title: { contains: "urgent" } });
pub fn composing_queries() {
    // Build two views from the same base conditions.
    let by_title = QueryBuilder::new("todos")
        .filter_eq("done", Value::Boolean(false))
        .order_by("title")
        .limit(20)
        .build();
    let by_newest = QueryBuilder::new("todos")
        .filter_eq("done", Value::Boolean(false))
        .order_by_desc("id")
        .build();

    let _ = (by_title, by_newest);
}

See Filters & Sorting for the full list of where operators, orderBy, limit, and offset.

Read durability

Queries and subscriptions return results as soon as they are available locally by default. Local reads are effectively instant, and remote updates stream in as they arrive, which is normally a good default. When you need a stronger guarantee for the first result, pass a tier option to fetch data from a different tier before returning.

Jazz data propagates through tiers, from fast local confirmation to broader reach.

TierWhereWhen to use
"local"On the deviceDefault. Instant writes, no network needed.
"edge"Nearest sync serverConfirm data has left the device before continuing.
"global"Central serverEnsure all clients can see the data, regardless of location.

For background on how data flows between tiers, see How Sync Works.

Choosing a tier

Pass a tier when you need the first result to be authoritative at a specific level — for example, when a user has just navigated to a page and a stale local snapshot would mislead them.

  • "local" — Local storage only. The default on browsers and clients. Fastest, but reflects only what this device has already synced.
  • "edge" — Wait for the nearest sync server to respond. The default on backends and servers. A good middle ground when you want confirmation that the query has fetched data from the network.
  • "global" — Wait for the central server. Use when you need a globally-consistent snapshot, accepting the extra round-trip latency.
export async function readTodosAtEdgeDurability(db: Db) {
  return db.all(app.todos.where({ done: false }), { tier: "edge", localUpdates: "immediate" });
}
pub async fn read_todos_at_edge_durability(client: &JazzClient) -> jazz_tools::Result<usize> {
    let query = QueryBuilder::new("todos").build();
    let rows = client
        .query(query, Some(DurabilityTier::EdgeServer))
        .await?;
    Ok(rows.len())
}

Subscriptions only gate the first result

The read tier gates the first delivery of a subscription only. After the initial snapshot arrives at the requested tier, subsequent updates are delivered as they reach the local node, regardless of which tier they've propagated to. For example, a subscription with tier: "global" guarantees a globally-consistent initial snapshot, but later incremental updates from other clients may arrive through edge tiers before being globally available even if the durability of the write is set to "global".

Local updates and propagation

Two additional options control how the subscription interacts with local writes and upstream servers:

  • localUpdates ("immediate" | "deferred", default "immediate") — With "immediate", your own local writes appear in the subscription while it's still waiting for the tier to confirm the initial snapshot (only once the subscription has settled at least once). With "deferred", all delivery is held until the tier confirms.
  • propagation ("full" | "local-only", default "full") — With "full", the subscription is sent to upstream servers, which push matching data back. With "local-only", only local storage is queried and no server communication happens.

See Durability Tiers for the full reference, including which APIs accept these options and how they compose.

Magic columns

You can select and filter on Jazz's magic columns just like other columns. They are omitted from select("*"), so opt in explicitly when you want them.

Permission introspection columns

  • $canRead — whether the current session can read the row
  • $canEdit — whether the current session can update the row
  • $canDelete — whether the current session can delete the row
  • Without a session, all three return null

Edit metadata columns

  • $createdBy — the Jazz principal that created the row
  • $createdAt — when the row was first created
  • $updatedBy — the Jazz principal that last updated the row
  • $updatedAt — when the row was last updated

These are useful for showing authorship, when a row last changed, or building "my created items" views without storing duplicate ownership fields.

export async function readTodoEditMetadata(db: Db, currentUserId: string, updatedSinceMs: number) {
  return db.all(
    app.todos
      .where({
        $createdBy: currentUserId,
        $updatedAt: { gt: updatedSinceMs },
      })
      .select("title", "$createdBy", "$createdAt", "$updatedBy", "$updatedAt"),
  );
}

See Permissions for policy examples using $createdBy and the other magic columns.

Framework hooks

Each framework has a reactive binding that re-renders when query results change. See Framework Patterns for side-by-side examples.

FrameworkAPINotes
React/ExpouseAll(query)Returns T[] | undefined
VueuseAll(query)Returns a Ref
Sveltenew QuerySubscription(query)Exposes reactive .current

The undefined loading state

useAll and QuerySubscription return undefined until the first server response arrives. After that, the value is an array — empty ([]) if no rows match, or populated with the requested data.

const allTodos = useAll(app.todos);
// allTodos is undefined while connecting, [] when loaded but empty
<script setup lang="ts">
import { useAll } from "jazz-tools/vue";
import { app } from "../schema.js";

const todos = useAll(app.todos);
// undefined = not yet connected; [] = connected, no rows; [...] = rows present
</script>

<template>
  <p v-if="todos === undefined">Connecting…</p>
  <ul v-else>
    <li v-for="todo in todos" :key="todo.id">{{ todo.title }}</li>
  </ul>
</template>
<script lang="ts">
  import { QuerySubscription } from 'jazz-tools/svelte';
  import { app } from '../schema.js';

  const todos = new QuerySubscription(app.todos);
  // .current: undefined = not yet connected; [] = connected, no rows; [...] = rows present
</script>

{#if todos.current === undefined}
  <p>Connecting…</p>
{:else}
  <ul>
    {#each todos.current as todo}
      <li>{todo.title}</li>
    {/each}
  </ul>
{/if}

You're unlikely to see undefined in practice unless you're awaiting a more durable tier. Local storage reads are effectively instant and resolve to [] if no data exists yet.

Conditional queries

Pass undefined instead of a query to skip evaluation. This is useful when building dynamic queries.

const [filter, setFilter] = useState<string | null>(null);
const filtered = useAll(filter ? app.todos.where({ title: { contains: filter } }) : undefined);
<script setup lang="ts">
import { ref, computed } from "vue";
import { useAll } from "jazz-tools/vue";
import { app } from "../schema.js";

const filter = ref<string | null>(null);
const query = computed(() =>
  filter.value ? app.todos.where({ title: { contains: filter.value } }) : undefined,
);
const filtered = useAll(query);
</script>

<template>
  <input v-model="filter" placeholder="Filter by title" />
  <ul v-if="filtered">
    <li v-for="todo in filtered" :key="todo.id">{{ todo.title }}</li>
  </ul>
</template>
<script lang="ts">
  import { QuerySubscription } from 'jazz-tools/svelte';
  import { app } from '../schema.js';

  let filter = $state<string | null>(null);

  const filtered = new QuerySubscription(
    () => filter ? app.todos.where({ title: { contains: filter } }) : undefined,
  );
</script>

<input bind:value={filter} placeholder="Filter by title" />
{#if filtered.current}
  <ul>
    {#each filtered.current as todo}
      <li>{todo.title}</li>
    {/each}
  </ul>
{/if}

React Suspense and Transitions

useAllSuspense is a React-specific variant that suspends instead of returning undefined, keeping the previous result visible while the next one loads.

App.tsx
export function ConcurrentTodoList() {
  const db = useDb();
  const [title, setTitle] = useState("");
  const [filterTitle, setFilterTitle] = useState("");
  const [showDoneOnly, setShowDoneOnly] = useState(false);
  const [page, setPage] = useState(0);
  const [isPending, startTransition] = useTransition();
  const deferredFilterTitle = useDeferredValue(filterTitle);

  let query = app.todos
    .orderBy("id", "desc")
    .limit(25)
    .offset(page * 25);

  if (deferredFilterTitle.trim()) {
    query = query.where({ title: { contains: deferredFilterTitle.trim() } });
  }
  if (showDoneOnly) {
    query = query.where({ done: true });
  }

  const isLoading = isPending || deferredFilterTitle !== filterTitle;

  function updatePage(nextPage: number) {
    startTransition(() => {
      setPage(nextPage);
    });
  }

  function handleFilterChange(e: React.ChangeEvent<HTMLInputElement>) {
    setFilterTitle(e.target.value);
    startTransition(() => {
      setPage(0);
    });
  }

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const trimmedTitle = title.trim();

    if (!trimmedTitle) {
      return;
    }

    await db.insert(app.todos, { title: trimmedTitle, done: false });
    setTitle("");
  }

  return (
    <>
      <form onSubmit={(e) => void handleSubmit(e)}>
        <input
          type="text"
          value={title}
          onChange={(e) => setTitle(e.target.value)}
          placeholder="What needs to be done?"
          required
        />
        <button type="submit">Add</button>
      </form>

      <div>
        <input
          type="text"
          value={filterTitle}
          onChange={handleFilterChange}
          placeholder="Filter by title (contains)"
          aria-label="Filter by title"
        />
        <label>
          <input
            type="checkbox"
            checked={showDoneOnly}
            onChange={(e) => setShowDoneOnly(e.target.checked)}
          />
          Done only
        </label>
      </div>

      <Suspense fallback={<p>Loading todos...</p>}>
        <div style={{ opacity: isLoading ? 0.5 : 1, transition: "opacity 0.2s" }}>
          <ConcurrentTodoResults query={query} page={page} onPageChange={updatePage} />
        </div>
      </Suspense>
    </>
  );
}

On this page