Reading data
Sync the right set, then query it locally with plain TypeScript.
Reading in ResourceKit is a two-part move:
where(...)picks the set to sync. Its filter is simple on purpose - equality, lists, ranges - because its only job is getting the right records onto the device.- Everything chained after it runs locally. Predicates, sorting, limits, joins - plain TypeScript over data that's already there.
const { data } = useSynced(
issues
.where({ workspaceId: "w1" }) // synced from the server
.filter((i) => i.title.includes(searchText)) // local - any predicate
.orderBy("score", "desc") // local - any field
.limit(50), // local
);Type the search box all you want: the synced set doesn't change, so the request counter doesn't move. The narrowing happens in memory, instantly, on every keystroke.
And everything stays typed from the schema down - hover it yourself:
const { data, } = ( .where({ : "w1", : "open" }),
);The sync filter
where() accepts equality (as a shorthand), in, and range conditions:
issues.where({ workspaceId: "w1" }); // equality
issues.where({ status: { in: ["open", "closed"] } }); // membership
issues.where({ score: { gte: 50, lt: 100 } }); // ranges
issues.where({ workspaceId: "w1", status: "open" }); // AND of fields
issues.where(); // everything (careful!)Fields set to undefined are dropped, which makes optional UI filters pleasant:
// status: "all" simply means "no constraint"
issues.where({
workspaceId,
status: statusFilter === "all" ? undefined : statusFilter,
});Narrower queries of a synced set are free. Once { workspaceId: "w1" } has been synced, ResourceKit can prove that { workspaceId: "w1", status: "open" } is already complete locally - and answers it without the network. Sync the broad set a screen needs once; every narrower view is instant.
Single records
const { data: issue } = useOne(issues, issueId);
// data: Issue | nulluseOne(resource, id) is shorthand for useSynced(resource.one(id)).
Query state
const { data, status, coverage, isRefreshing, error } = useSynced(query);| Field | Meaning |
|---|---|
data | The result - always an array for where() (empty while loading), record-or-null for one() |
status | loading (nothing local yet) · stale (showing local data, revalidating) · fresh (confirmed) · offline (network down, showing local data) |
coverage | complete (the local set is provably whole) · partial · unknown |
isRefreshing | A background refresh is in flight |
error | The last non-network refresh error, if any |
A common pattern - skeleton only when there's truly nothing to show:
if (status === "loading") return <Skeleton />;
return <List items={data} dimmed={status === "offline"} />;Large sets: windows with take()
Some sets are too big to sync whole - a workspace with 100k tasks doesn't belong on a phone. take() windows the synced set itself: only the top n records by the given order travel and are cached.
// Sync only the 50 most recently updated issues
issues.where({ workspaceId }).take(50, "updatedAt", "desc");Two things to know:
take()windows what gets synced;.limit()trims what gets shown from an already-synced set. Usetakefor size,limitfor presentation.- A windowed set honestly reports
coverage: "partial"forever - records beyond the window may always exist.
The server also enforces a hard cap (default 1000 rows per read). An unwindowed query that exceeds it fails loudly with a result_limit error rather than silently truncating - the fix is always to narrow the filter or add take().
Server-side reads: named queries
Some reads shouldn't be a filter at all: full-text search, aggregates, reports, calls to external APIs. Declare them on the resource as named queries - typed input, typed output, implemented with real code on the server:
import { namedQuery } from "resourcekit";
export const issues = resource("issues", {
schema: IssueSchema,
queries: {
search: namedQuery(
z.object({ workspaceId: z.string(), text: z.string() }),
z.array(IssueSchema),
),
},
});resources: {
issues: {
backbone: drizzleBackbone(db, issuesTable),
access: byWorkspace,
queries: {
search: async ({ input, ctx }) => {
// any server code: SQL, search engines, external APIs …
return db.select().from(issuesTable)
.where(ilike(issuesTable.title, `%${input.text}%`));
},
},
},
},On the client it's just another query - it works with useSynced, caches its results (repeating the same search is free until refreshed), and array results support local refinements:
const { data } = useSynced(
issues.queries.search({ workspaceId, text }).filter((hit) => !hit.archived),
);Input and output are validated on both sides against the declared schemas, so the types you see are the types you get.
One-shot reads
Outside React - or whenever you don't need reactivity - read once with engine.query():
const open = await appData.query(issues.where({ workspaceId, status: "open" }));It answers from the cache when the result is provably complete, asks the server otherwise, and falls back to local data when offline.