SDK Hooks

React hooks for querying data, running mutations, and reading auth state.

Hooks are Client Component code. The package ships "use client" in its bundles, so importing it does not turn your file into a Client Component — the file that calls a hook still needs its own "use client".

useUser

Returns the current authenticated user, or null if signed out. isLoading is true during the initial session rehydration — use it to avoid flashing a logged-out UI on page reload.

example.tsx
import { useUser } from "@weirdscience/based-client";

function Profile() {
  const { user, isLoading } = useUser();

  if (isLoading) return <Spinner />;
  if (!user) return <LoginForm />;

  return <p>{user.email} ({user.role})</p>;
}
FieldTypeDescription
user{ id, email, role } | nullCurrent user or null. role is "owner" | "member"
isLoadingbooleanTrue during session hydration on mount
errorHookError | nullAlways null today — the hook reads client state, it never fetches

useQuery

Fetch records from a table with optional filtering, ordering, and pagination. Pass your Tables type and a table name as generics for full type inference (see based typegen).

example.tsx
import type { Tables } from "./based";
import { useQuery } from "@weirdscience/based-client";

function PostList() {
  const { data, total, isLoading, error, refetch } = useQuery<Tables, "posts">(
    "posts",
    {
      filter: { status: "published" },
      order: "createdAt.desc",
      limit: 10,
      offset: 0,
    },
  );

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>{error.code}: {error.message}</p>;

  return (
    <>
      <p>{total} posts</p>
      {data?.map((post) => (
        <div key={post.id}>{post.title}</div>
      ))}
      <button onClick={refetch}>Refresh</button>
    </>
  );
}

Options

OptionTypeDescription
filterPartial<Record<keyof Row, string | number | boolean>>Column filters as key-value pairs. An unknown column is a 400 VALIDATION_ERROR
order`${keyof Row}.asc` | `${keyof Row}.desc`Sort order. Defaults to id.asc so pagination stays stable
limitnumberMax rows to return. Defaults to 20, capped at 100
offsetnumberNumber of rows to skip
enabledbooleanWhen false the request never fires: data stays null and isLoading is false. Defaults to true

Gate queries behind auth or any boolean to avoid a 403 while signed out:

example.tsx
const { user } = useUser();
const { data } = useQuery("notes", { enabled: !!user });
// Nothing is fetched until user is truthy.
// While disabled: data is null and isLoading is false.

Queries refetch on their own when the signed-in user changes, so signing out clears the previous user's rows. Responses that arrive out of order are discarded — only the newest request can write to data.

Return value

FieldTypeDescription
dataRow[] | nullRows, or null before the first response and while disabled
totalnumberTotal matching records (before pagination)
isLoadingbooleanTrue while fetching
errorHookError | null{ code, message, status?, details? } — not an Error instance
refetch() => voidRe-run the query

useRecord

Fetch a single record by id via GET /api/:table/:id. Unlike useQuery, data is a single row or null — not an array.

example.tsx
import type { Tables } from "./based";
import { useRecord } from "@weirdscience/based-client";

function Post({ id }: { id: string }) {
  const { data: post, isLoading } = useRecord<Tables, "posts">("posts", id);

  if (isLoading) return <Spinner />;
  if (!post) return <NotFound />;

  return <h1>{post.title}</h1>;
}

Pass null or undefined as the id to skip the query — no need for a separate enabled flag:

example.tsx
const [selectedId, setSelectedId] = useState<string | null>(null);
const { data } = useRecord("posts", selectedId);
// No fetch until setSelectedId is called.

Perfect for deterministic keys like ${userId}:${key} paired with PUT upsert:

example.tsx
const { data: prefs } = useRecord("preferences", `${user.id}:theme`);

Return value

FieldTypeDescription
dataRow | nullThe record, or null if not found or skipped
isLoadingbooleanTrue while fetching
errorHookError | null{ code, message, status?, details? } — not an Error instance
refetch() => voidRe-run the query

useMutation

Create, update, upsert, or delete records. The operation parameter picks the behavior. Pass <Tables, "tableName"> for typed mutation payloads.

mutate resolves to the row on success and to null on failure — it never throws, so an onClick handler cannot produce an unhandled rejection. Read the returned value, or the hook's error, instead of wrapping the call in try/catch.

example.tsx
import type { Tables } from "./based";
import { useMutation } from "@weirdscience/based-client";

function CreatePost() {
  const { mutate, isLoading, error } = useMutation<Tables, "posts">("posts", "create");

  async function onCreate() {
    const created = await mutate({ title: "New Post", status: "draft" });
    if (!created) return; // error holds { code, message, status?, details? }
    console.log(created.id);
  }

  return (
    <>
      <button disabled={isLoading} onClick={onCreate}>Create</button>
      {error && <p>{error.message}</p>}
    </>
  );
}

function UpdatePost({ id }: { id: string }) {
  const { mutate } = useMutation<Tables, "posts">("posts", "update");

  return (
    <button onClick={() => mutate({ id, status: "published" })}>
      Publish
    </button>
  );
}

function DeletePost({ id }: { id: string }) {
  const { mutate } = useMutation<Tables, "posts">("posts", "delete");

  return (
    <button onClick={() => mutate({ id })}>
      Delete
    </button>
  );
}

function SavePreference({ userId }: { userId: string }) {
  // Upsert — creates if missing, updates if present. Perfect for
  // client-chosen ids like `${userId}:theme`.
  const { mutate } = useMutation<Tables, "preferences">("preferences", "upsert");

  return (
    <button onClick={() => mutate({ id: `${userId}:theme`, value: "dark" })}>
      Use dark mode
    </button>
  );
}

Operations

Operationmutate(data)Notes
create{ ...fields }POST. All required columns must be provided; unknown keys are a 400
update{ id, ...fields }PUT, so it upserts — creates if missing (201), updates if present (200)
upsert{ id, ...fields }Same request as update; reads better when the caller picks the id
delete{ id }DELETE. id is required

Return value

FieldTypeDescription
mutate(data) => Promise<Row | null>Runs the write. Resolves to null on failure; never throws
isLoadingbooleanTrue while the mutation is in flight
errorHookError | null{ code, message, status?, details? } — not an Error instance