From TanStack Query FULL
TanStack Query is a cache and a scheduler, and the request itself is yours to write. data-query does the same caching and scheduling and also makes the request, so the three things you write by hand there, the request, the optimistic update, and the rollback, are declarations here.
The shape of the translation
A useQuery call becomes a query declaration plus an element that binds it. The declaration is global and made once, not per component render.
// TanStack Query
const { data, isPending, error } = useQuery({
queryKey: ['articles', page],
queryFn: () => fetch(`/api/articles?page=${page}`).then(r => r.json()),
staleTime: 30_000,
refetchOnWindowFocus: true
});
// WildflowerJS
wildflower.query('articles', {
from: '/api/articles',
key: 'id',
params: () => ({ page: route.page }),
refresh: ['focus', 'fresh:30']
});
<ul data-query="articles">
<template><li data-bind="title"></li></template>
</ul>
<p data-show="$articles.isLoading">Loading…</p>
<p data-show="$articles.error" data-bind="$articles.error"></p>
There is no hook, so there is no render to be inside of and no dependency array to get right. The element is the subscription.
Option by option
| TanStack Query | WildflowerJS | Notes |
|---|---|---|
queryKey | the query's name | A name, not an array. See "What you give up" below. |
queryFn | from | Takes a URL string as well as a function. |
enabled | nothing to write | A read whose :token has no value waits instead of firing. |
staleTime | 'fresh:N' rung | Gates the event rungs; poll keeps its own cadence. |
refetchInterval | a bare number rung | refresh: [30]. SECONDS, not milliseconds. |
refetchOnWindowFocus | 'focus' rung | |
refetchOnReconnect | 'reconnect' rung | |
retry | retry: N | Fixed doubling curve, no policy object. |
select | a computed | See the naming trap below. |
initialData | initial | |
gcTime | nothing | Queries are named entities and are not garbage collected. |
queryClient.invalidateQueries | wildflower.invalidateQueries(...names) | Names only, with no tags or patterns. |
select is a false friend. In TanStack Query it projects cached data for one component and can differ per consumer. Here it runs once on the response and defines what the rows are, closer to RTK Query's transformResponse. The TanStack equivalent, refining rows for display, is a computed that reads the query. Reads inside computeds track automatically, so the chain re-runs when rows arrive.
// The TanStack `select` role, done as a computed
wildflower.component('catalog', {
state: { filter: '' },
computed: {
visible() {
const q = wildflower.getQuery('articles'); // auto-tracks
return q.rows.filter(a => a.title.includes(this.filter));
}
}
});
Mutations are where the two differ most
TanStack Query's optimistic updates are imperative. You cancel in-flight queries, snapshot the cache, write the optimistic value, and reverse it yourself if the mutation fails. Its own reference is explicit that no automatic state reversal occurs.
// TanStack Query: you own the whole cycle
useMutation({
mutationFn: (task) => api.patch(`/tasks/${task.id}`, task),
onMutate: async (task) => {
await queryClient.cancelQueries({ queryKey: ['tasks'] });
const previous = queryClient.getQueryData(['tasks']);
queryClient.setQueryData(['tasks'], (old) =>
old.map(t => t.id === task.id ? { ...t, ...task } : t));
return { previous };
},
onError: (err, task, context) => {
queryClient.setQueryData(['tasks'], context.previous);
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ['tasks'] })
});
// WildflowerJS: the same behavior, declared
wildflower.query('tasks', {
from: '/api/tasks',
key: 'id',
to: '/api/tasks/:id',
body: item => item
});
wildflower.getQuery('tasks').write({ id: 42, done: true });
The rollback is also finer grained. A whole-cache snapshot restores everything as it was, so a second edit made while the first was in flight is wiped out along with the failure. Here each write claims the fields it changed, and a rejection reverts only the fields that write still owns. Tick a checkbox, rename the row while the tick is still out, and a failed tick reverts done while the rename stays exactly as typed.
There is no onSettled invalidation to write, because a write with no confirmation refetches on its own. Writes never retry automatically, because a failed POST that the server may already have committed looks identical from the client to one that never arrived.
What you give up
Addressable per-key caching. Returning to a page you have already visited paints immediately in both. A query keeps the rows of the last few resolved URLs in memory and repaints from them while it revalidates, so back-navigation does not wait out a round trip. What TanStack has on top of that is a cache you can address. queryKey makes each entry addressable, so you can read it with getQueryData, write it with setQueryData, invalidate it selectively, prefetch it, and set its lifetime.
One behavioral difference remains. TanStack's entries all stay live, so two components can show page 1 and page 2 at the same time. A query holds one result set, and its cache is a repaint source rather than a second live copy. Declare the two views as separate named queries, which is how the rest of the framework handles two separate things. For pages meant to stack rather than replace, append accumulates them.
A dedicated prefetch API, suspense integration, paused-and-resumed offline mutations, and the tag graph for invalidation are also absent. Retries, focus and reconnect revalidation, polling, SSR handoff, devtools, and reload persistence via persist are present and roughly equivalent.
What you gain
The transport is declared rather than written, so a URL, a verb, and the origins that receive your credentials are readable from the declaration instead of buried in a function body. There is no framework requirement, provider to mount, or build step, and there is one entry point instead of seven hooks.