firsthand

Data

Index · Previous: Routing · Next: Web components


npm install @firsthandjs/data

Signals and computeds cover everything an application can work out for itself, now: read a value, derive another, and the screen follows. This chapter is about everything else — the values that are not there yet when the component runs, and that the reactive graph cannot produce on its own because they come from somewhere outside it.

A server is the most common source by far, and every example here uses one. It is not the only one: a worker, IndexedDB, a WebSocket, the Geolocation API, a computation too expensive to repeat are all the same shape. Something out there has a value; you want it in here, as reactive state, with a way to say “that is out of date now”.

There are two ways data comes in, and the difference is who starts:

import {
  DataContext,
  createData,
  createFetchClient,
  tag,
  useAction,
  useResource,
} from '@firsthandjs/data';

const api = createFetchClient({ baseUrl: '/api' });

const App = component(() => {
  provide(DataContext, createData());
  return <Profile id={7} />;
});

const Profile = component<{ id: number }>((props) => {
  const user = useResource(({ request, tags }) => {
    tags(tag('user', { id: props.id }));
    return api.get<User>(`/users/${String(props.id)}`)(request);
  });

  const rename = useAction((name: string, { request, invalidates }) => {
    invalidates(tag('user', { id: props.id }), tag('users'));
    return api.patch<User>(`/users/${String(props.id)}`, { json: { name } })(request);
  });

  return (
    <article class={user.loading.value ? 'stale' : ''}>
      <h1>{user.data.value?.name ?? '…'}</h1>
      <button disabled={rename.running.value} onClick={() => void rename.run('Ada')}>
        Rename
      </button>
    </article>
  );
});

Two layers, and where caching sits

The example above has two halves, and keeping them apart is the one idea worth taking from this chapter.

The reactivity layer is useResource, useAction and tags. It knows who is watching what, what state that is in — loading, loaded, failed — and when it has to run again. It knows nothing about URLs, methods or GraphQL.

The transport layer is the client: createFetchClient, or Axios, urql, Apollo. It knows how to send one request and come back with an answer, and nothing about components.

Between them is one object. A loader is handed a request — an abort signal, and whether this run must go past whatever is remembered — and gives back a promise. That is the entire contract, which is why a loader can be any function at all: a fetch, a worker message, an algorithm that never leaves the page.

useResource(…)  ─ reactivity ────  what is watched, what state, when again
      │
      │  request: { signal, force }
      ▼
api.get('/users/7')  ─ transport ─  how it is sent, and what is remembered

Caching belongs to the transport, and that is a deliberate choice rather than an accident of layering. A cache answers “have I got this already?”, which needs to know when two things are the same thing — a URL, an operation, a key. In the reactivity layer that knowledge does not exist: a resource belongs to its call site, and two call sites are two resources even when they ask for exactly the same thing. Down at the transport, identity is right there in the request.

So: the client caches, and force is how an invalidation reaches through it. If you bring Apollo or urql you already have a cache and should use theirs; if you bring nothing, createCacheClient is ours. What you should not have is two, because two caches over one piece of data disagree, and the disagreement is a bug nobody can reproduce.

ADR-0022 and ADR-0023 have the full reasoning and the alternatives that were rejected.

Resources

A loader is any function returning a promise, and it is given the request:

const rows = useResource(({ request }) =>
  api.get<Row[]>(`/rows?page=${String(page.value)}`)(request),
);

Dependencies are what the loader reads

The loader runs inside an effect, so everything it reads before its first await is a dependency — a prop, a signal, a computed. Changing one runs it again and aborts what was in flight. That is the same rule as effect, and for the same reason: nothing is declared, so nothing can be forgotten.

const rows = useResource(({ request }) => {
  const query = search.value; // read here → a dependency
  const size = pageSize.peek(); // read with peek() → not a dependency
  return api.get<Row[]>(`/rows?q=${query}&n=${String(size)}`)(request);
});

A token read to build a header would be a dependency too — and writing it on a sign-out would re-send every request without one. Every client in this chapter reads its headers for you, untracked, so this is a mistake you have to go out of your way to make.

What it exposes

