Skip to main content

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:

OptionDefaultWhat it does
select*Columns to return, as a comma string like "title,done"
filternoneFilter object, see Filters
ordernone"col:desc", comma-separated for multiple columns
limit20Rows per fetch, maximum 100
offset0Rows to skip, for pagination
enabledtrueSet false to hold the query until you are ready
staleTimeHow long a cached result stays fresh before refetching
keepPreviousDatatrueShow the last result while the next one loads
placeholderDatanoneData to show before the first fetch resolves
retry2Retry attempts on failure
retryDelay400msDelay between retries
refetchIntervaloffPoll on an interval, in milliseconds
refetchOnWindowFocusfalseRefetch when the tab regains focus
refetchOnReconnectfalseRefetch when the network comes back

The result shape is always the same:

FieldTypeNotes
dataarrayAlways an array, never null. Empty while loading or when nothing matches
countnumberTotal matching rows, ignoring limit
loadingbooleanTrue while a fetch is in flight
errorDataError | nullSee error handling below
refetchfunctionManually re-run the query
403 is not a bug

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 pass id, created_at, or updated_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.