The client
Your client is a Preact component, default-exported from the file your manifest names. Xeer mounts it for you inside the provider that supplies identity and the live-update stream, so there is no entry script to write.
import { useMutation, useQuery, useState } from '@impetik/xeer/client';
import './styles.css';
export default function App() {
const notes = useQuery('notes.list', {});
const createNote = useMutation('notes.create');
const [text, setText] = useState('');
return (
<main>
<input value={text} onInput={(event) => setText(event.currentTarget.value)} />
<button onClick={() => void createNote({ text }).then(() => setText(''))}>Add</button>
{notes.loading && <p>Loading…</p>}
{notes.error && <p role="alert">{notes.error.message}</p>}
<ul>{(notes.data ?? []).map((note) => <li key={note.id}>{note.text}</li>)}</ul>
</main>
);
}JSX is Preact, wired up in the generated tsconfig.json with "jsxImportSource": "@impetik/xeer". Note
class rather than className — this is Preact, so plain HTML attribute names work.
#useQuery
function useQuery(name, input): {
data: Output | undefined;
error: Error | undefined;
loading: boolean;
refetch(): void;
}The operation name and both its input and output types come from the server you wrote — misspell the
name and your editor tells you, pass the wrong input shape and it tells you, and notes.data is typed
as your handler's return value.
input is a required second argument; pass {} for an input-less query. data is undefined on the
first render and while an error is outstanding. refetch() is synchronous and returns nothing — it
schedules a refetch rather than handing you a promise.
You will rarely need refetch, because queries refresh themselves. See Live
updates.
#useMutation
function useMutation(name): (input) => Promise<Output>;It returns a callable, not an object with a .mutate:
const createNote = useMutation('notes.create');
const removeNote = useMutation('notes.remove');
await createNote({ text: 'Water the plants' }); // resolves to the created row
await removeNote({ id: note.id });On success it invalidates the queries that read whatever tables the mutation wrote, so your own UI updates without a round trip and without a cache key to name.
On refusal it rejects with a XeerRuntimeError carrying code, status, id, and message. Handle
it as you would any rejection:
const [problem, setProblem] = useState<string | null>(null);
const add = async () => {
try {
await createNote({ text });
setProblem(null);
} catch (error) {
setProblem(error instanceof Error ? error.message : 'Something went wrong.');
}
};#useAuth
function useAuth(): {
readonly auth: AuthIdentity | null;
readonly loading: boolean;
readonly error: Error | null;
readonly isGuest: boolean;
readonly isAuthenticated: boolean;
refresh(): Promise<void>;
};auth is the same identity your server sees in ctx.auth: appUserId, kind, roles, permissions,
workspaceIds. It is null until the first check resolves, which is what loading is for.
const { auth, loading, isAuthenticated } = useAuth();
if (loading) return <p>Checking your identity…</p>;
if (!isAuthenticated) return <SignInButton />;
return <p>Signed in as {auth.appUserId}</p>;isGuest is true while auth is still null, so it means "not known to be a signed-in user" rather
than "definitely a visitor". Gate on isAuthenticated when you want the stricter reading.
Never use client-side identity as authorization. It decides what to render; the server decides what is allowed. See Auth.
#signIn and signOut
function signIn(options?: {
baseUrl?: string;
returnTo?: string;
provider?: 'google';
persona?: 'alice' | 'bob'; // local development only
workspaceIds?: readonly string[]; // local development only
}): void;
function signOut(options?: { baseUrl?: string }): Promise<void>;signIn is a full-page navigation, so nothing after it in the same function runs. signOut is a
request; it clears the session and invalidates every query, but it does not refresh useAuth by itself:
const { refresh } = useAuth();
<button onClick={() => void signOut().then(() => refresh())}>Sign out</button>persona and workspaceIds are how you switch between local test identities under xeer dev. A
deployed app ignores them and begins real sign-in on the same route, so the button you write for
development is the button that works in production.
#Ready-made auth components
For the common cases, so you do not have to build a sign-in flow to see one working:
import { AuthManagementPanel, SignInButton, UserButton } from '@impetik/xeer/client';SignInButton— starts sign-in. Takeschildren,className,returnTo.UserButton— shows the signed-in state and signs out on click; renders aSignInButtonwhen there is nobody signed in.AuthManagementPanel— a self-contained panel for the account actions a user is entitled to: revoking their sessions, exporting their data, deleting their account.
All three are plain components you can style or replace. Nothing else in the framework depends on them.
#invalidateQueries
function invalidateQueries(tables?: readonly string[]): void;Refetch everything, or only the queries known to read one of the named tables. You need this in exactly one situation: you wrote through an endpoint rather than a mutation.
const shorten = async (url: string) => {
await fetch('/api/links', { method: 'POST', body: JSON.stringify({ url }) });
invalidateQueries(['links']); // endpoint writes are not tracked
};Mutations do this for you. See Live updates.
#Preact hooks
useState is re-exported from @impetik/xeer/client for convenience. Every other hook comes from
Preact directly, which the compiler allows in client code:
import { useEffect, useMemo, useRef } from 'preact/hooks';#What client code may import
The compiler compiles your client and server as separate graphs and enforces the boundary:
| Allowed in client code | Refused |
|---|---|
@impetik/xeer/client | @impetik/xeer/server — fails with XE1202 |
@impetik/xeer/shared | your src/server.ts — fails with XE1201 |
preact, preact/hooks | |
| your own modules with no server imports |
This is why a value read from ctx.env cannot reach a browser bundle by accident: there is no import
path from the client graph to the code that can read it.
#Styling and assets
Import CSS from your client and it is bundled:
import './styles.css';Anything in public/ is served from the root of your app. There is no CSS framework, no component
library, and no opinion about how your app should look.
#Next
- Live updates — why the list above refreshes itself.
- Server functions and the database — the other half of every call here.
- Auth — what
useAuthis reading, and how sign-in works. - Local development — switching identity while you develop.