Cell Values
status idle, loading (nothing to show yet), success, error
loading true while a run is in flight, including one behind a visible value
data The last successful value; stays visible during a reload
error What the last failed run threw

status and loading are separate on purpose: “I have nothing to show” and “I am asking again” are different questions, and conflating them is what puts a spinner over data that is already correct.

await user.reload(); // run again, with `force`
user.dispose(); // stop early; otherwise the component's scope does this

reload() never rejects — a failure lands in error, and it resolves with undefined.

Actions

An action takes an input, does something, and says what it changed:

const publish = useAction((id: string, { request, invalidates }) => {
  invalidates(tag('post', { id }), tag('posts'));
  return api.post<Post>(`/posts/${id}/publish`)(request);
});

<button disabled={publish.running.value} onClick={() => void publish.run(id)}>
  Publish
</button>;

run() never rejects: a failure lands in error and status, and the promise resolves with undefined. An onClick that forgets to await cannot produce an unhandled rejection.

An action’s body is untracked — it runs from an event handler, and what it reads on the way is nobody’s dependency. It exposes data, error, status and running.

Two clicks

A resource that is asked again abandons the older request, because a newer read always wins. An action does not do that, and the reason is worth stating plainly: aborting a request does not undo it. The body may already have reached the server, been parsed and been committed. Abandoning it on this side would leave the screen showing one result and the server holding two.

So a second run() waits for the first by default:

// Two quick clicks send two requests, in order, and both are reported.
const addToCart = useAction((id: string, { request, invalidates }) => {
  invalidates(tag('cart'));
  return api.post<Cart>(`/cart/${id}`)(request);
});

Say otherwise when your mutation wants something else:

// An autosaved draft: the latest input supersedes the earlier one, and the
// mutation is idempotent, so abandoning the older request is safe.
const save = useAction((draft: Draft, { request }) => api.put('/draft')(request), {
  concurrency: 'switch',
});

// A double-clicked button: ignore the second call and hand back the first.
const submit = useAction(send, { concurrency: 'drop' });

// Independent mutations that do not conflict.
const track = useAction(report, { concurrency: 'all' });

running stays true until the last outstanding run lands, so a quick one finishing while a slow one is still out does not report the action as idle — and it stays true across a queue, from the moment run() is called rather than from the moment the work starts. Two clicks the person made as one gesture are one wait, and a button that re-enabled itself between them was reporting the machinery rather than the work.

invalidates() belongs to the run that called it. Under all two runs are out at once, and each invalidates what it changed, not what the other did.

Nothing an action sends touches the cache. Its request carries force, so it is never answered from memory, and mutating, so its answer is never put there — not even when the call has a cacheKey, and not even when it is a GET. A cache holds representations; what an action gets back is the answer to doing something. Two identical writes are two writes, as well: an action is never shared with another in flight.

Tags

A tag says what something is about. tag('user', { id: 7 }) is a name and some variables, and matching is exactly one rule:

A pattern matches when the names are equal and every variable the pattern names is equal.

So fewer variables match more. tag('user') means every user — the right answer for “I changed something and I do not know which one”. tag('user', { id: 7 }) means that one.

Tags are for invalidation only. Nothing is ever looked up by them, so they may be coarse, they may overlap, and two unrelated resources may share one; the worst case is a reload you did not need.

Declare them where you know them

tags() replaces. A run says what it is about; it does not accumulate what it used to be about. So call it before the await when the client knows, and after it when only the server does:

const user = useResource(async ({ request, tags }) => {
  tags(tag('user', { id: props.id })); // known now
  const loaded = await api.get<User>(`/users/${String(props.id)}`)(request);
  tags(tag('user', { id: loaded.id }), tag('org', { id: loaded.orgId })); // known now
  return loaded;
});

The late case is real: you ask for /users/me, and only the answer says which user that was. An invalidation arriving while the request is still out is remembered and matched again when the tags appear, so a run that was overtaken goes again rather than leaving a stale answer on the screen.

With GraphQL you usually declare nothing at all: the document carries its own @tag and @invalidates directives, and the client reports them for you. Documents has the detail.

Invalidating by hand

const invalidate = useInvalidate();
await invalidate(tag('users'));

Everything carrying a matching tag runs again, with force. A component that has never heard of the action shows the new value, because the only thing connecting the two is the tag.

