Loaders & server actions
JORVEL splits data into two symmetric primitives: defineLoader for reads and defineAction for mutations. Reads run before render and hydrate without a second fetch; mutations carry pending / error / result state. The React hooks — useAction and useFormAction — add the state machine expected of React 19 form actions.
Loaders (reads)
defineLoader lives in @jorvel/ssr. It registers a keyed loader that runs before render; components read the result via useLoaderData<T>(key) with no client refetch. See the SSR docs for the full loader lifecycle; on the client, the Suspense-friendly useRemoteData covers ad-hoc reads.
import { defineLoader, useLoaderData } from '@jorvel/ssr';
export const userLoader = defineLoader<User>({
key: 'user',
load: async ({ params, request }) => {
const res = await fetch(new URL('/api/users/' + params.id, request.url));
if (!res.ok) throw new Error('User ' + params.id + ' not found');
return res.json();
},
});
// in the component: const user = useLoaderData<User>('user');Actions (mutations)
defineAction is the write counterpart — a typed (input) => outputfunction. It is the "server action" primitive: call it from an event handler, a form, or directly from a route.
import { defineAction } from '@jorvel/runtime';
export const updateProfile = defineAction(async (input: { id: string; name: string }) => {
const res = await fetch('/api/users/' + input.id, {
method: 'PATCH',
body: JSON.stringify({ name: input.name }),
});
if (!res.ok) throw new Error('Update failed');
return res.json() as Promise<User>;
});useAction
Drive an action with React state: { data, error, pending, submit, reset }. Concurrent submissions are serialized last-wins — a slow earlier request can never clobber a newer result, and state updates after unmount are dropped.
import { useAction } from '@jorvel/runtime';
import { updateProfile } from './actions.js';
function ProfileForm({ user }: { user: User }) {
const { submit, pending, error, data } = useAction(updateProfile);
return (
<div>
<button disabled={pending} onClick={() => submit({ id: user.id, name: 'Ada' })}>
{pending ? 'Saving…' : 'Save'}
</button>
{error ? <p role="alert">{String(error)}</p> : null}
{data ? <p>Saved {data.name}</p> : null}
</div>
);
}useFormAction — progressive enhancement
useFormAction binds a FormData action directly to a <form>. With JS it intercepts submit, serializes FormData, and exposes pending/error/data. Wire the same action server-side and the form still posts natively without JS.
import { defineAction, useFormAction } from '@jorvel/runtime';
const subscribe = defineAction(async (fd: FormData) => {
const res = await fetch('/api/subscribe', { method: 'POST', body: fd });
if (!res.ok) throw new Error('Subscribe failed');
return 'ok';
});
function NewsletterForm() {
const { onSubmit, pending, error } = useFormAction(subscribe);
return (
<form action="/api/subscribe" method="post" onSubmit={onSubmit}>
<input name="email" type="email" required />
<button disabled={pending}>{pending ? 'Joining…' : 'Join'}</button>
{error ? <p role="alert">{String(error)}</p> : null}
</form>
);
}use(promise) — read a promise in render
React 19's use(), available on React 18: read a promise during render — suspend while pending, return the value when resolved. Wrap in <Suspense>; pass a stable promise (from a loader/cache, not created inline).
import { use } from '@jorvel/runtime';
function Profile({ userPromise }: { userPromise: Promise<User> }) {
const user = use(userPromise); // suspends until resolved, throws to ErrorBoundary on reject
return <h1>{user.name}</h1>;
}Streaming Suspense route data
Combine use(promise) with a route-level <Suspense> to stream: start the fetch in the loader, pass the promise down, and let the shell render while data resolves. On the server, renderToReadableStream flushes the fallback then the resolved content.
import { Suspense } from 'react';
import { use } from '@jorvel/runtime';
function Page({ dataPromise }: { dataPromise: Promise<Data> }) {
return (
<Suspense fallback={<Skeleton />}>
<Content dataPromise={dataPromise} />
</Suspense>
);
}
function Content({ dataPromise }: { dataPromise: Promise<Data> }) {
const data = use(dataPromise); // suspends the boundary until resolved
return <Detail data={data} />;
}
// SSR: renderToReadableStream(App) streams <Skeleton/> first, then <Detail/> when the promise settles.useQuery — client cache (TanStack-style)
For client-driven data with caching + stale-while-revalidate, use the built-in QueryClient + useQuery / useMutation — a small TanStack-Query-shaped layer (dedupe, background refetch, optimistic setQueryData, prefix invalidation). Wrap the tree in QueryClientProvider (optional — a globalThis-pinned default client is used otherwise).
import { useQuery, useMutation, useQueryClient } from '@jorvel/runtime';
function Todos() {
const qc = useQueryClient();
const { data, isLoading, isFetching, refetch } = useQuery({
queryKey: ['todos'],
queryFn: () => fetch('/api/todos').then((r) => r.json()),
staleTime: 30_000, // serve cache for 30s, then background-refetch
});
const add = useMutation({
mutationFn: (title: string) => fetch('/api/todos', { method: 'POST', body: title }),
onSuccess: () => qc.invalidate(['todos']), // refetch the list
});
if (isLoading) return <p>Loading…</p>;
return (
<>
<ul>{data.map((t) => <li key={t.id}>{t.title}</li>)}</ul>
<button disabled={add.isPending} onClick={() => add.mutate('New')}>Add</button>
</>
);
}QueryClient methods: setQueryData (optimistic/hydration), invalidate(prefix | predicate), prefetch, isStale. Use it when the client owns fetching; use defineLoader when the server should.
Cache tags & revalidation
Tag a useRemoteData read, then invalidate it after a mutation with revalidateTag / revalidatePath — Next-style. Purging an entry makes the next render refetch. Compose useRevalidationVersion() in a component so it re-renders (and re-suspends) automatically when a tag it depends on is revalidated.
import { useRemoteData, revalidateTag, useRevalidationVersion } from '@jorvel/runtime';
import { useAction } from '@jorvel/runtime';
import { updateProfile } from './actions.js';
function Profile({ id }: { id: string }) {
useRevalidationVersion(); // re-render when any tag is revalidated
const user = useRemoteData({
key: 'user:' + id,
fetcher: () => fetch('/api/users/' + id).then((r) => r.json()),
tags: ['user:' + id, 'users'],
});
const { submit } = useAction(updateProfile);
const save = async (name: string) => {
await submit({ id, name });
revalidateTag('user:' + id); // drop the cache → refetch fresh
};
// …
}revalidatePath(p) is sugar for revalidateTag(p) — tag loaders with a route path (tags: ['/dashboard']) to invalidate a whole page's data. invalidateRemoteData(key) purges one key; clearRemoteDataCache() wipes everything.
Optimistic UI
useOptimisticshows a predicted state instantly while a mutation is in flight, then drops the overlay when the authoritative state arrives. Same shape as React 19's built-in, available on React 18.
import { useOptimistic, useAction } from '@jorvel/runtime';
function TodoList({ todos }: { todos: Todo[] }) {
const [optimistic, addOptimistic] = useOptimistic(
todos,
(cur, next: Todo) => [...cur, next],
);
const { submit } = useAction(createTodo);
async function add(title: string) {
addOptimistic({ id: 'temp', title, pending: true }); // shows immediately
await submit({ title }); // overlay clears when `todos` updates
}
return <ul>{optimistic.map((t) => <li key={t.id}>{t.title}</li>)}</ul>;
}Symmetry with loaders
defineLoader (cache, SSR, Suspense via useRemoteData); writes go through defineAction (pending/error state via useAction). Keeping them separate makes caching and revalidation explicit instead of guessed.Validate inputs in the action
input / FormData (Zod/Valibot) inside the action — never assume the client sent what the types claim.