Skip to content

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 ​

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

ts
const [res, handlers] = resource(fetcher, options?);

State ​

The first element res is a reactive snapshot with three signals:

FieldTypeDescription
stateReadonlySignal<T | null>Resolved data, or null while loading / after an error
pendingReadonlySignal<boolean>true while a fetch is in flight
errorReadonlySignal<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.

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

HandlerDescription
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)
tsx
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:

OptionTypeDescription
initialTInitial data. Keeps pending false on first render (no Suspense fallback)
timeoutnumberAborts the fetch after ms and marks the resource failed
keystringUnique id for caching and sharing across consumers
keepPreviousStatebooleanKeep 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:

tsx
const [user] = resource(fetchUser, { initial: cachedUser });

timeout ​

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

tsx
const [a] = resource(fetchUser, { key: 'user' });
const [b] = resource(fetchUser, { key: 'user' }); // shares the cache entry

invalidate() clears that entry so the next consumer re-fetches. Positional keys (no key) do not participate in caching.

keepPreviousState ​

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

ts
resource(async (signal) => {
    const res = await fetch('/api/data', { signal });
    return res.json();
});

This is consistent across server and client:

  • Client — a real AbortController is created per run. It is aborted on refetch(), cancel(), timeout, or unmount. When the signal is aborted, neither data nor error are applied.
  • Server (SSR) — a real AbortController is created per resource during preload(). 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:

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

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

tsx
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.value is null and pending is true.
  • 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 ​

tsx
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>
    );
};

Released under the Apache-2.0 License.