Build a synced app

Compose the UI with fragments

Let each component declare the data it renders, then root the whole screen in one live coverage query with useRoot - no request waterfall.

View as Markdown

You’ve seen useQuery open one live query. A real screen is usually a component tree: a route, a list, each row, and the badge inside a row. If every component opens its own query, the screen resolves in stages. The parent loads, then the row loads, then the badge loads. That is a request waterfall.

Fragments are the Rindle way to keep component ownership without that waterfall:

  • each component declares the fields and relationships it renders;
  • the route roots the composed tree with one named query;
  • useRoot retains that broad server coverage query;
  • children receive opaque refs and read narrow local views with useFragment.

No child opens a new server subscription just to render data the root already covered.

A Fragment Is A Named Selection

A fragment is a reusable selection over one table. Put it in a React-free *.queries.ts module beside the component that renders it:

// UserBadge.queries.ts
import { defineFragment } from "@rindle/client";
import type { FragmentRef } from "@rindle/client";
import { user } from "../../shared/app-def.ts";

export const UserBadgeFragment = defineFragment(user, (u) => u.select("id", "name"));
export type UserBadgeRef = FragmentRef<typeof UserBadgeFragment>;

The component receives an opaque FragmentRef and reads the fragment’s data with useFragment:

// UserBadge.tsx
import { useFragment } from "@rindle/react";
import { UserBadgeFragment, type UserBadgeRef } from "./UserBadge.queries.ts";

export function UserBadge({ user }: { user: UserBadgeRef }) {
  const data = useFragment(UserBadgeFragment, user);
  if (!data) return null;
  return <span>{data.name}</span>;
}

useFragment does not open a server query. It opens a narrow local-only read for that exact fragment and keeps the root coverage lease alive while mounted. It returns null until the covered row is available locally.

Name Your Joins Once

Fragments often cross the same joins: issue -> owner, issue -> tags, comment -> author. Declare those joins once with defineRelationships:

// shared/app-def.ts
import { defineRelationships, rel } from "@rindle/client";

export const rels = defineRelationships({
  issueOwner: rel(issue, user, { ownerId: "id" }),
  issueTags: rel(issue, tag, { id: "issueId" }),
  issueComments: rel(issue, comment, { id: "issueId" }),
  commentAuthor: rel(comment, user, { authorId: "id" }),
});

You can still spell a correlation inline with sub(alias, table, { parent, child }, ...); named relationships just keep common joins typo-proof.

Compose Children With sub

Use sub to put related rows under an alias. Passing a fragment directly creates child refs in the React-facing local data:

const IssueOwnerFragment = defineFragment(issue, (i) =>
  i
    .select("id", "title", "ownerId")
    .sub("owner", rels.issueOwner, UserBadgeFragment),
);
// local fragment data: { id, title, ownerId, owner: UserBadgeRef[] }

That is the usual shape for a child component that owns its own read:

const owner = data.owner?.[0];
return owner ? <UserBadge user={owner} /> : data.ownerId;

If the parent owns the child payload, use an inline builder instead. This is useful for small rows such as tag chips where the parent sorts the list and renders the values directly:

export const TagChipFragment = defineFragment(tag, (t) => t.select("id", "name"));

export const IssueChromeFragment = defineFragment(issue, (i) =>
  i
    .select("id", "title", "status", "priority", "ownerId")
    .sub("owner", rels.issueOwner, UserBadgeFragment)
    .sub("tags", rels.issueTags, (t) => t.include(TagChipFragment).orderBy("name", "asc")),
);
// owner is refs; tags is inline TagChip data

A child fragment can cross joins of its own — a comment card renders its author with the same UserBadgeFragment the issue row uses:

export const CommentCardFragment = defineFragment(comment, (c) =>
  c
    .select("id", "body", "createdAt")
    .sub("author", rels.commentAuthor, UserBadgeFragment),
);
export type CommentCardRef = FragmentRef<typeof CommentCardFragment>;

To keep child refs and shape the edge (ordering, a deterministic tiebreak), pass the fragment and then an edge builder — IssueDetailCardFragment in the next section orders its comment thread exactly this way.

Share Same-Row Data With include

Use include to merge another fragment on the same table. This is how a list card and a detail pane can share the same issue chrome:

import type { FragmentRef } from "@rindle/client";

export const IssueCardFragment = defineFragment(issue, (i) =>
  i
    .include(IssueChromeFragment)
    .select("updatedAt")
    .countAs("commentCount", rels.issueComments),
);
export type IssueCardRef = FragmentRef<typeof IssueCardFragment>;

export const IssueDetailCardFragment = defineFragment(issue, (i) =>
  i
    .include(IssueChromeFragment)
    .sub("comments", rels.issueComments, CommentCardFragment, (c) =>
      c.orderBy("createdAt", "asc").orderBy("id", "asc"),   // the fragment + edge-builder form
    ),
);

include is order-independent. If two fragments spread the same alias with conflicting relationship shapes, Rindle throws instead of silently choosing one. An included fragment may not add root where, orderBy, or limit; the named root query owns filtering and windowing.

