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/query-core';
// "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 framework binding required, which is exactly what class views want:
import { signal, type Signal, effect,
getCurrentInjector, DestroyRef } from '@solid-mvc/core';
import { QueryObserver, type QueryObserverOptions,
type QueryObserverResult } from '@tanstack/query-core';
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() {
// Reads stay inside JSX — the tracked spots update on every
// observer push; the render body itself runs once.
return (
<Show when={!this.users.value.isPending} fallback={<Skeleton />}>
<Table>
<For each={this.users.value.data ?? []}>
{(u) => <TableRow>…</TableRow>}
</For>
</Table>
</Show>
);
}
}The result is a signal — JSX reads of isPending, isError, data update the DOM 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.