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

  1. effect() auto-tracks userId — when it changes, the fetch re-runs
  2. Setting loading.value = true immediately shows the spinner (O(1) DOM update)
  3. When the fetch resolves, setting user.value and loading.value triggers the when() to swap views
  4. 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 time

TanStack 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.