Data API
The @instroc/data package is how your app reads and writes its database. It gives you useQuery for reads, useMutation for writes, and a couple of variants for on-demand and paginated fetching. This page documents every hook, option, and return value, along with how errors behave so you can handle them the way the platform expects.
useQuery
useQuery(table, options) fetches rows from a table and keeps the result cached. It runs automatically when the component mounts and refetches when its inputs change.
import { useQuery } from "@instroc/data";
const { data, count, loading, error, refetch } = useQuery("tasks", {
filter: { done: false },
order: "created_at:desc",
limit: 20,
});
All options are optional. The full set:
| Option | Default | What it does |
|---|---|---|
select | * | Columns to return, as a comma string like "title,done" |
filter | none | Filter object, see Filters |
order | none | "col:desc", comma-separated for multiple columns |
limit | 20 | Rows per fetch, maximum 100 |
offset | 0 | Rows to skip, for pagination |
enabled | true | Set false to hold the query until you are ready |
staleTime | How long a cached result stays fresh before refetching | |
keepPreviousData | true | Show the last result while the next one loads |
placeholderData | none | Data to show before the first fetch resolves |
retry | 2 | Retry attempts on failure |
retryDelay | 400ms | Delay between retries |
refetchInterval | off | Poll on an interval, in milliseconds |
refetchOnWindowFocus | false | Refetch when the tab regains focus |
refetchOnReconnect | false | Refetch when the network comes back |
The result shape is always the same:
| Field | Type | Notes |
|---|---|---|
data | array | Always an array, never null. Empty while loading or when nothing matches |
count | number | Total matching rows, ignoring limit |
loading | boolean | True while a fetch is in flight |
error | DataError | null | See error handling below |
refetch | function | Manually re-run the query |
A 403 response means the table's security rules denied access, which is the expected outcome for, say, an anonymous visitor querying a private table. Render an empty state, not an error banner.
useLazyQuery
useLazyQuery has the same options and result shape as useQuery, but it does not run on mount. You trigger it yourself, which is useful for search boxes and on-demand lookups.
useInfiniteQuery
useInfiniteQuery(table, { pageSize }) handles "load more" lists. It fetches pages of pageSize rows (default 20) and appends them to data as you request more. Use it instead of managing offset by hand when building feeds and long lists.
useMutation
useMutation(table, options) returns three write methods plus state. All three throw on failure, so wrap calls in try/catch when you need to react to errors inline.
const { insert, update, remove, loading, isPending, variables, error } =
useMutation("tasks");
insert(data | data[], returning?)returns{data, count}. It accepts one row or an array, and returns the full inserted rows. Never passid,created_at, orupdated_at; the backend sets them.update(filter, data, returning?)returns{data, count}. The first argument is a filter object choosing which rows to change.remove(filter)returns{count}.
Options: onSuccess, onError, onSettled, invalidates (an array of extra table names to refresh), and optimistic (boolean, default false).
Successful mutations automatically refresh every query on the same table, so you never call refetch after a write. Use invalidates only when a write to one table should also refresh queries on another.
Errors
Queries surface failures through the error field; mutations throw. Both give you a DataError with a .status property carrying the HTTP status code, which is how you tell an access denial (403) apart from a real failure.
try {
await insert({ title });
} catch (e) {
if (e.status === 403) {
// access denied by the table's security rules
}
}
Example: a task list
Here is a realistic component combining a query, an insert, and an optimistic update for the done toggle.
import { useState } from "react";
import { useQuery, useMutation } from "@instroc/data";
function TaskList() {
const [title, setTitle] = useState("");
const { data: tasks, loading } = useQuery("tasks", {
order: "created_at:desc",
});
const { insert, isPending } = useMutation("tasks");
const { update } = useMutation("tasks", { optimistic: true });
async function addTask() {
if (!title.trim()) return;
try {
await insert({ title, done: false });
setTitle("");
} catch (e) {
// surface the failure to the user
}
}
if (loading) return <p>Loading tasks...</p>;
return (
<div>
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<button onClick={addTask} disabled={isPending}>
Add
</button>
<ul>
{tasks.map((task) => (
<li key={task.id}>
<label>
<input
type="checkbox"
checked={task.done}
onChange={() =>
update({ id: task.id }, { done: !task.done })
}
/>
{task.title}
</label>
</li>
))}
</ul>
</div>
);
}
The optimistic update flips the checkbox immediately and rolls back if the write fails, and the list refreshes on its own after each mutation. For everything you can put in a filter, continue to Filters. For who can read and write each table, see Security rules.