Hand the store your cache and an invalidation empties it as well as reloading what is watching:

export const cache = createCacheClient({ ttl: 30_000 });
provide(DataContext, createData({ caches: [cache] }));

The entries know what their requests were about, so the ones the invalidation named are dropped immediately. They are labels, not addresses: two call sites carrying one tag are still two entries, which is the distinction that keeps this from being the bug ADR-0022 removed. A cache you do not hand over — urql’s, Apollo’s — is untouched, and force is the only contact with it.

And the ones that are not there yet. A list two pages away is nobody’s subscriber: an invalidation reaches nothing, and walking back to it creates a resource rather than reloading one — which would then be handed whatever the transport kept from before the change. So the store remembers what it invalidated for a minute, and a first run whose tags match is forced. Once: the memory is dropped as soon as a run carrying those tags succeeds, so one invalidation cannot make the cache useless for the next sixty seconds. createData({ remember: 0 }) switches it off.

The cache

One cache, at the transport edge, for two jobs: what a client fetched, and what an algorithm of yours computed.

import { createCacheClient } from '@firsthandjs/data';

export const cache = createCacheClient({ ttl: 30_000, max: 200 });
Option Means Default
ttl How long an answer is served again without asking, in ms 0
max How many entries to keep; the least recently read is evicted first 100

With no ttl it still shares what is in flight. Ten components asking for the same thing at the same moment make one request and all get its answer — that is waste removed, not staleness introduced, which is why it needs no configuration. Serving an older answer is the separate decision, and that is what ttl buys.

Hand it to a client, and it caches reads:

const api = createFetchClient({ baseUrl: '/api', cache });

Or use it directly, with no client anywhere — the case where the slow thing is yours:

const report = useResource(({ request }) =>
  cache.read(`report:${month.value}`, () => buildReport(month.value))(request),
);

read(key, produce) runs produce only when there is nothing fresh under that key, shares one run between callers that overlap, and — the part that matters — drops the entry when the request says force. So an invalidated resource asks again for real, rather than being answered out of the memory the invalidation was meant to defeat.

The rest of it: write(key, value) to put something in by hand (a value pushed from a socket, a first page rendered on the server), peek(key) to look without running anything, forget(key) and forget() — which is what a sign-out calls, along with store.clear().

Whose answer is it?

A cache keyed by URL alone can serve one account’s answer to the next one: GET /api/me is the same URL for everybody. So the key carries an identity as well as a request, and by default that identity is the authorization header the request would be sent with.

const api = createFetchClient({
  headers: () => ({ authorization: `Bearer ${session.token.peek()}` }),
  cache: { ttl: 30_000 },
});
// Sign in as somebody else and the key changes with the header. The previous
// account's answers are still in memory, but unreachable.

When the identity is somewhere else — a cookie session, a tenant header, an account picker — say so:

const api = createFetchClient({
  init: { credentials: 'include' },
  scope: () => session.account.value?.id ?? 'anonymous',
  cache: { ttl: 30_000 },
});

Clearing on sign-out is still worth doing, because it frees the memory:

function signOut(): void {
  session.end();
  api.cache?.forget(); // and `store.clear()` for anything persisted
}

The difference is that forgetting to is now a memory question rather than a correctness one.

Actions are outside all of this: they neither read from the cache nor write to it, whatever key they carry.

A key you write yourself must carry everything that varies. cacheKey: 'search' for a POST whose body is the query means every search shares one answer. cacheKey: `search:${stableKey(body)}` is the version that does not — stableKey is exported for exactly this, and gives the same string whatever order an object was written in.

Keeping a value between visits

The cache is memory for this page view. A value that should survive a reload is a different thing, and it is the one thing that needs a name, because a call site does not survive a reload and a name does:

provide(
  DataContext,
  createData({
    storage: {
      read: (name) => idb.get(name),
      write: (name, data) => void idb.set(name, data),
      clear: () => void idb.clear(),
    },
  }),
);

const boards = useResource(loadBoards, { persist: 'boards' });

The stored value appears immediately with loading still true, and is replaced when the loader answers. A storage that throws is a storage that has nothing — it can never break a resource.

