С любовью ко всем разработчикам на свете ❤️
Point0 runs every query and mutation through TanStack Query's QueryClient. You
create it once and hand it to React Query's provider — Point0 holds it, gives
each SSR request its own instance, and ships its cache to the browser.
// src/lib/query-client.ts
import { createQueryClient } from '@point0/core'
// Safe on both server and client — each SSR request gets its own instance.
export const queryClient = createQueryClient()// src/app.client.tsx
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from '@/lib/query-client'
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Router>
<RouterRoutes />
</Router>
</QueryClientProvider>
)
}That's the whole setup.
createQueryClient returns a proxy, not a clientThe value you export is not a real QueryClient — it's a proxy that always
forwards to whichever real client is current:
export const queryClient = createQueryClient()
// queryClient is typed as QueryClient, but every method call is delegated, at
// call time, to the active client for the current context.On the server, each request runs in its own context with its own QueryClient;
the same queryClient.invalidateQueries(...) line resolves to the right
per-request instance, so one user's data never leaks into another's render. In
the browser there's a single client for the tab, so the proxy always lands on
it.
The optional argument is a function returning a TanStack QueryClientConfig —
Point0 constructs each real client itself and merges your config over its own
defaults, so framework defaults survive app customization instead of being
silently overwritten:
export const queryClient = createQueryClient(() => ({
defaultOptions: { queries: { staleTime: 60_000 } },
}))Top-level keys and defaultOptions.queries keys you set win; defaults you don't
touch stay. Point0 ships one default of its own — the RSC-safe
structuralSharing (element-containing query data is handed back fresh instead
of deep-merged; see RSC) — override it by setting structuralSharing
yourself. Per-query defaults are also settable higher in the chain with
.queryOptions.
Point0 does not render QueryClientProvider for you — mount() only mounts the
points and hydrates the store. Wire the provider yourself in app.client.tsx,
passing the proxy as client:
import { QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { queryClient } from '@/lib/query-client'
;<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={true} />
{/* your app */}
</QueryClientProvider>React Query calls methods on the client you give it; because the proxy forwards each call to the live instance, the provider stays correct across SSR requests without a fresh provider per request.
Most code never touches the client directly — useQuery, fetchQuery,
setQueryData, and the rest live on each query and
mutation point. When you do need the raw client, you have two
routes.
The queryClient module export. Import it anywhere — inside a loader, a
component, an event handler — and call TanStack methods on it. Clear the whole
cache on a sign-out or identity change:
import { queryClient } from '@/lib/query-client'
await authClient.admin.impersonateUser({ userId: id })
queryClient.clear() // drop every cached query
void navigate('profile', undefined, { replace: true })Pair a raw invalidateQueries with a point-scoped predicate to invalidate one
point's queries by hand after a mutation:
import { getQueryPredicate } from '@point0/core'
const { idea } = await ideaCreateMutation.fetch({ title, content })
void queryClient.invalidateQueries({
predicate: getQueryPredicate({ id: ideaListQuery.id }), // every ideaListQuery cache entry
})
ideaViewQuery.setQueryData({ sn: idea.sn }, { idea }) // seed the view cacheFor the common case — every cache entry of one point regardless of input — the
point's own helper is shorter: ideaListQuery.invalidateQuery(true). The same
true (or a (input) => boolean predicate) works on refetchQuery,
removeQuery, resetQuery, cancelQuery and their infinite siblings. Reach
for a raw invalidateQueries + getQueryPredicate when you need to match
across points or by tag.
getQueryClient() returns the real current client (not the proxy) for
the active context:
import { getQueryClient } from '@point0/core'
const client = getQueryClient() // the live QueryClient for this request / tabIt takes no arguments and always resolves the ambient client.
Don't call queryClient at module top level on the server. The proxy
resolves against the active request context, which only exists inside a server
run. Use it inside loaders, components, hooks, and handlers — never at import
time.
getQueryPredicatePoint0 stores every query under a structured key, so you rarely match by raw
TanStack key. getQueryPredicate(options) builds a (query) => boolean
predicate from a point's identity instead — drop it into any TanStack call that
takes one (invalidateQueries, removeQueries, getQueryCache().findAll, …):
import { getQueryPredicate } from '@point0/core'
// Every cache entry belonging to one point
queryClient.getQueryCache().findAll({
predicate: getQueryPredicate({ id: ideaListQuery.id }),
})It only ever matches Point0's own queries (entries keyed under 'point0');
plain TanStack queries you registered yourself are skipped. Every option you
pass is an AND filter — omit an option and it's ignored. The fields mirror a
point's identity:
| Option | Matches |
|---|---|
id | the point id <scope>:<type>:<name> — pass somePoint.id, the cleanest way to target one point |
scope | the point's scope (e.g. 'root') |
type | the point type ('query', 'page', …) |
name | the point name |
tags | the point's tags — see below |
mode | 'server' or 'client' query mode |
finiteness | 'finite' or 'infinite' (an infinite query) |
output | the fetch output type the cache entry holds |
tags accepts three shapes: a single string matches entries carrying that tag;
an array matches only entries that carry all of them; a function
(tags: string[]) => boolean lets you decide:
getQueryPredicate({ tags: 'my-tag' }) // has 'my-tag'
getQueryPredicate({ tags: ['a', 'b'] }) // has BOTH 'a' and 'b'
getQueryPredicate({ tags: (tags) => tags.includes('a') })
// Combine identity filters — AND across options
getQueryPredicate({
scope: 'root',
type: 'query',
name: 'ideaList',
mode: 'client',
})For matching mutations rather than queries, there's a parallel
getMutationPredicate with the same id / scope / type / name / tags
options, returning a (mutation) => boolean predicate for the mutation cache.
{ queryClient }Every imperative cache and fetch helper on a query or mutation takes a trailing
options object with an optional queryClient:
await ideaQuery.fetchQuery({ id }, undefined, { queryClient: someOtherClient })
ideaQuery.invalidateQuery({ id }, undefined, { queryClient: someOtherClient })
const m = ideaCreateMutation.getMutationCache(
{ id: 7 },
{ queryClient: someOtherClient },
)When you omit it — the normal case — the helper uses the ambient global client, which the proxy already resolves to the correct per-request (server) or singleton (client) instance:
await ideaQuery.fetchQuery({ id }) // uses the ambient client — what you want almost alwaysSo { queryClient } is an escape hatch: pass it only when you already hold a
second client and need to act on that one (a manual multi-client setup, or
hand-rolled SSR control). Build the second QueryClient yourself and thread it
through.
Server: one QueryClient per request. Each incoming request gets a fresh
client from your init factory, so caches never mix across users. A nested or
recursive server fetch reuses the parent request's client, so the inner run
shares the same cache instead of starting cold.
Client: one shared QueryClient per tab. The proxy resolves to the same
instance everywhere — which is what QueryClientProvider client={queryClient}
needs.
Multiple apps share one proxy. Even with several mounted apps (different
scopes, e.g. an admin app and a public app on one server), you keep a single
createQueryClient() module. The proxy resolves the right underlying client per
context; you don't create a distinct QueryClient per scope.
The server renders with its request-scoped client, serializes the client's cache into the HTML, and the browser rehydrates it — so the first paint already has the data and the client doesn't refetch what the server already fetched.
You write none of this. The client is registered with Point0's store as a
transferable item, so it rides along automatically: the server injects
window.__POINT0_DEHYDRATED_SUPER_STORE__ into the page, and mount() reads it
back and hydrates on first access. See SSR and the SsrStore
for the transport mechanics.
Two Point0-specific things happen during that roundtrip:
Errors are projected for the wire. Errors sitting in the cache (a failed query) are serialized before they cross to the browser — the public projection in production, the private one in development, where the developer is the audience. This means a production client never receives a server stack trace or private error fields. See Error handling.
Hydrated data is treated as fresh. On hydration Point0 rewrites the cache timestamps to "now", so data that arrived with the HTML isn't immediately considered stale and refetched on mount.
When a page is prefetched via the pageDehydratedState policy, the dehydrated
cache is narrowed to just that page's snapshot, and a re-prefetch from a
still-fresh snapshot reads the current store rather than resurrecting a query
the user removed in the meantime (e.g. getMe after sign-out). Those prefetch
policies live under navigation (and SSR).
When SSR is enabled Point0 server-renders the first page load; only after that
first render does navigation between pages go client-side (SPA-style). Either
way, whatever the server puts in the cache and dehydrates is serialized into the
page and visible in the browser — don't cache secret data expecting it to stay
on the server. Gate access in a .with wrapper (and resolve server-side
identity in .ctx for loaders), and let the error projection above keep
private error detail off the wire.
createQueryClient(getConfig?)createQueryClient(getConfig?: () => QueryClientConfig) // => QueryClient (a proxy)getConfig — optional function returning a TanStack QueryClientConfig.
Point0 builds each real client as
new QueryClient(merge(point0Defaults, getConfig())) — your keys win, the
defaults you don't touch stay (today that's the RSC-safe structuralSharing).
Returning a QueryClient instance throws — the client is Point0's to
construct. You cannot replace a built client instance later (the item is
read-only); you only replace the config, and only by calling
createQueryClient again.queryClient everywhere, including as the
QueryClientProvider client prop.getQueryClient()getQueryClient() // => QueryClient (the real current client)Returns the live client for the active context. No arguments. Only valid inside a server run or in the browser — not at module top level on the server.
getQueryPredicate(options) / getMutationPredicate(options)getQueryPredicate(options) // => (query: Query) => boolean
getMutationPredicate(options) // => (mutation: Mutation) => booleanBuild a predicate that matches Point0's own cache entries by point identity, for
any TanStack call that takes a predicate. Options (all optional,
AND-combined): id (<scope>:<type>:<name>), scope, type, name, tags
(string · all-of array · (tags) => boolean). getQueryPredicate additionally
takes mode ('server'/'client'), finiteness ('finite'/'infinite'),
and output. Non-Point0 entries never match. See
Matching cache entries.
{ queryClient } optionAccepted on the imperative cache/fetch helpers of every query and
mutation — fetchQuery, prefetchQuery, ensureQueryData,
getQueryData, setQueryData, refetchQuery, invalidateQuery,
cancelQuery, removeQuery, resetQuery, getQueryState, getQueryCache,
getQueriesCache, their infinite-query mirrors, and the mutation cache
accessors (getMutationCache, getMutationsCache, fetchMutation). Always
optional; defaults to the ambient client. The position varies by method — it's a
member of the trailing options object, not a positional argument (see the
method tables on Query and Mutation).
The client lives in Point0's store under the name __POINT0_QUERY_CLIENT__. You
normally reach it through the queryClient proxy or getQueryClient(); the raw
item is an advanced surface. It is read-only: you can swap the factory but
not .set a client instance, which is what keeps per-request isolation intact.