ResourceKit
Guides

Reading data

Sync the right set, then query it locally with plain TypeScript.

Reading in ResourceKit is a two-part move:

  1. 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.
  2. 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,  } = (
const data: LiveQueryState<TResult>
.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 | null

useOne(resource, id) is shorthand for useSynced(resource.one(id)).

Query state

const { data, status, coverage, isRefreshing, error } = useSynced(query);
FieldMeaning
dataThe result - always an array for where() (empty while loading), record-or-null for one()
statusloading (nothing local yet) · stale (showing local data, revalidating) · fresh (confirmed) · offline (network down, showing local data)
coveragecomplete (the local set is provably whole) · partial · unknown
isRefreshingA background refresh is in flight
errorThe 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. Use take for size, limit for 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:

lib/data/issues.ts
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),
    ),
  },
});
server - the implementation
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.

On this page