Async Loading States
Rimitive handles async state through two modules: resource for reactive data fetching with automatic refetch, and load for simpler async boundaries. Both use match for rendering.
The Resource Pattern
Section titled “The Resource Pattern”The resource module wraps async operations and exposes their state reactively:
const ProductList = (svc: Service) => () => { const { el, resource, match } = svc;
const products = resource((signal) => fetch('/api/products', { signal }).then(r => r.json()) );
// products() returns { status: 'pending' | 'ready' | 'error', value?, error? }};Rendering Different States
Section titled “Rendering Different States”Use match to render based on state:
return match(products, (state) => { switch (state.status) { case 'pending': return Spinner(svc); case 'error': return ErrorMessage(svc, state.error); case 'ready': return ProductGrid(svc, state.value); }});Or use the convenience accessors:
return el('div')( match(products.loading, (loading) => loading ? Spinner(svc) : match(products.error, (error) => error ? ErrorMessage(svc, error) : ProductGrid(svc, products.data()!) ) ));A Show Helper
Section titled “A Show Helper”If you want something closer to Solid’s <Show>, create a simple helper:
const show = <T, R>( when: () => T | null | undefined | false, render: (value: T) => R, fallback?: () => R): R | undefined => { const value = when(); if (value) return render(value); return fallback?.();};
// Usagereturn el('div')( show( () => products.data(), (data) => ProductGrid(svc, data), () => Spinner(svc) ));Multiple Resources
Section titled “Multiple Resources”When loading multiple resources, handle them together:
const Dashboard = (svc: Service) => () => { const { el, resource, computed, match } = svc;
const user = resource((s) => fetchUser(s)); const stats = resource((s) => fetchStats(s)); const notifications = resource((s) => fetchNotifications(s));
// Combine loading states const allLoading = computed(() => user.loading() || stats.loading() || notifications.loading() );
const anyError = computed(() => user.error() || stats.error() || notifications.error() );
return match(allLoading, (loading) => { if (loading) return Spinner(svc)();
const error = anyError(); if (error) return ErrorMessage(svc)(error);
return el('div')( UserHeader(svc)(user.data()!), StatsPanel(svc)(stats.data()!), NotificationList(svc)(notifications.data()!) ); });};Dependent Resources
Section titled “Dependent Resources”When one resource depends on another:
const ProductDetail = (svc: Service) => () => { const { el, resource, computed, match } = svc;
const productId = svc.params.id; // from router
const product = resource((s) => fetch(`/api/products/${productId()}`, { signal: s }).then(r => r.json()) );
// Reviews depend on product being loaded const reviews = resource((s) => { const p = product.data(); if (!p) return Promise.resolve([]); // No product yet, return empty
return fetch(`/api/products/${p.id}/reviews`, { signal: s }) .then(r => r.json()); });
return match(product, (state) => { if (state.status !== 'ready') { return state.status === 'pending' ? Spinner(svc)() : ErrorMessage(svc)(state.error); }
return el('div')( ProductInfo(svc)(state.value), match(reviews, (reviewState) => reviewState.status === 'pending' ? el('div')('Loading reviews...') : reviewState.status === 'error' ? el('div')('Failed to load reviews') : ReviewList(svc)(reviewState.value) ) ); });};Refetching
Section titled “Refetching”Resources track dependencies and refetch automatically:
const category = signal('electronics');
const products = resource((s) => fetch(`/api/products?category=${category()}`, { signal: s }) .then(r => r.json()));
// Change category -> products automatically refetchescategory('clothing');Manual refetch is also available:
el('button').props({ onclick: () => products.refetch()})('Refresh')The Load Pattern
Section titled “The Load Pattern”For simpler async boundaries—especially in SSR scenarios—use load():
const ProductList = (svc: Service) => () => { const { el, load, match } = svc;
return load( () => fetch('/api/products').then(r => r.json()), (state) => match(state.status, (status) => { switch (status) { case 'pending': return Spinner(svc)(); case 'error': return ErrorMessage(svc)(state.error()); case 'ready': return ProductGrid(svc)(state.data()!); } }) );};The key difference: load() takes a fetcher and a renderer. The renderer receives a state object with reactive properties (status, data, error) that you read by calling them.
When to use each
Section titled “When to use each”Use resource when you need:
- Automatic refetching when dependencies change
- AbortController integration for request cancellation
- A reactive value you can pass around and read anywhere
Use load when you need:
- Simple one-shot data fetching
- SSR streaming with explicit data boundaries
- Direct control over the loading UI in one place
// load() is ideal for SSR - data and UI are co-locatedconst Page = (svc: Service) => () => { const { el, load, match } = svc;
return el('main')( load( () => fetchPageData(), (state) => match(state.status, (status) => status === 'ready' ? PageContent(svc)(state.data()!) : status === 'error' ? ErrorFallback(svc)() : LoadingSkeleton(svc)() ) ) );};Cleanup
Section titled “Cleanup”Resources should be disposed when no longer needed:
const ProductPanel = (svc: Service) => () => { const { el, resource } = svc;
const products = resource((s) => fetchProducts(s));
// Cleanup when element is removed return el('div').ref(() => products.dispose)( // ... content );};Anti-patterns
Section titled “Anti-patterns”Don’t Ignore Loading and Error States
Section titled “Don’t Ignore Loading and Error States”Only rendering the “ready” case leads to a broken UI during loading or when errors occur:
// ❌ WRONG - crashes when data isn't ready, no error handlingconst ProductList = (svc: Service) => () => { const { el, resource, map } = svc;
const products = resource((s) => fetchProducts(s));
// products.data() is undefined while loading! return el('div')( map(products.data()!, (p) => ProductCard(svc)(p)) );};// ✅ CORRECT - handle all states explicitlyconst ProductList = (svc: Service) => () => { const { el, resource, match, map } = svc;
const products = resource((s) => fetchProducts(s));
return match(products, (state) => { switch (state.status) { case 'pending': return Spinner(svc)(); case 'error': return ErrorMessage(svc)(state.error); case 'ready': return el('div')( map(state.value, (p) => ProductCard(svc)(p)) ); } });};Don’t Forget Resource Cleanup
Section titled “Don’t Forget Resource Cleanup”Resources keep fetching and updating until disposed. Without cleanup, they leak memory:
// ❌ WRONG - resource lives forever, even after component unmountsconst ProductPanel = (svc: Service) => () => { const products = svc.resource((s) => fetchProducts(s));
return svc.el('div')(/* ... */); // Resource never disposed!};// ✅ CORRECT - dispose when element unmountsconst ProductPanel = (svc: Service) => () => { const products = svc.resource((s) => fetchProducts(s));
return svc.el('div').ref(() => products.dispose)(/* ... */);};Don’t Ignore the AbortSignal
Section titled “Don’t Ignore the AbortSignal”Resources pass an AbortSignal to cancel in-flight requests. Ignoring it causes race conditions when dependencies change rapidly:
// ❌ WRONG - ignores signal, old requests may resolve after new onesconst Search = (svc: Service) => () => { const query = svc.signal('');
const results = svc.resource(() => // No signal parameter! fetch(`/api/search?q=${query()}`).then(r => r.json()) );};// ✅ CORRECT - pass signal to fetch for automatic cancellationconst Search = (svc: Service) => () => { const query = svc.signal('');
const results = svc.resource((signal) => fetch(`/api/search?q=${query()}`, { signal }).then(r => r.json()) );};When query changes, the previous fetch is automatically aborted.
Don’t Use Resource for Mutations
Section titled “Don’t Use Resource for Mutations”Resources are for reactive data fetching (GET). For mutations (POST, PUT, DELETE), use the asyncAction pattern:
// ❌ WRONG - resource for a mutation doesn't make senseconst DeleteButton = (svc: Service) => () => { const deletion = svc.resource((s) => fetch('/api/item', { method: 'DELETE', signal: s }) ); // This fetches immediately on mount!
return svc.el('button')(/* ??? */);};// ✅ CORRECT - use asyncAction for imperative mutationsconst DeleteButton = (svc: Service) => () => { const { el, signal } = svc;
const pending = signal(false); const error = signal<Error | null>(null);
const deleteItem = async () => { pending(true); error(null); try { await fetch('/api/item', { method: 'DELETE' }); } catch (e) { error(e instanceof Error ? e : new Error(String(e))); } finally { pending(false); } };
return el('button').props({ onclick: deleteItem, disabled: pending, })('Delete');};Don’t Access .data() Without Checking Status
Section titled “Don’t Access .data() Without Checking Status”The data() accessor returns undefined until the resource is ready. Using it directly without guards causes runtime errors:
// ❌ WRONG - data() is undefined until readyconst UserProfile = (svc: Service) => () => { const user = svc.resource((s) => fetchUser(s));
// This crashes during loading! return svc.el('div')(user.data()!.name);};// ✅ CORRECT - use match to ensure data existsconst UserProfile = (svc: Service) => () => { const { el, resource, match } = svc; const user = resource((s) => fetchUser(s));
return match(user, (state) => state.status === 'ready' ? el('div')(state.value.name) : state.status === 'error' ? el('div')('Error loading user') : el('div')('Loading...') );};