(countAs(alias, rel) above attaches a live correlated count as a scalar number — an empty child reads 0 — maintained incrementally as children come and go, never re-counted.)

Root The Tree With useRoot

Fragments are inert until a named query includes the top fragment:

// IssueListItem.queries.ts
import { defineQuery, newQueryBuilder } from "@rindle/client";
import { schema } from "../../shared/app-def.ts";

const q = newQueryBuilder(schema);

// The validator runs on BOTH tiers, against untrusted args — validate hard.
const validateIssuesPageArgs = (raw: unknown): { limit: number } => {
  const limit = (raw as { limit: number }).limit;
  if (!Number.isInteger(limit) || limit < 1 || limit > 1000) throw new Error("bad limit");
  return { limit };
};

export const issuesPageQuery = defineQuery(
  "issuesPage",
  validateIssuesPageArgs,
  ({ limit }: { limit: number }) =>
    q.issue.orderBy("createdAt", "desc").limit(limit).include(IssueCardFragment),
);

The browser and the API server import the same defineQuery value. Calling it validates args and stamps the query with its wire identity; the authority validates the same args before resolving the authoritative AST.

Two habits worth copying: a query with no args needs no validator — the two-argument form defineQuery("rooms", () => q.room.orderBy(…)) is complete — and every windowed query wants a deterministic tiebreak (a final .orderBy("id", "asc") after the primary sort) so paging is stable when the sort column ties.

In React, prefer useRoot for a fragment-rooted screen:

import { fragmentKey, useRoot } from "@rindle/react";
import { IssueCardFragment, issuesPageQuery } from "./IssueListItem.queries.ts";
import { IssueListItem } from "./IssueListItem.tsx";

function IssueList() {
  const [issues, { status }] = useRoot(issuesPageQuery, { limit: 50 }, IssueCardFragment);
  const loading = status !== "complete" && issues.length === 0;

  if (loading) return <p>Loading issues...</p>;

  return (
    <ul>
      {issues.map((issue) => (
        <IssueListItem key={fragmentKey(issue)} issue={issue} />
      ))}
    </ul>
  );
}

Passing the fragment as the final argument switches useRoot into root-ref mode: the route gets IssueCardRef[], and each row component reads its own fragment:

import { useFragment } from "@rindle/react";
import { IssueCardFragment, type IssueCardRef } from "./IssueListItem.queries.ts";

function IssueListItem({ issue }: { issue: IssueCardRef }) {
  const data = useFragment(IssueCardFragment, issue);
  if (!data) return null;

  const owner = data.owner?.[0];

  return (
    <li>
      {data.title} - {data.commentCount} comments
      {owner ? <UserBadge user={owner} /> : data.ownerId}
      {data.tags.map((tag) => <TagChip key={tag.id} tag={tag} />)}
    </li>
  );
}

Use root-ref mode when the route only needs to hand rows to child components. (A query that takes no args drops the args slot: useRoot(roomsQuery, RoomCardFragment).)

If the route owns the root row itself, omit the fragment argument. Here issueDetailQuery is a second named query — a singular (.one()) root over the detail fragment. Args are whatever shape its validator accepts; this one takes the bare issue id:

// IssueDetail.queries.ts
export const issueDetailQuery = defineQuery(
  "issueDetail",
  (raw): string => {
    if (typeof raw !== "string" || raw.length === 0) throw new Error("bad issue id");
    return raw;
  },
  (id: string) => q.issue.where("id", "=", id).one().include(IssueDetailCardFragment),
);
function IssueDetail({ issueId }: { issueId: string }) {
  const [issue, { status }] = useRoot(issueDetailQuery, issueId);

  if (!issue) {
    return <p>{status === "complete" ? "Issue not found." : "Loading issue..."}</p>;
  }

  return (
    <section>
      <h1>{issue.title}</h1>
      {issue.comments.map((comment) => (
        <CommentCard key={fragmentKey(comment)} comment={comment} />
      ))}
    </section>
  );
}

Without a root fragment argument, useRoot returns the query’s local React-facing data. Any child relationship composed with a fragment still becomes refs; inline child builders remain inline data. (CommentCard reads its ref with useFragment(CommentCardFragment, comment), exactly like UserBadge above.)

Loading And Masking

useRoot returns [data, details]. details.status is the server coverage state:

  • unknown means the root coverage query is not server-authoritative yet;
  • complete means the server has answered;
  • for .one() queries, complete plus null means not found.

useFragment returns null until its covered local row is available. In SSR, preloaded root snapshots let useRoot and descendant useFragment calls render from the same first-paint seed.

The moment a fragment names columns with select(...), its data type narrows to exactly those columns. A fragment with no select keeps the full row. Child fragments passed directly to sub are opaque refs, so the parent cannot accidentally read child-owned fields. The selected columns are also the columns the root coverage query asks the authority to sync.

Keep Query Modules React-Free

*.queries.ts modules should import @rindle/client, schema/relationship definitions, validators, and sibling fragments. They should not import .tsx components. This lets the browser, API authority, and SSR loader import the same named queries without dragging React into the server graph.

That shared module graph is what keeps first paint and live hydration aligned: the route loader preloads the same named root query that useRoot retains after hydration.

Next Steps