Async & Data Fetching
What You're Used To
React has useEffect + useState for data fetching, plus third-party libraries like TanStack Query or SWR for caching and deduplication. Vue has useFetch composables. These all deal with the same problem: your component renders before the data arrives.
CellUI components execute once, so there's no "render-then-fetch" cycle. Instead, you use signals to model loading states and effect() to trigger fetches.
The Pattern
typescriptimport { signal, effect, view } from '@cmj/cellui';
import { when } from '@cmj/cellui';
function UserProfile(userId: Signal<number>) {
const user = signal<{ name: string } | null>(null);
const loading = signal(true);
const error = signal('');
effect(() => {
const id = userId.value;
loading.value = true;
error.value = '';
fetch(`/api/users/${id}`)
.then(r => r.json())
.then(data => { user.value = data; loading.value = false; })
.catch(e => { error.value = e.message; loading.value = false; });
});
return view`
${when(loading, {
true: () => view`<div class="spinner">Loading...</div>`,
false: () => view`
${when(computed(() => error.value !== ''), {
true: () => view`<div class="error">${error}</div>`,
false: () => view`<div class="profile"><h1>${() => user.value?.name}</h1></div>`,
})}
`,
})}
`;
}Why this works
effect()auto-tracksuserId— when it changes, the fetch re-runs- Setting
loading.value = trueimmediately shows the spinner (O(1) DOM update) - When the fetch resolves, setting
user.valueandloading.valuetriggers thewhen()to swap views - The component function never re-executes — only the reactive bindings update
Batching fetches
If your fetch updates multiple signals, use batch() to avoid intermediate renders:
typescripteffect(() => {
const id = userId.value;
loading.value = true;
fetch(`/api/users/${id}`)
.then(r => r.json())
.then(data => {
batch(() => {
user.value = data;
loading.value = false;
lastFetched.value = Date.now();
});
// One DOM update pass, not three
});
});Reusable fetch helper
Extract the loading/error/data pattern into a helper:
typescriptfunction useFetch<T>(url: Signal<string> | string) {
const data = signal<T | null>(null);
const loading = signal(true);
const error = signal('');
const urlSignal = typeof url === 'string' ? signal(url) : url;
effect(() => {
const u = urlSignal.value;
loading.value = true;
error.value = '';
fetch(u)
.then(r => {
if (!r.ok) throw new Error(`${r.status} ${r.statusText}`);
return r.json();
})
.then(d => batch(() => { data.value = d; loading.value = false; }))
.catch(e => batch(() => { error.value = e.message; loading.value = false; }));
});
return { data, loading, error };
}
// Usage:
const { data, loading, error } = useFetch<User[]>(
computed(() => `/api/users?page=${page.value}`)
);This is not a framework feature — it's a function you write. CellUI doesn't ship a data fetching library because signals and effects are enough to build one in 20 lines.
Polling
typescriptfunction LiveMetrics() {
const metrics = signal<Metric[]>([]);
const refreshRate = signal(5000);
let timer: number;
const fetchMetrics = () => {
fetch('/api/metrics')
.then(r => r.json())
.then(data => { metrics.value = data; });
};
effect(() => {
clearInterval(timer);
timer = setInterval(fetchMetrics, refreshRate.value);
fetchMetrics(); // immediate first fetch
});
const container = document.createElement('div');
CellUI.onDispose(container, () => clearInterval(timer));
container.appendChild(view`
<div>
${each(metrics, 'id', (m) => view`
<div class="metric">${m.name}: ${m.value}</div>
`)}
</div>
`);
return container;
}Notice CellUI.onDispose — when the component is removed from the DOM, the interval stops. No memory leak.
WebSocket / real-time
typescriptfunction ChatMessages() {
const messages = signal<Message[]>([]);
const ws = new WebSocket('wss://api.example.com/chat');
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
messages.value = [...messages.value, msg];
};
const container = document.createElement('div');
CellUI.onDispose(container, () => ws.close());
container.appendChild(view`
<div class="chat">
${each(messages, 'id', (msg) => view`
<div class="message">
<strong>${msg.author}</strong>: ${msg.text}
</div>
`)}
</div>
`);
return container;
}When NOT to use effect for fetching
effect() re-runs whenever any signal it reads changes. If you read multiple signals inside the effect, a change to ANY of them triggers a refetch:
typescript// CAREFUL: refetches when EITHER page OR sortOrder changes
effect(() => {
fetch(`/api?page=${page.value}&sort=${sortOrder.value}`);
});This might be what you want. If not, use untracked() to read signals without tracking:
typescripteffect(() => {
const p = page.value; // tracked — refetch when page changes
const sort = untracked(() => sortOrder.value); // NOT tracked
fetch(`/api?page=${p}&sort=${sort}`);
});
// Only refetches when page changes, reads latest sort at fetch timeTanStack Query integration
For caching, deduplication, and background refetching, use TanStack Query with CellUI signals:
typescriptimport { QueryClient, QueryObserver } from '@tanstack/query-core';
const queryClient = new QueryClient();
function useQuery<T>(queryKey: string[], queryFn: () => Promise<T>) {
const data = signal<T | undefined>(undefined);
const isLoading = signal(true);
const observer = new QueryObserver(queryClient, { queryKey, queryFn });
observer.subscribe(result => {
batch(() => {
data.value = result.data;
isLoading.value = result.isLoading;
});
});
return { data, isLoading };
}This wraps TanStack Query's observer into CellUI signals. You get caching, deduplication, and stale-while-revalidate — with CellUI's O(1) DOM updates.