JORVEL

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.

ts
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.

ts
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.

tsx
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.

tsx
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).

tsx
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.

tsx
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).

tsx
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.

tsx
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.

tsx
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

Reads go through 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

An action is a trust boundary when wired to a server route. Validate input / FormData (Zod/Valibot) inside the action — never assume the client sent what the types claim.