A lightweight React SPA template — zero SSR, zero-runtime CSS, type-safe.
English | 简体中文
Painless is a RealWorld conduit demo built as a template: clone it, delete what you don't need, and you have a production-shaped client-side React app — routing with data loading and login guards, an HTTP client, a typed mock pipeline, and a test suite — without buying into any framework runtime.
Modern frontend development is overly complex. Painless strips away the complexity while keeping the power.
- Zero complex concepts - No SSR, no server-side runtime, just pure client-side React
- Zero-runtime CSS - Using Linaria, styles are extracted at build time
- Type-safe - Full TypeScript support with zero configuration
- Instant feedback - Hot Module Replacement for instant updates
- Feel the prefetch - Hovering an article link (
PreviewLink) renders a scaled-down live preview of the target view before you even click
Painless makes deliberate trade-offs. Here's what we chose NOT to include, and why.
We believe introducing SSR/SSG adds architectural complexity that isn't justified for most applications. If you need SEO for search engines, serve pre-rendered HTML to bot traffic via a headless browser — a simple, effective solution that doesn't contaminate your application architecture with server-side concerns.
A frontend framework should not try to be a backend. API Routes, Server Actions, and server-side middleware belong in dedicated backend frameworks. A web frontend is not an application's only client — mobile apps, desktop apps, and other clients all need the same backend. Coupling the web frontend with the backend is a half-measure that serves only one client while leaving others to integrate separately. A clean API layer that all clients can consume is the right boundary.
The route is the page, and the page is the state. Nested and parallel routes attempt to decompose page state into independent URL-driven fragments, which introduces unnecessary complexity in data loading, error boundaries, and layout composition. We believe this is over-engineering — if a section of your UI needs independent state, it's a component, not a route.
If your application is properly decomposed into pages and components, each with clear responsibilities, state lives where it's used. State management libraries encourage centralizing state that should be local, creating coupling between unrelated parts of the application. Use React's built-in primitives (useState, useContext, useRef) and lift state only when genuinely shared.
Some data libraries keep old object references when a refetch returns identical content (structural sharing), letting subscribers skip re-renders. We deliberately don't: deep equality costs O(payload) on every successful fetch, while the re-render it prevents is a cheap page-level reconcile that usually produces no DOM changes at all. Refetches are low-frequency — staleTime gates background revalidation, and fresh hits fire no request. If a specific component must skip updates, give it scalar props behind React.memo; don't tax every fetch of every query to save one component's render.
Image optimization is a service concern, not a framework concern. A dedicated image service (CDN-based or self-hosted) can serve optimized images to all clients — web, mobile, desktop — not just the frontend framework. Coupling this into the framework creates vendor lock-in and serves only one client.
The auth token lives in localStorage (src/services/auth.ts) — RealWorld mandates Token-header authentication, and localStorage survives refresh, so a login isn't lost on reload. The cost is stated plainly: any successful XSS can read the token, where an httpOnly cookie keeps it out of JavaScript's reach entirely.
We accept the trade deliberately. The textbook alternative — an httpOnly refresh cookie plus an access token held in memory — requires backend cooperation a pure client-side template cannot assume, and it discards the session on every refresh: a narrower attack surface bought with a worse product. The main XSS vector is already closed — React escapes by default and the template never touches dangerouslySetInnerHTML — and what remains is defense-in-depth (CSP and friends) that is application-specific and deliberately left to you. A template's job is to put the trade-off on the table, not to decide it for you. If your threat model differs, the whole mechanism is one file: swap the storage in src/services/auth.ts.
Painless produces standard static assets. It does not couple to any specific deployment platform — no proprietary middleware, no platform-specific APIs, no vendor lock-in. Deploy to GitHub Pages, Netlify, Vercel, Cloudflare Pages, your own CDN, or a USB drive. The output is yours.
- React - UI library
- @native-router/react - Lightweight client-side routing with data loading and prefetching
- react-toolroom - Async data hooks (
react-toolroom/async) - fetch-fun - Pipeable functional fetch toolkit
- react-f0rm - Event-driven form library
- haze-ui - Component library with zero-runtime CSS
- react-use-control - Controlled/uncontrolled state in one line
- Linaria - Zero-runtime CSS-in-JS
- Vite - Build tooling
- TypeScript - Type safety
- Vitest - Test framework
If you know TanStack Query/Router/Form, the problems they solve are solved here too — with different shapes: per-entity caches instead of a client + provider, scenario hooks instead of option bags, composable functions instead of a framework runtime. The map below is written against the actual code in src/, not marketing.
| TanStack | Here | Note |
|---|---|---|
QueryClient + QueryClientProvider |
per-entity createQueryCache(name, cacheTime?, {persist?}) + the allCaches registry |
No provider, no context — caches are plain module-level objects; the registry drives logout clears and the DevTool cache view. cacheTime defaults to 5 min (TanStack's gcTime default); tagsCache is the one persisted entity. |
queryKey array + hashing |
the args tuple, hashed by stableHash (signals stripped, undefined keys dropped recursively) |
The key's shape is pinned on the cache type (EntityCache<T, K>), so a wrong-shaped key is a compile error, not a silent cache miss. |
useQuery(options) |
createQueryHook(config) → a scenario hook (useTagsQuery, useCommentsQuery, …) |
All options (staleTime, initData, mock) close once at the scenario declaration point; the call site takes only args. fetch × cache are paired exactly once by bindQueryFn(fetch, cache) — loader, hook and mutation channels resolve the same binding. |
onMutate/onError optimistic ritual |
cache.mutation((…) => ({mutate, key, update, apply})) |
update is the optimistic step, apply a field-selecting merge, rollback automatic and identity-guarded (a concurrent writer's newer value survives); layers compose — favoriting writes the article entity and every home page containing it in one call. |
invalidateQueries prefix match |
useMutation(fn, {invalidates: [[commentsCache, slug]]}) (exact key) or {invalidates: [homeCache, articleCache]} (whole entity); imperative invalidate(router) for view-stack snapshots |
Granularity follows "does the write map 1:1 to the key?" — a comment maps to [slug], an edit can't enumerate home's search-combination keys; failures never invalidate. |
useInfiniteQuery |
useInfinite (react-toolroom/async) |
fetchNextPage/fetchPreviousPage/maxPages equivalents; deliberately not wired into a cache — the About feed (src/services/feed.ts) is the in-repo example. |
refetchInterval |
usePolling |
Skips ticks while a call is slow, pauses when the tab is hidden. |
queryFn + fetch defaults |
the fetch-fun pipe (src/util/http.ts) |
Timeout, retry, auth injection and error mapping are pipe stages, not options — the role ky plays; the OpenAPI graft on openapi-typescript types (src/services/article.openapi.ts) is the openapi-fetch analogue. |
Router loader |
route data via createDataLoader({fetch, cache, keyOf}) → the [loader, useData, queryFn] triplet |
The loader shares the entity cache with component queries through withCache (fresh hit = zero requests, stale = old value first + background revalidate); cache set events auto-refresh(router), so views contain zero refresh calls. |
beforeLoad + router context |
same names, same semantics | Returning a path from beforeLoad redirects during resolve — the URL never lands on the guarded route; Router context={{getUser}} is typed via the route's third generic. |
| Search-param validation | route search: takes any Standard Schema (zod / valibot / hand-written) |
useSearch(schema) on the read side; TypedLink<AppRoutes>'s search prop is checked against the schema's Input side, so a typo'd field is a compile error. |
useBlocker |
same name, @native-router/react |
Sync predicate (() => !isDirty(form)) with {state, proceed, reset} to drive a confirm dialog; a vetoed POP is automatically pushed back. |
useForm / <Form> |
react-f0rm useForm / <Form> + haze-ui FormItem |
Controlled fields via the control token, per-field subscriptions (useIsSubmitting, useHasErrors); this template composes small field-level validate callbacks, and react-f0rm/resolvers/standard-schema exists for zod/valibot/arktype; server 422s land in the same error channel via setServerErrors. |
| Query DevTools | the DevTool panel (dev-only) | Cache view (per-entry age, in-flight badges, event stream) plus a request log — dev-only modules that fold out of production builds. |
All numbers measured the same way on 2026-08-31: esbuild --bundle --minify (peer deps external, regular deps included), zlib gzip level 9. "used" = the import set painless actually pulls (for TanStack, the hooks an app of this shape needs); "full" = the whole entry.
| Role | Here (min+gzip) | Counterpart (min+gzip) |
|---|---|---|
| Async state | react-toolroom/async ~6.0 kB (full: 7.1) | @tanstack/react-query ~10.9 kB (full: 13.9) |
| Forms | react-f0rm ~6.3 kB (full: 8.0) | @tanstack/react-form ~17.6 kB |
| HTTP client | fetch-fun ~5.5 kB | ky ~9.3 kB |
| Routing | @native-router core+react ~11.0 kB | @tanstack/react-router ~34.8 kB |
Versions measured: react-toolroom 0.18.2, react-f0rm 0.7.0, fetch-fun 0.10.0, @native-router/core 1.10.0 + react 1.9.0 (the versions painless installs) vs @tanstack/react-query 5.102.8, @tanstack/react-form 1.33.5, @tanstack/react-router 1.170.32, ky 2.1.0 (latest npm at measurement time).
- Structural sharing — deep equality costs O(payload) on every successful fetch to skip a re-render that is usually a no-op DOM reconcile; a hot component gets
React.memo+ scalar props instead (see Design Philosophy). - The
enabledswitch — options close once at the scenario declaration point, so conditional fetching is a conditional hook call; runtime option switches are the option-bag problem this layer exists to avoid, and unused options are YAGNI'd away (docs/decisions.md§2). - Offline mutation queue — replaying writes across reloads is a conflict-resolution product policy; even cache persistence is per-entity and opt-in here (only
tagsCache), and a template refuses to make product calls for you. pendingMs-style pending timeout — in-app navigations keep the current view with a global loading indicator and stale hits render the old value instantly, so pending is cold-start-only; a hanging loader keeps showing the skeleton rather than silently rendering a data-less view.- Nested / parallel routes — the route is the page and the page is the state; a URL fragment that needs independent state is a component, not a route (see Design Philosophy).
- SSR / streaming — zero server runtime by design; bots get pre-rendered HTML from a headless-browser service and the app ships as plain static assets.
All examples below are taken from (or lightly adapted from) the actual source in src/.
Routes are a plain module-level object: each route owns a path, a lazy component, and an optional async data loader that runs before the view renders.
// src/views/index.tsx (excerpt)
import {View, HistoryRouter as Router, createRoutes} from '@native-router/react';
// createRoutes (satisfies semantics): the table is checked against Route
// while every path keeps its string-literal type — `as Route` would widen
// the paths to string and kill TypedLink's path union
const routes = createRoutes({
component: () => import('./Layout'),
children: [
{
path: '/',
// search schema: parsed + coerced at resolve time; changing the search
// re-runs `data` (the URL is the state). withCache shares the entity
// cache with useQuery (fresh hit = zero requests, stale = old value
// first + background revalidate); mockViewData wraps the outside so
// only real data enters the cache.
search: homeSearchSchema,
data: mockViewData(
withCache(
homeCache,
({search}: {search: HomeSearch}): [HomeSearch] => [search],
({search, signal}: {search: HomeSearch; signal: AbortSignal}) =>
articleService.query(search, signal)
),
articlePageSchema,
'articlePage'
),
pendingComponent: HomeSkeleton, // cold start only — see Design Philosophy
component: () => import('./Home')
},
{
path: '/article/:title',
component: () => import('./Article'),
data: withCache(
articleCache,
({params}: {params: {title?: string}}): [string] => [params.title!],
({params: {title}, signal}: {params: {title?: string}; signal: AbortSignal}) =>
articleService.findByTitle(title!, signal)
),
errorComponent: NotFound // article-level 404; others go to errorHandler
},
// ... /help, /about, /login, /register
{path: '/editor', beforeLoad: requireLogin, component: () => import('./Editor')},
{path: '/editor/:slug', beforeLoad: requireLogin, component: () => import('./Editor')}
]
});
export type AppPaths = RoutePaths<typeof routes>;
export default function App() {
return (
<Router routes={routes} errorHandler={(e) => <RouterError error={e} />}>
<View />
<Loading />
</Router>
);
}Views read route data with a typed useData<T>() and react to URL changes with useMatched(). In Home, the tag filter and pagination live entirely in the query string — the route declares a search schema (any Standard Schema — zod/valibot/…; here a hand-written one), the loader receives the coerced ctx.search, and changing the search re-runs the loader, so the URL is the state:
// src/views/Home/index.tsx
import {useData, useSearch} from '@native-router/react';
export default function Home() {
const {articles, articlesCount} = useData<ArticlePage>() ?? {articles: [], articlesCount: 0};
const {tag, offset, limit} = useSearch(homeSearchSchema);
// ...
}@native-router ships route guards: beforeLoad runs before the view resolves — return a path string and the router redirects during resolve, before the navigation commits (the URL never lands on the guarded route):
// src/views/index.tsx
const requireLogin: Route['beforeLoad'] = () => {
if (!getCurrentUser()) return '/login';
};
// routes
{path: '/editor', beforeLoad: requireLogin, component: () => import('./Editor')},
{path: '/editor/:slug', beforeLoad: requireLogin, component: () => import('./Editor')}Prefetching runs the same guard — hovering a PrefetchLink to a guarded route while logged out just resolves the redirect target, no side effects.
PrefetchLink prefetches the target route's data and view chunk on hover (or focus). The template's PreviewLink wraps it and additionally renders a scaled-down live preview of the prefetched view:
// src/components/PreviewLink.tsx
import {PrefetchLink} from '@native-router/react';
import {useControl, type Control} from 'react-use-control';
type Props = ComponentProps<typeof PrefetchLink> & {
visible?: Control<boolean> | boolean;
};
export default function PreviewLink({children, visible: visibleControl, ...props}: Props) {
const [visible, setVisible] = useControl(visibleControl as Control<boolean>, false);
return (
<PrefetchLink {...props}>
<span
onMouseEnter={() => setVisible(true)}
onMouseLeave={() => setVisible(false)}
onFocus={() => setVisible(true)}
onBlur={() => setVisible(false)}
tabIndex={0}
>
{children}
</span>
<Preview visible={visible} />
</PrefetchLink>
);
}Note: prefetching runs route
beforeLoadguards too — hovering a link to a guarded route while logged out just resolves the redirect target, no side effects.
State a component exposes to its host — panel open/closed, preview visibility — follows the control object convention instead of the classic value/defaultValue/onChange triple. A control is an opaque token returned by useControl: whoever creates the state first owns it, everyone else reuses it. The same convention powers every stateful haze-ui component and the FormItem form bridge.
Before — the classic triple needs dual-source arbitration on every render and every write:
type Props = {
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
};
function DevTool({open, defaultOpen = false, onOpenChange}: Props) {
// Two sources of truth: external `open` vs internal state
const [internal, setInternal] = useState(defaultOpen);
const isOpen = open ?? internal;
const setOpen = (next: boolean) => {
if (open === undefined) setInternal(next);
onOpenChange?.(next);
};
// ...
}After — actual src/components/DevTool.tsx source, one prop, uncontrolled by default:
import {useControl, type Control} from 'react-use-control';
function DevToolInner({open: openControl}: {open?: Control<boolean> | boolean}) {
const [open, setOpen] = useControl(openControl as Control<boolean>, false);
// ...
}
export default function DevTool({children, open}: Props) {
return (
<>
{children}
<DevToolInner open={open} />
</>
);
}The host picks a mode per usage — the component never changes:
<DevTool /> // uncontrolled: internal state, closed by default
<DevTool open /> // uncontrolled: plain value seeds the initial state
// controlled: the host owns the state — one shared state, not two kept in
// sync; the panel's Close button writes back through the same control
const [open, setOpen, openCtrl] = useControl(false);
<DevTool open={openCtrl} />
<button onClick={() => setOpen(true)}>Open panel</button> // e.g. a hotkey, or E2EWhat the token buys over the triple:
- One prop instead of three — no
defaultOpen/onOpenChangeplumbing; a plain value already means "uncontrolled seed" - No arbitration, no mirroring — there is no second source of truth to reconcile: a control that already holds state is reused as-is (no
useEffectsync, no callback round-trips) - Sibling sharing for free — pass the same control to several children and they share one state; the triple needs
value+onChangethreaded through each child - One mechanism across the stack —
FormItem's render-propcontrolis the same token, which is why a react-f0rm field binds a haze-ui input asvalue={control}with zero adapters
Scope discipline — the pattern is for state a host may want to steer. Deliberately not converted:
- read-only receivers:
Previewkeepsvisible: boolean— it never writes back - state owned by another library: react-f0rm owns form fields
- page-local state: view-level
errormessages stayuseState
One caveat: a control prop must be identity-stable across renders — dev builds throw if the same mounted hook receives a different control object.
Instead of adopting a data-fetching library, the template composes react-toolroom/async primitives (useInjectable, useCache, useRun, useResultSelect, useLoading, useArgsStatus, useFocusRevalidate, useReconnectRevalidate, useRefresh) into one project-owned factory — demonstrating the idea that each project should shape its own query layer. createQueryHook(config) closes every option at the scenario declaration point (once, immutable); the hook it returns takes only args at the call site — zero options, zero plumbing:
// src/util/useQuery.ts (signature)
export function createQueryHook<C extends QueryHookConfig>(
config: C
): (args: SceneArgs<C>) => QueryResult<SceneData<C>>;
// SceneArgs: the queryFn's args (trailing optional signal stripped);
// SceneData: its return type, plus undefined unless initData was declared
type QueryHookConfig = {
queryFn: QueryFn<any, any[]>; // required — a bindQueryFn(fetch, cache) product
staleTime?: number; // defaults to 2000ms
initData?: unknown; // initial data; narrows data to non-nullable
mock?: MockConfig; // {schema, key} — hooks the DevTool mock panel
};
type QueryResult<T> = {
data: T;
loading: boolean; // initial load only — true until the first result exists
fetching: boolean; // any request in flight (incl. background refetches)
error: Error | undefined;
failureCount: number; // per-args failures since the last success
stale: boolean;
dataUpdatedAt: number | undefined; // last successful settle for these args (TanStack's namesake)
refetch: () => void | Promise<unknown>; // drops the cache entry for the current args and re-runs
};Note what the config does not contain: cache. The fetch function and its cache are paired exactly once by bindQueryFn(fetch, cache) into a branded QueryFn — a plain service function lacks the phantom brand and cannot enter createQueryHook at compile time — and the loader, scenario-hook and mutation channels all resolve that one binding (no re-pairing at assembly points).
The caches are per-entity (articleCache / homeCache / commentsCache / tagsCache, declared via createQueryCache(name, cacheTime?, {persist?})): the value type and the key-tuple type are pinned on the cache itself, so peek results are narrowed without as casts and a wrong-shaped key is a compile error — the 'article'-style magic string prefix disappears because identity is the cache binding. The hash normalizes two ways (signals stripped; object keys with undefined values dropped recursively), so {tag: undefined} and {} are one key — a loader key from a schema output and a view-side key from component state can never drift apart. allCaches registers every entity cache for logout-time clears and the DevTool panel; tagsCache additionally carries a localStorage mirror (hydrated on load, wiped on logout).
Out of the box the preset wires the behaviors projects usually hand-roll: concurrent same-args calls are deduplicated at the provider level — useCache's miss/stale revalidation routes through the cache's load (an atomic get-or-insert of the in-flight slot), so every consumer and every channel (another component, a route loader — see below) asking for the same key while a request is pending shares that one promise; dependency changes abort the previous request via a trailing AbortSignal threaded through the service layer to fetch (useRun({signal: true})); cache/load/refetch keys are all one structural hash (stableHash with signals stripped — key-order-insensitive); and window focus / visibility regain and reconnects revalidate in the background (useFocusRevalidate / useReconnectRevalidate) — fresh entries hit the cache without a request, stale ones swap in silently.
Real declarations and call sites, from the tag sidebar and the comment list:
// src/services/dataloaders.ts — the scenario declaration points
// (createDataLoader's triplet — loader / useData / queryFn — see the next section)
export const [, , queryTags] = createDataLoader({
fetch: articleService.fetchTags,
cache: tagsCache,
keyOf: (): [] => []
});
export const useTagsQuery = createQueryHook({
queryFn: queryTags,
initData: [],
mock: {schema: tagListSchema, key: 'tagList'}
});
export const useCommentsQuery = createQueryHook({queryFn: queryComments, initData: []});
// src/views/Home/Tags.tsx — the call site carries zero options
const {data: tags, loading, error, stale} = useTagsQuery([]);
// src/views/Article/CommentList.tsx — initData: [] narrows data to Comment[]
const {data: comments, loading, error, dataUpdatedAt} = useCommentsQuery([title]);The preset wires the behaviors most projects need (dedup, SWR, focus/reconnect revalidation, abort-on-change). react-toolroom/async ships more primitives that the preset deliberately does not re-export — when one of these fits, drop to the library hook directly on the same injectable/cache instead of growing the preset:
- Polling (
usePolling— TanStack'srefetchInterval): live dashboards. It skips ticks while a call is slow and pauses when the tab is hidden; passargsso the poller addresses the same cache key as youruseRun. - Infinite lists (
useInfinite— TanStack'suseInfiniteQuery):fetchNextPage/fetchPreviousPage,maxPageswindowing. The About page's feed (src/services/feed.ts) is a working in-repo example — offset pagination aggregated into an endless list, the first page driven byuseRunlike any plain query, the next by an IntersectionObserver sentinel — and it deliberately opts out of the cache: what to cache and for how long belongs to scenarios that actually share data across pages, which is the point of per-scenario assembly over a one-size preset. - Retry observability (
useRetry+useFailureCount): the preset already reports per-argsfailureCountin its result; a scenario that wants automatic retries drops touseRetryand pairs it withuseFailureCountfor "retrying (2/3)…" UI. - Mutation serialization (
useMutationscope, react-toolroom 0.11): rapid-fire writes to the same entity — the template's favorite button queues per-slug (scope: (slug) => \favorite:${slug}``), so a second click executes on the settled baseline instead of racing. - Lower-level stores (
useResult/useLoading/useErrorshare one broadcast domain per injectable): siblings reading the same query sync for free; late mounters start from the last result with zero requests.
The rule of thumb: the preset is the default path; a library primitive beside it is an addition, not a fork — both talk to the same entity caches.
The two data channels — route data loaders and the scenario hooks (createQueryHook products) — deliberately share the entity caches. They differ in when they trigger and whether they block (loader: navigation resolve, pendingComponent skeleton; query: post-mount, loading/error states), but cache and invalidation are one. One declaration covers both channels: createDataLoader({fetch, cache, keyOf, mock?}) (src/util/dataLoader.ts) returns a triplet [loader, useData, queryFn] — the loader goes on the route, useData() reads typed data in the view (the useData<T>()! assertion and generic annotations collapse into the factory; {optional: true} covers a shared component on a route that may carry no data, and dev builds verify the route actually declared this loader), and queryFn feeds createQueryHook for the component channel. keyOf(ctx) is the single place the entity's key is defined — the loader addresses articleCache as [title] / homeCache as [search], and mutations address the same tuples through the cache binding, so views never hand-assemble keys at all (the old homeCacheArgs "payload must match the schema output shape" footgun is structurally gone — the hash drops undefined keys). All declarations live in src/services/dataloaders.ts — the application binding layer the route table and views consume.
Under the hood the loader is wrapped by withCache(cache, keyOf, fn) (src/util/loaderCache.ts) with SWR semantics — fresh hits return the cached value with zero requests, stale hits return the old value immediately and revalidate in the background, misses fall through to the skeleton/error paths. Combined with the router's view stack, a navigation lands in one of four states:
| Navigation lands on | Loader runs? | What the user sees |
|---|---|---|
| viewStack snapshot (POP within the session window) | no — replay | instant previous view, zero requests |
cache hit, fresh (< staleTime 2s) |
yes — cache only | instant cached data, zero requests |
| cache hit, stale | yes — background revalidate | old value immediately, refreshed in place — no skeleton, no flash |
| cache miss | yes — network | pendingComponent skeleton (cold start) |
In-flight requests are shared across channels at the provider level: a PrefetchLink warm-up and the real navigation resolve to the same in-flight promise, so hovering a link first does not double-fetch. Idle entries are reclaimed per-entry (cacheTime ages from each entry's lastUsedAt — loader-written entries with no live consumers are reclaimed after the window too; no never-expire special cases).
Two session-level freshness edges are covered: on logout() the Layout also calls invalidate(router) (native-router ≥1.6) — view-stack snapshots of the previous account are dropped, so a later back POP re-resolves through guards and loaders instead of replaying the old account's views; and pageshow with persisted: true (bfcache restore — the SPA gets no navigation event) triggers refresh(router): loaders re-run against the cache, fresh hits cost nothing, stale ones swap in silently.
Write-through favorite / follow used to be ~30 hand-rolled lines per call site (peek baseline → set → refresh(router) → success merge → failure rollback). Since react-toolroom 0.10, that pipeline is a cache-bound declarative API — the recipe lives in the service layer (src/services/mutations.ts), composed per cache projection:
// src/services/mutations.ts
// article layer: the single-entity primitive, reusable by any view / other layer
export const favoriteOnArticle = articleCache.mutation(
(slug: string, on: boolean) => ({
mutate: () => api.favoriteArticle(slug, on),
key: [slug],
update: (old) => ({...old, favorited: on,
favoritesCount: old.favoritesCount + (on ? 1 : -1)}),
// field-selecting merge: only the favorite fields are authoritative —
// a `following` written while this was in flight survives the apply
apply: (old, resp) => ({...old, favorited: resp.favorited,
favoritesCount: resp.favoritesCount})
})
);
// home layer: the feed projection, composed on top (key omitted = patch every
// settled entry; pages that don't contain the slug miss-bail and are skipped)
export const favoriteOnHome = homeCache.mutation((slug: string, on: boolean) => ({
mutate: () => favoriteOnArticle(slug, on), // composition point
update: (page, slug, on) => {
const target = page.articles.find((x) => x.slug === slug);
if (!target) return undefined;
return patchArticleIn(page, slug, {...});
},
apply: (page, resp) => patchArticleIn(page, resp.slug, {...})
}));The pipeline journals every write, and on failure rolls back with an identity guard — an entry is restored only if it still holds exactly the optimistic value, so a concurrent writer's newer state survives our rollback. Views reduce to a call plus error surfacing:
// src/views/Home/index.tsx
const [favorite] = useMutation(favoriteOnHome, {
// serialize rapid clicks on the same article; different articles don't block
scope: (slug: string) => `favorite:${slug}`
});
const toast = useToast();
const toggleFavorite = (a: Article) => {
if (!getCurrentUser()) return void navigate(router, '/login');
// rollback is automatic; the toast is the only user-facing feedback left
void favorite(a.slug, !a.favorited).catch((e) =>
toast(e instanceof Error ? e.message : 'Favorite failed', {variant: 'danger'})
);
};Three properties fall out of the composition for free:
- Multi-projection consistency — favoriting from
Homewrites both thearticleCacheentry and everyhomeCachepage containing the slug in one call; the "back to the list shows the stale count" gap is gone. - Refresh is automatic —
withCachesubscribes each cache'ssetevents on first loader run and refreshes the router when an already-seen key's value reference changes (microtask-debounced). Write-through, rollback,patchWherebatches and background revalidation settles all flow through it; views contain zerorefreshcalls. The reference-change check doubles as a structural-sharing substitute: a revalidation that settles with the same reference triggers nothing. - Failure isolation — each layer miss-bails independently (nothing is fabricated for absent entries, so an optimistic write can never resurrect an entry a logout just cleared), and a rejection unwinds every composed layer.
Comment posting stays with declarative invalidation, pinned to the exact key: useMutation(articleService.addComment, {invalidates: [[commentsCache, article.slug]]}) clears only the current article's comment entry (other articles' caches survive, and a mounted CommentList refetches passively via the provider's delete event — the list shape after an append is not locally computable, a hard refetch is the right tool). Editor saving invalidates wholesale (invalidates: [homeCache, articleCache]) — two deliberate granularities: a comment write maps 1:1 to the [slug] key, while the home projection's keys are full search combinations that an edit cannot enumerate at the write point.
src/util/http.ts builds a pipeable client: base URL (VITE_API_URL override, default https://api.realworld.io/api/), JSON headers, a per-attempt 10s timeout (inner of retry, so every retry gets a fresh budget), retry ×2 (idempotent methods + transient statuses only, library defaults for backoff/Retry-After), a whole-request 30s totalTimeout budget that bounds all retries + backoff as one unit, auth injection, and an error mapper. Errors stay fetch-fun's HTTPError — identity preserved via withMessage, so instanceof HTTPError, .status and .data (the field-structured errors object) all keep working downstream; message is rewritten for readability (the API's message first, otherwise the errors object joined into text). A 401 fires the registered unauthorized handler (the auth service uses it to auto-logout on expired tokens):
// src/util/http.ts (excerpt)
const client = ff
.create({baseUrl: BASE_URL})
.pipe(ff.header, 'content-type', 'application/json')
.pipe(ff.header, 'accept', 'application/json')
.pipe(ff.use, ff.withTimeout(10_000)) // per-attempt budget, inner of retry
.pipe(ff.use, ff.withRetry(2)) // idempotent methods + transient statuses only
.pipe(ff.totalTimeout, 30_000) // whole-request budget: retries + backoff bounded
.pipe(ff.use, ff.withAuth(() => tokenGetter() ?? '', 'Token')) // empty creds → no header
.pipe(ff.mapError, (e) => {
if (!(e instanceof ff.HTTPError)) return e;
if (e.status === 401 && tokenGetter()) fireUnauthorized();
return e.withMessage(errorText(e.data) || e.message);
});In dev builds a withLogging middleware is appended that pushes every Request/Response/Error event into a ring buffer (src/util/requestLog.ts) — the DevTool panel renders it as a request log (see below). import.meta.env.DEV folds the branch out of production builds.
Services are thin functions over get/post/put/del:
// src/services/article.ts
export function fetchTags(): Promise<string[]> {
return http.get<{tags: string[]}>('tags').then(({tags}) => tags);
}Types claim a shape; the wire can disagree. In dev builds, every service call also carries the JSON Schema generated from the same domain types (the same module the mock pipeline uses — one contract, three consumers: types, mocks, validation). A 2xx body that violates it rejects with fetch-fun's ValidationError, whose message locates the drift in one line — which request, which JSON pointer, what was expected, what arrived:
GET articles: 响应失配于 /articles/0/title — must be string(实际值: 42)
// src/util/http.ts (excerpt) — init.schema is the opt-in hook
function responseSchema(schema: unknown, label: string): ff.StandardSchema {
return {
'~standard': {
version: 1,
vendor: 'painless/json-schema',
validate: async (value) => {
const {check} = await import('./validate'); // ajv, dynamic
return check(schema, value, label);
}
}
};
}
// src/services/article.ts (excerpt) — schemas fold away in production
const schemas = import.meta.env.DEV
? {list: articlePageSchema, article: envelope('article', articleSchema), /* … */}
: undefined;
export function query(params?: ArticleQuery, signal?: AbortSignal) {
return http.get<ArticlePage>('articles', params, {signal, schema: schemas?.list});
}Non-2xx responses skip validation (HTTPError semantics untouched). Mock-sizing annotations (@minItems/@maxItems/@unique/@faker — "10 per page" is a generation directive, and a real last page can be shorter) are stripped before checking. Production pays nothing: import.meta.env.DEV folds the branch, ajv is a devDependency loaded via dynamic import inside it — verified absent from built chunks (same treatment as the faker stack).
The mock pipeline gets the same check (mock.ts validates always-mode output against the same schema) but downgrades failure to a located console.error instead of throwing — known json-schema-faker 0.6 quirks (deep $ref nesting drops @faker annotations, e.g. articles[].author.image comes out null) shouldn't brick DevTool's mock mode; see docs/decisions.md §7.
When the backend publishes an OpenAPI spec, openapi-typescript (devDependency, zero runtime) turns it into pure types and a thin graft constrains the whole fetch-fun pipe at compile time — path, method, request body, and 2xx response. src/services/article.openapi.ts is a working demo against the official RealWorld spec (committed at openapi/realworld.yml; regenerate types with npm run openapi):
// src/services/article.openapi.ts (excerpt) — full compile-time constraints
export function findBySlug(slug: string, signal?: AbortSignal) {
return ff.fetchData(
api
.pipe(typedPath, '/articles/{slug}', {slug}) // must be a real spec path + params
.pipe(typedMethod, 'get') // must exist under that path
.pipe(queryAndSignal(undefined, signal))
.pipe(typedJson, 'get') // response typed by the spec
);
}Typos fail loudly: '/article' is not a key of paths; 'post' doesn't exist under /tags; {nome: 'Ada'} doesn't satisfy NewArticleRequest; a 'post' reader after a 'get' method is a type error. The demo coexists with the handwritten services/article.ts (they differ on purpose: the handwritten one unwraps {article} → Article, the demo returns the spec's raw response shape). It's referenced by no view, so it never enters a production chunk. Boundaries and known spec-vs-handwritten drift are recorded in docs/decisions.md §6.
src/services/auth.ts persists the current user to localStorage (painless.user), restores it on load, and registers a token supplier with the HTTP layer — so login/logout never requires rebuilding the client pipeline. src/index.tsx imports @/services/auth for its side effect, ensuring even the first route-data request after a cold refresh carries Authorization:
// src/services/auth.ts
let currentUser: User | null = readStoredUser();
http.setTokenGetter(() => currentUser?.token);
export function getCurrentUser(): User | null;
export function onAuthChange(handler: (user: User | null) => void): () => void;
export function login(email: string, password: string): Promise<User>;
export function register(username: string, email: string, password: string): Promise<User>;
export function logout(): void;Layout subscribes with onAuthChange and swaps the nav between Login / Register and username / New Article / Logout.
Domain types carry JSON Schema annotations as JSDoc tags. At build time, rollup-plugin-type-as-json-schema compiles them into .schema files; in dev, src/util/faker.ts feeds them to json-schema-faker with the @faker-js/faker instance (passed via options.extensions, as required by json-schema-faker 0.6):
// src/types/base.ts
/**
* @faker {"lorem.sentence": [20]}
*/
export type Sentence = string;
/**
* @faker {"lorem.paragraphs": [5]}
*/
export type Paragraphs = string;
// src/types/index.ts
export type Article = {
title: Sentence;
body: Paragraphs;
slug: Slug;
// ...
};Two integration points:
- Route loaders:
mockViewData(fn, schema, key)wraps a routedatafunction. - Component queries:
useQuery'smock: {schema, key}option.
Both register with the DevTool panel (dev-only), where each dataset can be switched between empty (mock only when the API errors or returns nothing) and always. Mode switches and the panel's Refresh button clear all per-entity caches (clearAllCaches walks the allCaches registry) — mocks take precedence over cache: they must bypass the loader's fresh withCache hits, or the mock would never take effect (dev-only; production has no mock callers).
The panel also hosts two more dev inspectors:
- Cache view —
allCachessnapshots with per-entry age, in-flight badges and a set/delete event stream, plus a Clear button (this is the closest thing to TanStack Query DevTools this stack needs, at ~0 runtime cost). - Request log — the dev-only
withLoggingmiddleware insrc/util/http.tspushes every Request/Response/Error event into a ring buffer (src/util/requestLog.ts); the panel renders them newest-first with status coloring, so "did this interaction hit the network?" is one glance away.
src/index.tsx creates a single useControl boolean at the app root — seeded from prefers-color-scheme, then user-owned — and drives lightTheme/darkTheme (haze-ui's --haze-* CSS variable classes) off it. The control travels to the nav bar's ThemeToggle via a plain context (src/util/theme.tsx); the toggle itself is a haze-ui Switch whose checked prop natively accepts a Control<boolean> — no value/onChange plumbing. This is also the canonical example of sharing a control across distant components: the root creates it once, everyone else reuses the same token.
Linaria styles are tagged template literals extracted at build time by @wyw-in-js/vite — nothing ships to the browser but class names:
// src/views/Home/index.tsx
import {css} from '@linaria/core';
// Push the favorite button to the right end of the author row
const pushRight = css`
margin-left: auto;
`;The template ships no error-reporting SDK — reporting is a product decision it refuses to make for you. What it does ship is three mounting points, one per layer, each seeing a different slice of failures:
- HTTP layer (
src/util/http.ts) — every request from every channel (route loaders,useQuery, mutations) funnels through the one fetch-fun pipeline, and its finalmapErrorstage is where a reporter belongs: capture, then return the error unchanged soinstanceof HTTPError,.statusand.datakeep working downstream. Richest signal per failure (status, parsed error body, request context) — but request failures only, and filter the noise: a request aborted because a newer navigation superseded it (AbortSignalthreading) is routine, not an error. - Router layer (
src/views/index.tsx) — navigation-time failures split by segment:params/searchparse failures, and any route without its own fallback, land in the globalerrorHandler(rendersRouterError); a route'serrorComponent(e.g.NotFoundon/article/:title) covers only that route'sdatafailures. The division of labor:errorComponentis presentation — the page decides what "article not found" looks like — whileerrorHandleris the single choke point that sees every other loader failure, and the natural place to report them. - Render layer (
src/index.tsx) — the router catches resolve failures; an error thrown while a view renders, or from an event handler, is outside its pipeline. The template deliberately mounts no rootErrorBoundaryyet — the natural spot isRootinsrc/index.tsx, wrapping<App />as the last-resort boundary, paired with a crash fallback UI instead of a white screen.
Wiring one up (Sentry pseudo-code — the template carries no such dependency):
// ① HTTP layer — src/util/http.ts, at the mapError exit: report, return as-is
.pipe(ff.mapError, (e) => {
if (!isAbort(e)) Sentry.captureException(e); // superseded-request aborts aren't errors
return e;
})
// ② Router layer — src/views/index.tsx, global errorHandler (errorComponent only renders)
errorHandler={(e) => {
Sentry.captureException(e);
return <RouterError error={e} />;
}}
// ③ Render layer — src/index.tsx root ErrorBoundary (not mounted yet; wraps <App />)
<ErrorBoundary onError={(e) => Sentry.captureException(e)} fallback={<Crash />}>
<App />
</ErrorBoundary>The layers overlap by design — a loader's 500 already passed through the HTTP layer — so mount where your product needs signal and let the SDK dedupe (Sentry-class SDKs fingerprint repeats).
# Clone the template
git clone https://github.com/wmzy/painless.git my-app
cd my-app
# Install dependencies
pnpm install
# Start development server
pnpm startpainless/
├── src/
│ ├── components/ # Reusable UI: Loading, RouterError,
│ │ # PreviewLink + Preview, Popover, DevTool (dev-only mock panel)
│ ├── services/ # API layer over http: article.ts, auth.ts
│ ├── types/ # Domain types; base.ts carries JSON Schema annotations
│ ├── typings/ # Ambient declarations (vite.d.ts, schema.d.ts)
│ ├── util/ # http.ts, useQuery.ts, faker.ts
│ ├── views/ # index.tsx (router + routes), Layout/, Home/, Article/,
│ │ # Editor/, Login/, Register/, About/, Help/
│ └── index.tsx # Entry point
├── public/ # Static assets
├── .github/workflows/ # CI (lint, test, build) and Pages deploy
├── vite.config.mts # Vite configuration (@ alias, Linaria, schema plugin)
└── package.json
| Command | Description |
|---|---|
pnpm start |
Start development server (Vite) |
pnpm build |
Build for production |
pnpm serve |
Preview the production build (vite preview) |
pnpm lint |
Run ESLint with auto-fix |
pnpm lint:ci |
Run ESLint without auto-fix (used in CI) |
pnpm test |
Run tests in watch mode (Vitest) |
pnpm test:run |
Run tests once (CI mode) |
pnpm test:ui |
Run tests in the Vitest UI |
pnpm coverage |
Run tests with coverage |
pnpm openapi |
Regenerate src/types/openapi.d.ts from openapi/realworld.yml |
pnpm deploy |
Build the demo and publish to GitHub Pages |
pnpm commit |
Run lint-staged, then an interactive commitizen prompt |
CI runs on every push/PR to main: lint:ci → test:run → build.
Tests use Vitest with Testing Library. Component tests (Home, Editor, Article, PreviewLink, Loading, RouterError) mock the service layer rather than the network, so they exercise real view logic; unit tests cover useQuery, http, faker, and auth directly.
pnpm test:run # all tests
pnpm test:run -- src/util # a directory- @native-router/react - Routing
- react-toolroom - Async data hooks
- fetch-fun - Functional fetch toolkit
- react-f0rm - Event-driven forms
- haze-ui - Component library
- react-use-control - Controlled/uncontrolled state
Contributions are welcome! Please read our contributing guide.