Reference
@lumiose/query
TanStack Query for Lumiose, with Solid Query's API and results in reactive stores.
An optional package, not part of lumiose: install it with pnpm add @lumiose/query. It is a thin
adapter. Caching, deduplication, retries, stale times, invalidation and refetching on focus or
reconnect come from @tanstack/query-core, so the TanStack Query docs
apply as written. Concepts: Signals and reactivity,
Data and actions.
#When to use it
Lumiose reads data without a library: async components await it, fetchJSON
fetches JSON, and invalidate() and submit() refresh what a page shows. Those awaits are cached:
fetchJSON by URL and query(key, fn) by key, deduplicated,
stale-while-revalidate, invalidated by URL or key prefix, and sent from the server to islands with
the page. Prefer the built-in primitives when:
- data is read by async components and refreshed by form actions or
invalidate(target), - pages render on the server (
@lumiose/querydoes not fetch there, see Server rendering).
Use @lumiose/query for client-side data (spa routes and islands) that needs what a client cache
gives you:
- many components reading the same data, fetched once and kept in sync,
- polling (
refetchInterval), refetching on window focus and reconnect, per-querystaleTime, - retries, pagination with
placeholderData: keepPreviousData, dependent queries (enabled), - mutations with optimistic updates and rollback (
onMutate,onError), - a team or code that already knows TanStack Query.
#QueryClientProvider
function QueryClientProvider(props: { client: QueryClient; children?: JSX.Element }): JSX.Element;Provides client to everything rendered inside it, through a context. Once mounted it calls client.mount(), which refetches on window focus and reconnect and resumes
paused mutations, and client.unmount() when it is disposed. A client is usually created once, at
module scope:
import { QueryClient, QueryClientProvider } from "@lumiose/query";
const queryClient = new QueryClient();
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Books />
</QueryClientProvider>
);
}Islands start a new root and do not see context provided outside them: wrap each island that uses queries in its own provider. Pass the same module-scope client to share one cache between islands.
#useQueryClient
function useQueryClient(queryClient?: QueryClient): QueryClient;queryClient if given, else the client of the nearest QueryClientProvider. Throws "No
QueryClient set, use QueryClientProvider to set one" when there is none. Call it while the
component sets up, and use the client later (client.invalidateQueries(...) in a handler).
#useQuery
function useQuery<TQueryFnData, TError, TData, TQueryKey>(
options: () => QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
queryClient?: () => QueryClient,
): UseQueryResult<TData, TError>;Subscribes to a query. options takes every QueryObserverOptions
field except structuralSharing, plus reconcile (below). It is an accessor: when a signal it
reads changes, such as a part of the key or enabled, the query switches to the new options.
function Book(props: { id: number }) {
const book = useQuery(() => ({
queryKey: ["book", props.id],
queryFn: () => fetchJSON<Book>(`/api/books/${props.id}`),
}));
return (
<article>
{book.isPending ? (
<p>Loading…</p>
) : book.isError ? (
<p>{book.error.message}</p>
) : (
<h2>{book.data.title}</h2>
)}
{book.isFetching && <small>Refreshing…</small>}
</article>
);
}The result is a store with the fields of TanStack's QueryObserverResult
(data, error, status, isPending, isFetching, refetch, …). Read fields where they are
used, as with any store: destructuring (const { data } = book) reads once and loses updates.
- Structural sharing through
reconcile. A newdatais written into the store withreconcile(): array items are matched byid, unchanged objects keep their identity, and only the fields that changed notify. List rows and effects that read an unchanged item do not re-run after a refetch.reconcile: "slug"matches by another key;reconcile: falsestoresdataas the cache holds it (replaced on each change);reconcile: (old, next) => datadecides. query-core's ownstructuralSharingis turned off. - The store holds a copy of
data. The cache's object is shared by every observer of the query, and a store writes into the object it wraps. Writing tobook.datatherefore changes this component only: update the cache withclient.setQueryData(). - Lifetime. The observer subscribes after the component mounts, so the fetch starts then, and
it unsubscribes when the component (its owner) is disposed. What
queryFnreads is not tracked. - Two
useQuerycalls with the same key share one fetch and one cache entry.
#useMutation
function useMutation<TData, TError, TVariables, TOnMutateResult>(
options: () => MutationOptions<TData, TError, TVariables, TOnMutateResult>,
queryClient?: () => QueryClient,
): UseMutationResult<TData, TError, TVariables, TOnMutateResult>;A mutation. The result is a store with TanStack's MutationObserverResult fields (status,
isPending, data, error, variables, reset, …), plus:
mutate(variables, { onSuccess, onError, onSettled }?): runsmutationFn. It never rejects; errors go toerrorand the callbacks.mutateAsync(variables, callbacks?): the same, as a promise that rejects on error.
function AddBook() {
const client = useQueryClient();
const add = useMutation(() => ({
mutationFn: (title: string) =>
fetchJSON<Book>("/api/books", { method: "POST", body: JSON.stringify({ title }) }),
onSuccess: () => client.invalidateQueries({ queryKey: ["books"] }),
}));
return (
<button disabled={add.isPending} onClick={() => add.mutate("Dune")}>
{add.isPending ? "Adding…" : "Add"}
</button>
);
}#Re-exports
Everything @tanstack/query-core exports is re-exported, as by @tanstack/solid-query:
QueryClient, QueryCache, MutationCache, skipToken, keepPreviousData, hashKey, its types,
and so on. The adapter's own types: QueryOptions, UseQueryOptions, UseQueryResult,
DefinedUseQueryResult, UndefinedInitialDataOptions, DefinedInitialDataOptions,
MutationOptions, UseMutationOptions, UseMutationResult, UseMutateFunction,
UseMutateAsyncFunction and QueryClientProviderProps. With initialData,
useQuery's result is typed DefinedUseQueryResult: data is never undefined.
#Server rendering
Browser first: SPA routes and islands. In a server render, effects never run, so queries do not
subscribe and never fetch. useQuery shows what the client's cache already holds, otherwise the
pending state, and QueryClientProvider does not mount the client.
- Render the same thing on the server and in the browser's first render. With an empty cache on both sides, both render the pending state and the island fetches once it hydrates.
- A module-scope client on the server is shared by every request. Create one per request, or render only the pending state on the server.
Dehydrating the server's cache into the page (TanStack's dehydrate/hydrate) is not supported
yet.
#Not supported yet
<Suspense>: queries do not suspend, and thesuspenseoption has no effect. RenderisPendinginstead.throwOnError: errors stay in the result instead of reaching an<ErrorBoundary>.useQueries,useInfiniteQuery,useIsFetching,useIsMutating,useMutationStateandqueryOptions(). The observers they wrap (QueriesObserver,InfiniteQueryObserver) are re-exported from query-core.