The transport: pick a client

Every client here is made the same way — create…Client(…) — configured once with a base URL, headers and a cache, specialised with .with(…), and overridable per call.

fetch

createFetchClient is a small REST client built on the browser’s own fetch. It is what you use when the answer to “which HTTP client?” is “none, the platform is fine” — and it gives you the five things you would otherwise write by hand in every project:

import { createFetchClient } from '@firsthandjs/data';

export const api = createFetchClient({
  baseUrl: '/api',
  headers: () => ({ authorization: `Bearer ${session.token.value}` }),
  cache: { ttl: 30_000 },
});
api.get<User>('/users/7');
api.post<Note>('/notes', { json: { title } });
api.put<Note>('/notes/7', { json: note });
api.patch<Note>('/notes/7', { json: { title } });
api.remove<void>('/notes/7');
api.request<Row[]>('/rows', { method: 'REPORT' }); // the general form

Each returns a loader — a function waiting for the request — so a call site is api.get<User>('/users/7')(request).

Bodies: json: takes an object and sends it as JSON with the content type the platform will not set for you (a stringified object is a string, so fetch labels it text/plain). Everything the platform does label itself — FormData, URLSearchParams, Blob — goes in body: untouched, boundary and all.

api.post('/files', { body: formData });
api.post('/session', { body: new URLSearchParams(form) });

Every other RequestInit option passes straight through: headers, credentials, mode, cache, keepalive, signal excepted — that one comes from the request.

fetch forbids a body on GET: it throws a TypeError rather than sending one. An endpoint that wants a body wants POST.

Per call you can override the caching — cacheKey: 'search:ada' for a POST that reads, cacheKey: false for the read that must never be remembered — and api.with({ baseUrl: '/admin' }) makes a variation that keeps everything else, cache included.

The seam for everything a client library would do with plugins is fetch itself:

export const api = createFetchClient({
  fetch: async (input, init) => {
    const response = await fetch(input, init);
    if (response.status === 401) {
      session.end(); // one place, visible, yours
    }
    return response;
  },
});

There are deliberately no interceptors, no retry and no token refresh: at three of those a pipeline starts to earn its keep, and at that point you want Axios, which is the next section.

Axios

📦 @firsthandjs/data-axios

npm install @firsthandjs/data-axios
import axios from 'axios';
import { createAxiosClient } from '@firsthandjs/data-axios';

const instance = axios.create({ baseURL: '/api' });
instance.interceptors.response.use(undefined, retryOnce);

export const api = createAxiosClient(instance, {
  headers: () => ({ authorization: `Bearer ${session.token.peek()}` }),
  cache: { ttl: 30_000 },
});
const profile = useResource(({ request, tags }) => {
  tags(tag('profile', { id: props.id }));
  return api.get<Profile>(`/profiles/${props.id}`)(request);
});

The instance stays yours — base URL, interceptors, retries, transformers. The client adds the abort signal, the per-request headers and the cache, and touches exactly one Axios method.

urql

📦 @firsthandjs/data-urql

npm install @firsthandjs/data-urql @urql/core
import { Client, fetchExchange } from '@urql/core';
import { createUrqlClient } from '@firsthandjs/data-urql';

export const billing = createUrqlClient(
  new Client({ url: '/graphql', exchanges: [fetchExchange] }),
  { headers: () => ({ authorization: `Bearer ${session.token.peek()}` }) },
);
const invoices = useResource(({ request }) =>
  billing.query(InvoicesDocument, { month: month.value })(request),
);

const pay = useAction((id: string, { request }) => billing.mutate(PayDocument, { id })(request));

Nothing declares tags at either call site: the documents do, and the client reports them into the request. Whether you keep cacheExchange is your decision — with it urql caches and force reaches past it; without it urql is pure transport and our cache is available instead.

Apollo

📦 @firsthandjs/data-apollo

npm install @firsthandjs/data-apollo @apollo/client
import { gql } from '@apollo/client';
import { createApolloClient } from '@firsthandjs/data-apollo';

export const billing = createApolloClient(apollo, gql, {
  headers: () => ({ authorization: `Bearer ${session.token.peek()}` }),
});

