Skip to content

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

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

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

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

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

Resources keep fetching and updating until disposed. Without cleanup, they leak memory:

// ❌ WRONG - resource lives forever, even after component unmounts
const ProductPanel = (svc: Service) => () => {
const products = svc.resource((s) => fetchProducts(s));
return svc.el('div')(/* ... */);
// Resource never disposed!
};
// ✅ CORRECT - dispose when element unmounts
const ProductPanel = (svc: Service) => () => {
const products = svc.resource((s) => fetchProducts(s));
return svc.el('div').ref(() => products.dispose)(/* ... */);
};

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 ones
const 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 cancellation
const 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.

Resources are for reactive data fetching (GET). For mutations (POST, PUT, DELETE), use the asyncAction pattern:

// ❌ WRONG - resource for a mutation doesn't make sense
const 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 mutations
const 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 ready
const 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 exists
const 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...')
);
};