Nano Stores
The request is a store. Attach the plugin and the Fch instance gains the
NanoStore contract — get(), value, listen(), subscribe() — so the same
object drives React, Preact, Vue, Svelte, Solid, Lit and Angular through the
standard @nanostores/* adapters, with no wrapper to install.
import { Fch } from "@westtrade/fch";
import { nanostores } from "@westtrade/fch/plugins/nanostores";
const req = new Fch("https://api.example.com/houses/1").use(nanostores());
req.get(); // { loading: false }
await req.json<House>(); // fills req.get().data
The state is:
interface StoreState<T> {
loading: boolean; // true between request start and settle
data?: T; // the parsed body
error?: Error; // a normalised Error
response?: Response; // the raw Response, when one arrived
timer: "idle" | "submitting" | "delayed" | "timeout";
delayed: boolean; // true after delayMs
timedOut: boolean; // true after timeoutMs
}
There is no dependency on nanostores: the instance only implements the contract,
which is get(), a value property, and listen(). That is exactly what
useStore() needs.
A form that works in every framework
One store, one submit handler, and the framework only renders it. The form is the same everywhere; only the template changes.
// house-store.ts — framework-agnostic
import { Fch } from "@westtrade/fch";
import { nanostores } from "@westtrade/fch/plugins/nanostores";
export interface House {
id: number;
name: string;
rooms: number;
}
// A fresh request per submit; the store lives on the instance.
export function createHouse() {
return new Fch("https://api.example.com/houses", { method: "POST" }).use(
nanostores(),
);
}
export async function submitHouse(req: Fch, form: HTMLFormElement) {
const [house] = await req.setFormData(form).json<House>();
return house;
}
Every example below renders the same two things: loading and either data or
error. useStore() re-renders when the store changes, so nothing else is
needed.
React & Preact
import { useStore } from "@nanostores/react"; // or "@nanostores/preact"
import { useRef } from "react";
import { createHouse, submitHouse } from "./house-store";
export function HouseForm() {
const req = useRef(createHouse()).current;
const { loading, data, error } = useStore(req);
return (
<form
onSubmit={(e) => {
e.preventDefault();
submitHouse(req, e.currentTarget);
}}
>
<input name="name" required />
<input name="rooms" type="number" required />
<button disabled={loading}>{loading ? "Saving…" : "Save"}</button>
{error && <p role="alert">{error.message}</p>}
{data && <p>Created house #{data.id}</p>}
</form>
);
}
Vue
<script setup>
import { useStore } from '@nanostores/vue'
import { createHouse, submitHouse } from './house-store'
const req = createHouse()
const state = useStore(req)
function onSubmit(e) {
submitHouse(req, e.target)
}
</script>
<template>
<form @submit.prevent="onSubmit">
<input name="name" required />
<input name="rooms" type="number" required />
<button :disabled="state.loading">
{{ state.loading ? 'Saving…' : 'Save' }}
</button>
<p v-if="state.error" role="alert">{{ state.error.message }}</p>
<p v-else-if="state.data">Created house #{{ state.data.id }}</p>
</form>
</template>
Svelte
Svelte reads the store with $ because the instance satisfies the store
contract, including the immediate call that subscribe() must make:
<script>
import { createHouse, submitHouse } from './house-store'
const req = createHouse()
function onSubmit(e) {
submitHouse(req, e.target)
}
</script>
<form on:submit|preventDefault={onSubmit}>
<input name="name" required />
<input name="rooms" type="number" required />
<button disabled={$req.loading}>
{$req.loading ? 'Saving…' : 'Save'}
</button>
{#if $req.error}
<p role="alert">{$req.error.message}</p>
{:else if $req.data}
<p>Created house #{$req.data.id}</p>
{/if}
</form>
Solid
import { useStore } from "@nanostores/solid";
import { createHouse, submitHouse } from "./house-store";
export function HouseForm() {
const req = createHouse();
const state = useStore(req);
return (
<form
onSubmit={(e) => {
e.preventDefault();
submitHouse(req, e.currentTarget);
}}
>
<input name="name" required />
<input name="rooms" type="number" required />
<button disabled={state().loading}>
{state().loading ? "Saving…" : "Save"}
</button>
{state().error && <p role="alert">{state().error.message}</p>}
{state().data && <p>Created house #{state().data.id}</p>}
</form>
);
}
Lit
import { StoreController } from "@nanostores/lit";
import { LitElement, html } from "lit";
import { createHouse, submitHouse } from "./house-store";
class HouseForm extends LitElement {
// `store.current` is the state; the controller re-renders on change.
private store = new StoreController(this, createHouse());
private onSubmit(e: SubmitEvent) {
e.preventDefault();
submitHouse(this.store.store, e.target as HTMLFormElement);
}
render() {
const { loading, data, error } = this.store.value;
return html`
<form @submit=${this.onSubmit}>
<input name="name" required />
<input name="rooms" type="number" required />
<button ?disabled=${loading}>${loading ? "Saving…" : "Save"}</button>
${error ? html`<p role="alert">${error.message}</p>` : ""}
${data ? html`<p>Created house #${data.id}</p>` : ""}
</form>
`;
}
}
customElements.define("house-form", HouseForm);
Angular
@nanostores/angular exposes the store as an Observable, which fits
async:
import { Component } from "@angular/core";
import { NanostoresService } from "@nanostores/angular";
import { Observable } from "rxjs";
import { createHouse, submitHouse, House } from "./house-store";
@Component({
selector: "app-house-form",
template: `
<form (submit)="onSubmit($event)">
<input name="name" required />
<input name="rooms" type="number" required />
<button [disabled]="(state$ | async)?.loading">
{{ (state$ | async)?.loading ? "Saving…" : "Save" }}
</button>
@if ((state$ | async)?.error; as error) {
<p role="alert">{{ error.message }}</p>
}
@if ((state$ | async)?.data; as data) {
<p>Created house #{{ data.id }}</p>
}
</form>
`,
})
export class HouseFormComponent {
private req = createHouse();
state$: Observable<unknown> = this.nanostores.useStore(this.req);
constructor(private nanostores: NanostoresService) {}
onSubmit(e: Event) {
e.preventDefault();
submitHouse(this.req, e.target as HTMLFormElement);
}
}
Vanilla JS
No framework and no adapter: listen() is all you need. This is the same form
with a render function.
<form id="house-form">
<input name="name" required />
<input name="rooms" type="number" required />
<button type="submit">Save</button>
<p id="status"></p>
</form>
<script type="module">
import { Fch } from "@westtrade/fch";
import { nanostores } from "@westtrade/fch/plugins/nanostores";
const form = document.getElementById("house-form");
const status = document.getElementById("status");
const req = new Fch("https://api.example.com/houses", {
method: "POST",
}).use(nanostores());
// Render on every change: loading, then data or error.
req.listen(({ loading, data, error }) => {
status.textContent = loading
? "Saving…"
: error
? error.message
: data
? `Created house #${data.id}`
: "";
form.querySelector("button").disabled = loading;
});
form.addEventListener("submit", (event) => {
event.preventDefault();
// Fire and forget: the store reports the outcome.
req.setFormData(form).json();
});
</script>
listen() returns an unsubscribe function, which matters for a component that
mounts and unmounts:
const off = req.listen(render);
// later
off();
Loading timers
A spinner that appears instantly feels broken when the response takes 30 ms. The
timers follow Superforms' loading timers:
delayed is what you render, not loading.
idle -> submitting -> delayed -> timeout
0 ms delayMs timeoutMs
const req = new Fch("https://api.example.com/houses/1", {
delayMs: 500, // show a spinner after 500 ms
timeoutMs: 8000, // after 8 s, say "still working"
}).use(nanostores());
Without the plugin these are plain getters, read at render time:
const req = new Fch(url, { delayMs: 500, timeoutMs: 8000 });
const pending = req.send();
req.timer; // "submitting"
req.loading; // true
req.delayed; // false until 500 ms
req.timedOut; // false until 8 s
await pending;
req.timer; // "idle"
With the plugin they are store keys, so a template subscribes instead of polling:
const { loading, delayed, timedOut, data, error } = useStore(req);
{delayed && !timedOut && <Spinner />}
{timedOut && <p>Still working…</p>}
{data && <p>House #{data.id}</p>}
The phases are cumulative — delayed stays true when timeout is reached, and
loading is true in all three active phases. Nothing is mutually exclusive, so
tests and templates do not have to special-case the later phases.
They never abort anything
delayMs and timeoutMs are observational. They do not delay, cancel or
retry a request; only timeout does that:
| Option | Effect |
|---|---|
timeout | aborts the attempt (with retries, per attempt) |
timeoutMs | only changes the reported phase |
That separation is deliberate: a "slow" indicator should never be able to break a request that was about to succeed.
Rules
delayMsmust be<= timeoutMs, or construction throws — the phases would otherwise be unreachable in order.- Pass
falsefor either to disable that phase. With both disabled the request stays insubmitting. - A fast response never reports
delayed; it goessubmitting → idle. - Timers reset on settle, so a reused instance starts clean on the next call.
Listening without the plugin
const off = req.onTimer((state) => {
// "submitting" | "delayed" | "timeout" | "idle"
spinner.hidden = state !== "delayed";
});
off(); // unsubscribe
Mirroring into your own store
The request is a store, but sometimes a view should receive state without holding the request object. Pass an existing store:
import { map } from "nanostores";
export const $house = map({ loading: false });
const req = new Fch("https://api.example.com/houses/1").use(
nanostores({ store: $house }),
);
await req.json();
// $house.get() -> { loading: false, data: {...}, error: undefined, response }
A map() is patched key by key, so a subscriber to data is not woken when only
loading changes. An atom() is replaced wholesale on each change.
Both this store and the request stay in sync; the request is still the one that runs.
Keeping old data while reloading
By default data is cleared when a new request starts, so a view never shows
stale data next to a fresh error. To keep it (a common pattern for pagination):
const req = new Fch(url).use(nanostores({ clearOnRequest: false }));
loading: true plus the previous data lets a UI dim the old list instead of
blanking it.
One request per submit
A Fch instance is a builder, and every execution sends a new request. So a
store on the instance is a fine place for "the current submit", not for a list:
// one store per submit, created fresh
const [house] = await new Fch("/houses", { method: "POST" })
.use(nanostores())
.setJsonBody(input)
.json<House>();
If you need the state to outlive one request, pass your own store with
{ store } — that store is yours to keep.
Caveats
- The store lives on the instance. Two requests on one instance share it; the second overwrites the first. For concurrent requests, use one instance per request, or separate stores.
loadingis per execution. A retried request staysloading: truethroughout its retries, because they belong to one execution.datais the parsed body. For a binary response use.blob()and read theresponsein the state;datawill be the text form.responseis the caller's response. Do not consume its body; readdatainstead.