Apollo’s InMemoryCache is a normalising cache — a different thing from a store of resources — so there are two ways to run them together, and only two:

const user = fromObservable(...billing.watch(UserDocument, { id: props.id }));

None of the three packages depends on the client it binds, not even as a peer: the handful of methods each uses is declared structurally. They have no version to follow, and nothing to break when yours changes.

More than one API

There is nothing to configure. A second API is a second client, and one component may read from both:

const invoices = useResource(({ request }) => billing.query(InvoicesDocument)(request));
const products = useResource(({ request }) => catalog.get<Product[]>('/products')(request));

Tags are one namespace, so two servers that both have a user want distinct names — tag('billing:user', { id }). A prefix is enough; nothing parses it.

GraphQL documents

Put each operation in a .gql file, with its tags as directives:

# notes.gql
#import "./note-fields.gql"

query Notes($search: String) @tag(name: "notes") {
  notes(search: $search) {
    ...NoteFields
  }
}
# update-note.gql
mutation UpdateNote($id: ID!, $body: String!)
@invalidates(name: "note", id: $id)
@invalidates(name: "notes") {
  updateNote(id: $id, body: $body) {
    id
    body
  }
}

A query’s @tag directives are declared before the request goes out, so an invalidation arriving mid-flight still finds it. A mutation’s @invalidates are what it changed — and since an action’s request is the store’s invalidation, billing.mutate(Doc, vars)(request) needs nothing further.

The loader and the types

// vite.config.ts
import { graphql } from '@firsthandjs/data/vite';
export default defineConfig({ plugins: [firsthand({ packageName: 'app' }), graphql()] });
// codegen.ts
generates: {
  'src/graphql-types.ts': {
    // One plugin: adding `typescript` to the same file declares every input twice.
    plugins: ['typescript-operations'],
    config: { useTypeImports: true, skipTypename: true },
  },
  'src/graphql-modules.d.ts': {
    plugins: ['@firsthandjs/data/codegen'],
    config: { typesPath: './graphql-types' },
  },
}

The second output writes one declare module '*/notes.gql' per operation, so no call site carries a type argument and an operation with required variables cannot be called without them. Rename a field in the schema, re-run codegen, and everything that used it stops compiling.

Without the plugin, parseGraphQL(source) does the same at runtime — at the cost of shipping the parser, which the loader path leaves out of the bundle.

Sources that push

Everything above is you asking. The other half is a source that tells you: a WebSocket, an EventSource, an RxJS stream, or a normalising client’s cache when the same entity appears in twenty places and all of them must agree.

const user = fromObservable(source, { reload: () => watched.refetch() });

The contract is the smallest one every such source satisfies: subscribe with a next and an error, returning an unsubscribe function or something carrying one. Apollo, urql, RxJS and TanStack’s QueryObserver all do, and so does fifteen lines around a WebSocket.

What you get back is an ordinary Resource: the same data, status, loading and error cells, so a component cannot tell — and should not care — which of the two kinds it was given.

fromPromise(factory) is the small end of the same idea: a promise, with no tags and no dependencies.

Testing

A loader is a function and a client is a value, so there is nothing framework-specific to mock. Three levels, in order of preference:

The network. MSW intercepts fetch, so the component, the loader and the client all run for real:

server.use(http.get('/api/users/7', () => HttpResponse.json({ id: 7, name: 'Ada' })));

The client. It is a plain object; a test can hand over one of its own, or narrow the real one with api.with({ fetch: fakeFetch, cache: false }).

The store. createData() is an ordinary value, so a test provides its own — with a storage stub when persistence is what is under test. store.size says how many resources are alive, which is how a leak is asserted.

There are deliberately no test utilities in this package: a resource is a call site running a function, and a helper that reached into one would be reaching into your component.

Devtools

@firsthandjs/devtools reports every resource as it is created, run, invalidated and dropped, with its tags rendered readably. The tags are the thing worth seeing: they are the answer to “why did this reload?”.

What it deliberately does not do

Retries, backoff, token refresh, request queues, normalisation, optimistic cache surgery, pagination helpers, Suspense-style integration. Each belongs to a transport or to an application’s own policy, and each would be paid for by every application that does not need it.


Next: Web components.