Resources
resource is PreffX's async-data primitive. It wraps a fetcher (an async function) and exposes reactive state (state, error, pending) plus a set of handlers (refetch, mutate, cancel, invalidate) to control the fetch lifecycle.
Resources are isomorphic: the same component code runs on the server (SSR preload) and on the client (hydration + reactivity) with no branching.
Basic usage
import type { PC } from 'preffx';
export const UserCard: PC = (_props, { resource, computed }) => {
const [user] = resource(async () => {
const res = await fetch('/api/user');
return res.json();
});
const display = computed(() => user.state.value?.name ?? 'loading');
return (
<article className="user">
<h1>{display}</h1>
</article>
);
};resource(fetcher) returns a tuple:
const [res, handlers] = resource(fetcher, options?);State
The first element res is a reactive snapshot with three signals:
| Field | Type | Description |
|---|---|---|
state | ReadonlySignal<T | null> | Resolved data, or null while loading / after an error |
pending | ReadonlySignal<boolean> | true while a fetch is in flight |
error | ReadonlySignal<E | null> | Last error, or null |
state and error are intentionally separate: an error is not part of the data state. This keeps the two concerns independent.
const [res] = resource<string, ApiError>(fetchUser);
const view = computed(() => {
if (res.pending.value) return 'loading…';
if (res.error.value) return `Error: ${res.error.value.message}`;
return res.state.value;
});The error type E is inferred from the fetcher's rejection type, or can be passed explicitly: resource<string, ApiError>(fetcher).
Handlers
The second element is a control object:
| Handler | Description |
|---|---|
refetch() | Re-runs the fetcher (aborting any in-flight request first) |
mutate(v) | Optimistically sets the resolved value without re-fetching |
cancel() | Aborts the in-flight request and resets pending. Does not touch cache |
invalidate() | Invalidates the cached entry for this resource's key (explicit keys only) |
const [user, { refetch, mutate, cancel }] = resource(fetchUser);
return (
<div>
<button onClick={refetch}>Refresh</button>
<button onClick={() => mutate({ name: 'Optimistic' })}>Set</button>
<button onClick={cancel}>Cancel</button>
</div>
);Options
resource(fetcher, options?) accepts:
| Option | Type | Description |
|---|---|---|
initial | T | Initial data. Keeps pending false on first render (no Suspense fallback) |
timeout | number | Aborts the fetch after ms and marks the resource failed |
key | string | Unique id for caching and sharing across consumers |
keepPreviousState | boolean | Keep the previous state while a refetch / dependency re-run is in flight |
initial
When initial is provided, data is already available, so pending starts false and Suspense does not show a fallback:
const [user] = resource(fetchUser, { initial: cachedUser });timeout
const [user] = resource(fetchUser, { timeout: 5000 });On timeout the in-flight request is aborted and error is set to a Resource timed out error.
key and caching
Explicit keys enable a per-root cache shared across consumers. Two components using the same key share one state/error entry and only fetch once:
const [a] = resource(fetchUser, { key: 'user' });
const [b] = resource(fetchUser, { key: 'user' }); // shares the cache entryinvalidate() clears that entry so the next consumer re-fetches. Positional keys (no key) do not participate in caching.
keepPreviousState
const [user, { refetch }] = resource(fetchUser, { keepPreviousState: true });While a refetch is in flight, the previous state stays visible (stale-while-revalidate) and only error/pending update.
Abort signal
The fetcher always receives an AbortSignal as its first argument:
resource(async (signal) => {
const res = await fetch('/api/data', { signal });
return res.json();
});This is consistent across server and client:
- Client — a real
AbortControlleris created per run. It is aborted onrefetch(),cancel(), timeout, or unmount. When the signal is aborted, neither data nor error are applied. - Server (SSR) — a real
AbortControlleris created per resource duringpreload(). If the signal is aborted, neither data nor error are applied — matching the client semantics.
Note: aborting the signal cancels the request; it does not turn into an error. To signal a failure (e.g. "no permission"), throw an error from the fetcher instead:
resource(async () => {
if (!hasPermission()) throw new ForbiddenError('no access');
return fetchData();
});Dependency-driven refetch
If the fetcher reads a signal inside its body, the resource re-runs automatically when that signal changes:
const userId = signal(1);
const [user] = resource(async () => {
return fetchUser(userId.value); // re-runs when userId changes
});Server-Side Rendering (SSR)
On the server, resource does not run an effect — it registers its fetcher for preload(). The same component works unchanged:
import { createRoot } from 'preffx/server';
const server = createRoot();
const { preload, serialize } = server.renderToString(App, {});
await preload(); // resolve every registered resource
const html = serialize(); // markup + preload data script- Before
preload(),state.valueisnullandpendingistrue. - After
preload(), the resolved value is embedded in the markup and written to the preload data script. - On the client during hydration, the resource reads the preloaded value and skips the fetch.
Full example
import type { PC } from 'preffx';
type User = { id: number; name: string };
const UserCard: PC = (_props, { resource, computed }) => {
const [user, { refetch }] = resource<User>(
async (signal) => {
const res = await fetch('/api/user', { signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
},
{ timeout: 5000 }
);
const view = computed(() => {
if (user.pending.value) return 'Loading…';
if (user.error.value) return `Error: ${user.error.value.message}`;
return user.state.value?.name;
});
return (
<div>
<p>{view}</p>
<button onClick={refetch}>Refresh</button>
</div>
);
};