Data fetching

Server state is a library concern, not a framework concern: TanStack Query does caching, deduping, and invalidation — the framework provides the client through DI and bridges results into signals.

This page documents the pattern the enterprise example ships — the snippets are its real files (src/lib/query.module.ts, src/lib/query.ts, src/pages/users.view.tsx), trimmed for width.

Provide the QueryClient

lib/query.module.ts
import { QueryClient, type QueryClientConfig } from '@tanstack/preact-query';

// "Library as a module" — the same pattern RouterModule.forRoot uses:
// an object with __module metadata can sit in 'imports'.
export class QueryModule {
  static forRoot(config: QueryClientConfig = {}) {
    const client = new QueryClient({
      defaultOptions: { queries: { staleTime: 30_000, retry: 1 } },
      ...config,
    });
    return {
      __module: {
        providers: [{ provide: QueryClient, useValue: client }],
      },
    };
  }
}

Bridge QueryObserver into a signal

QueryObserver is TanStack's headless core — no hooks required, which is exactly what class views want:

lib/query.ts
import { signal, type Signal } from '@preact/signals';
import { effect, getCurrentInjector, DestroyRef } from '@preact-mvc/core';
import { QueryObserver, type QueryObserverOptions,
         type QueryObserverResult } from '@tanstack/preact-query';

export function querySignal<TData, TError = Error>(
  client: QueryClient,
  options: QueryObserverOptions<TData, TError>
         | (() => QueryObserverOptions<TData, TError>),
): Signal<QueryObserverResult<TData, TError>> {
  const getOpts = typeof options === 'function' ? options : () => options;
  const observer = new QueryObserver(client, getOpts());
  const s = signal(observer.getCurrentResult());
  const unsubscribe = observer.subscribe((r) => (s.value = r));
  getCurrentInjector()?.get(DestroyRef).onDestroy(unsubscribe);
  if (typeof options === 'function') {
    effect(() => observer.setOptions(getOpts()));  // reactive keys
  }
  return s;
}

Consume in a view

pages/users.view.tsx
@Component()
export class UsersView extends View {
  private api = inject(FakeApi);
  private qc = inject(QueryClient);
  private search = signal('');

  // Options GETTER → a scope-bound effect re-keys the observer when the
  // debounced search signal changes. One observer, correct cleanup.
  private users = querySignal(this.qc, () => ({
    queryKey: ['users', this.search.value],
    queryFn: () => this.api.users(this.search.value),
  }));

  render() {
    const res = this.users.value;         // QueryObserverResult<User[]>
    if (res.isPending) return <Skeleton />;
    const rows = res.data ?? [];
    return <Table>{rows.map((u) => <TableRow key={u.id}>…</TableRow>)}</Table>;
  }
}

The result is a signal — isPending, isError, data re-render the view as they change. Keys, caching, invalidateQueries, refetch, mutations — all standard TanStack semantics; the framework only owns the subscription boundary.

Why the options getter matters

Static options give you a fixed query. A getter runs inside a scope-bound effect: every signal it reads re-runs the effect on write, calling observer.setOptions — which re-keys the query through TanStack's cache (dedupe + refetch handled upstream). The debounced-search → new-key → filtered-rows chain in the demo is exactly this.

The lifecycle side is the other half: subscriptions registered on the view's DestroyRef die with the view — navigate away and the observer unsubscribes. See Integrating libraries for the general recipe.