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
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:
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
@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.