Data Loading#

SignalX is value-first: an async read returns reactive state you render through, not a promise you throw to a wrapper. There is no loading wrapper and no thrown-promise protocol — a component owns its own pending and error branches and renders them with match().

Three primitives cover it, all imported from 'sigx' and called synchronously during component setup:

  • useData(key, fetcher) — a keyed read. Auto-runs, SSR-transferable, deduped.
  • useAction(fn) — a write. Runs on demand via .run(input); never auto-runs.
  • all(...) — combine several reads into one all-or-nothing state.

Plus useStream(key, source) for progressive, token-at-a-time text.

useData#

Every read has a key — data always has identity. The key is the reactive trigger, the cache / SSR identity, and the fetcher's input, all at once. The fetcher runs untracked; only the key getter is reactive, so anything that should re-run the fetch belongs in the key.

useData returns a reactive AsyncState<T> with a state of idle | pending | ready | refreshing | errored, the last-good value, the error, a loading flag (state === 'pending' only), a match() dispatcher, and a refresh() method.

TSX
import { component, render, useData } from 'sigx';

const Joke = component(() => {
    const joke = useData('joke', async (key, { signal }) => {
        const res = await fetch('https://icanhazdadjoke.com/', {
            headers: { Accept: 'application/json' },
            signal,
        });
        return (await res.json()).joke as string;
    });

    return () => (
        <div style="padding: 16px;">
            {joke.match({
                pending: () => <p>Loading…</p>,
                error: (e, retry) => (
                    <p>Failed: {e.message} <button onClick={retry}>Retry</button></p>
                ),
                ready: (text) => <p>{text}</p>,
            })}
            <button
                onClick={() => joke.refresh()}
                disabled={joke.loading || joke.state === 'refreshing'}
            >
                Another one
            </button>
        </div>
    );
});

render(<Joke />, "#sandbox");

The fetcher receives the resolved key as its first argument and an AbortSignal on the context — pass it straight to fetch. The signal aborts only when this cell is the fetch's sole consumer and the run is superseded (keyed fetches may be shared, so they de-dupe rather than abort).

match — the value-first render#

match() is the type-safe way to render every state at once. The ready arm is the only route to a non-null T:

ArmWhenNotes
idleconditional fetch not starteddefaults to pending if omitted
pendingnothing to show yetomitted ⇒ renders nothing while pending
error(e, retry, stale)fetch failedstale is the last-good value (for "keep content + toast"); omitted ⇒ bubbles to an error scope
ready(value)the happy pathrequired

You never have to use matchstate, value, error and loading are plain reactive reads — but it keeps the four states exhaustive and the success type non-null.

Reactive keys#

Pass a getter to make the key reactive. Return a string or a tuple; a falsy result skips the fetch and holds the read in idle (for "type to search" and other conditional reads):

TSX
// re-fetches whenever props.id changes; tuple keys serialize to canonical JSON
const user = useData(() => ['user', props.id], async ([, id], { signal }) => {
    const res = await fetch(`/api/users/${id}`, { signal });
    return res.json();
});

// conditional: no fetch until the box has text
const results = useData(() => query.value && ['search', query.value], searchApi);

A tuple exists to carry parameters, so a static tuple is rejected by design — a static key is a plain string; parameters that change belong in a reactive getter.

Stale-while-revalidate with refresh#

refresh() re-runs the fetcher in place. It keeps the current value visible and moves state to refreshing (not pending), so a revalidation indicator reads state === 'refreshing' while the ready arm keeps rendering. It never rejects — a failed refresh lands on .error:

TSX
const feed = useData('feed', loadFeed);

<button onClick={() => feed.refresh()} disabled={feed.state === 'refreshing'}>
    {feed.state === 'refreshing' ? 'Refreshing…' : 'Refresh'}
</button>

A key change is different: it's a hard reset — value is cleared and state returns to pending, so a wrong-key value never flashes.

SSR + hydration#

A static string key makes the read SSR-transferable automatically:

TSX
import { component, useData } from 'sigx';

const UserProfile = component<{ id: string }>(({ props }) => {
    const user = useData(`user:${props.id}`, async (key, { signal }) => {
        const res = await fetch(`/api/users/${props.id}`, { signal });
        return res.json();
    });

    return () => user.match({
        pending: () => <p>Loading…</p>,
        ready: (u) => <h1>{u.name}</h1>,
    });
});

With a static (or resolved reactive) key, useData:

  • Runs on the server, serializing the resolved value under the key into window.__SIGX_ASYNC__ — a prototype-pollution-safe, page-lifetime cache the server renderer emits.
  • Restores on hydration from that cache — no refetch on first client render.
  • Dedupes per key — two components with the same key share one in-flight request and one cached value.

Pass { server: false } for a client-only keyed read: SSR renders the pending arm and the fetch runs after hydration. It still keeps its key for dedupe and future cache coverage — there is no unkeyed form.

Server functions as keys#

A server function already carries a stable identity, so it is a key. Pass one straight to useData and the function is both the key and the fetcher — no string to invent, no fetcher to write:

TSX
import { useData } from 'sigx';
import { getVotes } from './votes.server';

const votes = useData(getVotes);          // the fn is the key and the fetcher

For arguments, return a [fn, ...args] tuple from a getter. It's reactive and idle-skips on a falsy result, exactly like a normal tuple key:

TSX
const post = useData(() => [getPost, props.id]);   // re-runs when props.id changes

The build stamps each server function with a stable key derived from its id and name, so these reads are SSR-transferable and deduped just like string-keyed ones. (Move a function between files without pinning an explicit id and its key changes — set id on anything you invalidate by reference.)

To refresh a server-function read after a write, declare what it invalidates by reference — a bare server-function reference prefix-matches every parameterized read of it, and the same declaration drives single-flight refresh of the server boundaries that rendered it. That declaration can live on the server function's own options (its invalidates, which runs on every transport) or on a client action via @sigx/cache; with neither, refresh the read explicitly with .refresh() (below).

useAction#

useAction is the write counterpart: the manual sibling of useData that never auto-runs. Trigger it with .run(input). run never rejects — it resolves a settled RunResult<T> ({ ok: true, value } or { ok: false, error }), so both fire-and-forget and const r = await save.run() are safe.

TSX
import { component, useAction } from 'sigx';

const SaveButton = component<{ draft: Draft }>(({ props }) => {
    const save = useAction(async (draft: Draft, { signal }) => {
        const res = await fetch('/api/save', { method: 'POST', body: JSON.stringify(draft), signal });
        if (!res.ok) throw new Error('Save failed');
        return res.json();
    });

    return () => (
        <button
            disabled={save.loading}
            onClick={async () => {
                const r = await save.run(props.draft);
                if (r.ok) toast('Saved'); else toast(r.error.message);
            }}
        >
            {save.loading ? 'Saving…' : 'Save'}
        </button>
    );
});

An action exposes state (idle | pending | ready | errored), value (last success — a search box renders from this), error, loading (state === 'pending', the blessed double-submit guard), match(), run(input), and reset().

In-flight writes are never aborted — an aborted POST is not an undone POST. A newer run() or a reset() only supersedes the observation: the older run's promise resolves { ok: false, error: SupersededError } and never writes state. reset() returns the action to idle, clearing value and error (dismiss a success message, reuse a form).

Cross-read invalidation is explicit — after a successful write, refresh the reads it affects:

TSX
const r = await save.run(draft);
if (r.ok) user.refresh();   // re-pull the read this write invalidated

all#

all() combines several AsyncStates into one — a single match for a whole dashboard. It's a pure derived view (no fetching), with named or positional forms:

TSX
import { useData, all } from 'sigx';

const user = useData(`user:${id}`, loadUser);
const posts = useData(`posts:${id}`, loadPosts);

const page = all({ user, posts });          // named: page.value.user, page.value.posts
// const page = all(user, posts);           // positional: page.value[0], page.value[1]

return () => page.match({
    pending: () => <Spinner />,
    error: (e) => <Error error={e} />,
    ready: ({ user, posts }) => <Dashboard user={user} posts={posts} />,
});

The combined state follows all-or-nothing rules: any member idleidle (a conditional member holds the gate); else any errorederrored; else any pendingpending (until all first settle); else any refreshingrefreshing (values exist, so the ready arm keeps rendering); else ready. .error is first-error-wins; .errors collects them all (named or positional to match). Partial loading — where each section shows its own spinner — wants independent match calls instead.

useStream#

useStream(key, source) accumulates a streamed AsyncIterable<string> into a reactive string — built for progressive, token-at-a-time content like LLM output. It returns a { value } signal that grows as chunks arrive:

TSX
import { component, useStream } from 'sigx';

const Answer = component<{ prompt: string }>(({ props }) => {
    const answer = useStream(`answer:${props.prompt}`, () => streamCompletion(props.prompt));

    return () => <p>{answer.value}</p>;
});

Like a keyed useData, useStream is SSR-aware:

  • Server, streaming: tokens append into the page as they arrive; the final text swaps in through the replacement pipeline and is serialized under the key.
  • Server, blocking: the source is drained fully and the final text is rendered inline.
  • Client, hydrating: the final text is restored from the key — the source is not re-run, so there are no duplicate calls.
  • Client navigation: the source runs live and the signal updates per chunk.

useStream is text-only, and the appended tokens are XSS-safe by construction (text nodes, never raw HTML). It stops pulling from the source when the component unmounts.

Errors#

Fetcher rejections are values, not throws: they land on .error and render through the error arm of match(). When you omit the error arm, the error bubbles to the nearest errorScope, then to the app's onError handler — so uncaught data errors still surface without wrapping every read. See Error handling for the full model.

Server rendering#

The server side of keyed data loading is owned by @sigx/server-renderer's document renderers (renderDocument and friends), which run the fetchers, inject the window.__SIGX_ASYNC__ state blob, and stream replacements. See the server renderer guide for the document-rendering APIs; from a component's point of view, useData / useAction / useStream are all you import.

Caching#

Everything above is core's mechanism — keyed reads, dedup, SSR transfer. Freshness and retention are policy, and policy lives in a pack. Add @sigx/cache and the same useData/useAction gain a cache option: staleTime, focus and interval revalidation, keepPreviousData, cache-aware invalidate() and optimistic mutate() — one plugin, no change to your call sites.

TSX
const user = useData('user', fetchUser, {
    cache: { staleTime: 60_000, revalidateOnFocus: true },
});

Next Steps#