Skip to content
Guides contents

GuidesQueries & schemas

Compose the UI with fragments

Define component data requirements, combine them into named queries, and subscribe through useRoot and useFragment.

View as Markdown

A fragment describes the columns and relationships that a component reads. A query combines fragments with .include() and .sub(). React components receive references to rows, then read their fragments with useFragment.

In a synced app, the root query requests the data for the component tree. Each fragment reader opens a separate local read and retains that same root query. It does not send a separate query for each row.

Run a small example

This independent example uses an in-memory browser store with two tables. It needs no API server or SQL migration. It does not use the schema from the synced-app quickstart.

Create a Vite React project:

pnpm create vite fragment-demo --template react-ts
cd fragment-demo
pnpm install
pnpm add @rindle/client @rindle/wasm @rindle/react

Define the tables, fragments, and named query:

// shared/fragments.ts
import {
  createSchema, defineFragment, defineQuery, newQueryBuilder, string, table,
} from "@rindle/client";
import type { FragmentRef } from "@rindle/client";

export const issue = table("issue")
  .columns({ id: string(), title: string() })
  .primaryKey("id");

export const comment = table("comment")
  .columns({ id: string(), issueId: string(), body: string() })
  .primaryKey("id");

export const schema = createSchema({ tables: [issue, comment] });
export const q = newQueryBuilder(schema);

export const CommentFragment = defineFragment(comment, (c) =>
  c.select("id", "body"),
);

export const IssueCardFragment = defineFragment(issue, (i) =>
  i.select("id", "title").sub(
    "comments",
    comment,
    { parent: ["id"], child: ["issueId"] },
    CommentFragment,
    (comments) => comments.orderBy("id", "asc"),
  ),
);

export type CommentRef = FragmentRef<typeof CommentFragment>;
export type IssueCardRef = FragmentRef<typeof IssueCardFragment>;

export const issueCardsQuery = defineQuery("issueCards", () =>
  q.issue.orderBy("id", "asc").limit(20).include(IssueCardFragment),
);

The correlation joins issue.id to comment.issueId. The final .sub() callback orders the comments within each issue. issueCardsQuery includes both fragments in one query definition. Defining it performs no I/O.

Create the browser store and its initial rows:

// src/local-store.ts
import { createWasmStore, initWasm } from "@rindle/wasm";
import wasmUrl from "@rindle/wasm/pkg/rindle_bg.wasm?url";
import { schema } from "../shared/fragments.ts";

await initWasm(wasmUrl);
export const store = await createWasmStore(schema);

await store.write((tx) => {
  tx.add("issue", { id: "i1", title: "Ship the example" });
  tx.add("comment", { id: "c1", issueId: "i1", body: "Add a screenshot." });
  tx.add("comment", { id: "c2", issueId: "i1", body: "Review the instructions." });
});

The ?url import lets Vite serve the WASM asset. The store holds these rows in memory. Reloading the page creates the example again.

Read fragments in React

useRoot(query, fragment) returns row references and query status. useFragment(fragment, ref) reads one reference from the local store. Nested fragment relationships also return references.

// src/IssueCards.tsx
import { fragmentKey, useFragment, useRoot } from "@rindle/react";
import {
  CommentFragment, IssueCardFragment, issueCardsQuery,
} from "../shared/fragments.ts";
import type { CommentRef, IssueCardRef } from "../shared/fragments.ts";

export function CommentView({ comment }: { comment: CommentRef }) {
  const data = useFragment(CommentFragment, comment);
  if (data === null) return null;
  return <li>{data.body}</li>;
}

function IssueCard({ issue }: { issue: IssueCardRef }) {
  const data = useFragment(IssueCardFragment, issue);
  if (data === null) return null;
  return (
    <article>
      <h2>{data.title}</h2>
      <ul>
        {data.comments.map((comment) => (
          <CommentView key={fragmentKey(comment)} comment={comment} />
        ))}
      </ul>
    </article>
  );
}

export function IssueCards() {
  const [issues, { status }] = useRoot(issueCardsQuery, IssueCardFragment);
  if (issues.length === 0) {
    return <p>{status === "complete" ? "No issues." : "Loading issues…"}</p>;
  }
  return issues.map((issue) => (
    <IssueCard key={fragmentKey(issue)} issue={issue} />
  ));
}

References are opaque tokens. They are not projected rows or database IDs. Use fragmentKey(ref) for a React key. Do not construct a reference by hand. A fragment read can return null if its row is absent or deleted.

Replace the Vite entry point:

// src/main.tsx
import { createRoot } from "react-dom/client";
import { Rindle } from "@rindle/react";
import { IssueCards } from "./IssueCards.tsx";
import { store } from "./local-store.ts";

const container = document.getElementById("root");
if (!container) throw new Error("Missing #root element");

createRoot(container).render(
  <Rindle store={store}>
    <IssueCards />
  </Rindle>,
);

Run pnpm dev. The page shows one issue and two comments.

Use the same pattern in a synced app

For a synced app, use its generated table definitions and its client store. Register issueCardsQuery in the API server’s named-query registry. The synced-app quickstart defines that server and registration step. Your SQL tables must contain the columns used by the fragments.

The named query provides coverage: the rows and fields the server keeps available locally. The root and its fragment readers share this coverage subscription. Each reader still has its own local materialized view.

Local rows can render before the server confirms coverage. status === "unknown" means coverage is not yet confirmed. It does not mean that every visible row is absent or invalid. Use status === "complete" before presenting an empty result as authoritative.

Fragments organize reads. Authorization still belongs in the API server.

Choose the result shape

API React result
useQuery(query) Projected rows, including nested row data
useRoot(query) Root row data, with nested fragment relationships represented as references
useRoot(query, fragment) References for the root fragment
useFragment(fragment, ref) One fragment’s data, or null

An inline .sub() builder returns nested data rather than fragment references. Use a named fragment in .sub() when child components need separate local reads. Use .one() on the root query for a single result instead of an array.

Continue with fine-grained reactivity to see which edits update each component. For route loading and server rendering, see preloads and SSR.