Skip to content

Async Loading States

React has Suspense. Solid has <Suspense> and <Show>. These handle async loading states declaratively.

Rimitive handles async state explicitly through two primitives: resource for reactive data fetching with automatic refetch, and load for simpler async boundaries. Both use match for rendering. No magic, no special components—just reactive state.


The resource primitive 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? }
};

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()!)
)
)
);

The explicit approach is more verbose but clearer about what’s happening.


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?.();
};
// Usage
return el('div')(
show(
() => products.data(),
(data) => ProductGrid(svc, data),
() => Spinner(svc)
)
);

But this is just a function—nothing special about it.


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()!)
);
});
};

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)
)
);
});
};

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 refetches
category('clothing');

Manual refetch is also available:

el('button').props({
onclick: () => products.refetch()
})('Refresh')

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.

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-located
const 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)
)
)
);
};

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
);
};