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;
useRootretains 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:
unknownmeans the root coverage query is not server-authoritative yet;completemeans the server has answered;- for
.one()queries,completeplusnullmeans 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
- The browser client -
useRoot,useQuery, optimistic writes, and the store the root query materializes against. - The API server - named query registration and authority-side AST resolution.
- Server rendering - preload the same composed root query for first paint, then hand off to the live browser client.
- Full app: the issue tracker - fragments reused across list, board, detail, and activity-feed roots.
- Supported query shapes - what
select,sub,include, andcountAsexpress today.