Skip to content

feat(query-core, react-query): add experimental query handles - #2

Closed
KATT wants to merge 12 commits into
masterfrom
cursor/experimental-query-handles-271e
Closed

KATT wants to merge 12 commits into
masterfrom
cursor/experimental-query-handles-271e

Conversation

@KATT

@KATT KATT commented Nov 1, 2026 •

Copy link
Copy Markdown
Owner

This PR doesn't depend on #1 and shares no code with it, so it targets master. #3 adds the devtools support on top of this branch.

🎯 Changes

This PR adds query handles to query-core as experimental_ APIs, and react-query re-exports them. Every existing API works as before; the new behavior only applies to queries that these functions start.

A route loader or Server Component starts queries from a query factory (a function that takes one input, or none, and returns query options). It then passes a small serializable handle ({ queryKey, input }) to the components that read the queries.

const postQuery = (input: { id: string }) =>
  queryOptions({
    queryKey: ['post', input.id] as const,
    queryFn: () => fetchPost(input.id),
    staleTime: 60 * 1000,
  })

// loader
const viewer = await experimental_ensureHandle(queryClient, viewerQuery)
const post = experimental_prefetchHandle(queryClient, postQuery, { id })

// component
const { data } = useSuspenseQuery({
  ...experimental_fromHandle(postQuery, props.post),
  select: (post) => post.title,
})

query-core

  • experimental_prefetchHandle(qc, factory, input) starts a query without waiting and doesn't reject: fetch errors show up where a component reads the handle. experimental_ensureHandle waits for any data: it resolves at once with stale cached data and refetches in the background. experimental_fetchHandle waits for fresh data. All three call queryClient.query, or queryClient.infiniteQuery when the options have an initialPageParam, and they follow staleTime the same way on server and browser clients.
  • Every handle is the same plain { queryKey, input }. A handle from experimental_ensureHandle or experimental_fetchHandle means the cache had the query's data when the loader finished; the handle itself carries no data.
  • The handle functions take only the factory's input. A factory may have more parameters, like tRPC's queryOptions(input, opts?), but passing them is a type error, because the handle only carries the input and experimental_fromHandle couldn't pass the rest on.
  • experimental_fromHandle(factory, handle) rebuilds the options, and adds nothing to them. You spread it and add read-site options like select, which the factory types. A select inside the factory works too. Readers get the data from the cache, so useQuery types data as TData | undefined, as usual, and useSuspenseQuery gives defined data.
  • A handle's type comes from its factory's key, so handles from two factories only mix when their keys have the same type (as const keeps them apart). Only the types check this; nothing checks at runtime. The handle's key type drops the DataTag and keeps optional elements.
  • experimental_waterfallHandle builds a handle for a query whose input you only know during render. It's a plain function, not a hook, and it starts nothing: the component that reads the handle fetches the query, as useQuery or useSuspenseQuery always does. It has two forms:
experimental_waterfallHandle(searchQuery, { term })
experimental_waterfallHandle(postsQuery, props.posts, (input) => ({ ...input, page }))

While the new input maps to the same key (by the factory's queryKeyHashFn), the second form returns the handle you passed in, so the first page reads the query the loader started. That also keeps the old input when only fields outside the key change, so everything the queryFn reads belongs in the key.

  • experimental_InferQueryHandleData<typeof postQuery> gives the data a reader gets, without calling a hook, for example to type a child component's prop. It's the same type as useSuspenseQuery's data: what the factory's select returns, or else the queryFn's data, which is InfiniteData for an infinite factory. It takes a factory type or a handle type.
  • The package exports four types: experimental_QueryHandle, experimental_QueryHandleFactory, experimental_FromHandleOptions and experimental_InferQueryHandleData. The tRPC-like proxy tests below build on the first three alone. The other helper types stay internal, so they can change while the API is experimental.

react-query

No new hooks: react-query re-exports the handle functions from query-core, and they work with every existing hook.

Docs

A new guide, framework/react/guides/query-handles, with a nav entry. It covers:

  • the three start functions, fromHandle with read-site options, and experimental_waterfallHandle
  • that select infers both at the read site and inside the factory, and that a read-site select replaces the factory's
  • experimental_InferQueryHandleData for typing a prop that takes the data
  • that handles carry no data, and that useSuspenseQuery gives defined data while useQuery data stays possibly undefined, since the cache may lose the data after the loader ran
  • how to stream pending queries by including them in shouldDehydrateQuery, and that this needs a transport that can send promises
  • server staleTime: this works as before, so with staleTime: 0 the browser refetches on mount. The guide shows the double fetch and how to avoid it.
  • nested dehydration without sending a query twice: track the query hashes you've sent for each request (with React cache(), or next to the request's QueryClient). The guide also shows a module-level version to avoid and explains why it breaks.

Tests

  • query-core: handles.test.tsx, including both experimental_waterfallHandle forms, a custom queryKeyHashFn, experimental_fetchHandle with an infinite factory, the three start functions on a server client, awaited handles that are plain { queryKey, input }, experimental_fromHandle adding no initialData, and a reader with no data after queryClient.clear()
  • react-query: queryHandles.test.tsx streams a pending prefetched query through dehydrate and HydrationBoundary to a useSuspenseQuery reader, which doesn't fetch it again.
  • react-query: waterfallHandle.test.tsx covers a reader that fetches once, a loader's handle that stays until the new input maps to another query, and an awaited handle's cached data on the first render. queryHandles.test-d.tsx covers input types, a factory's extra parameters (@ts-expect-error), optional inputs, optional key elements, useQuery data that is possibly undefined and useSuspenseQuery data that is defined for handles from every verb, the same for infinite queries, keys that keep factories apart, and both waterfall forms.
  • react-query: queryHandles.test-d.tsx also checks select with useQuery, useSuspenseQuery and the infinite hooks. A read-site select spread over the options gets the queryFn's data with no annotation, and read-site placeholderData and enabled keep their types. A select inside the factory types the readers' data, works with all four handle functions, and leaves the cache data typed as the queryFn's data. A read-site select over a factory that already selects replaces it and gets the queryFn's data.
  • react-query: queryHandles.test-d.tsx and trpcLikeProxy.test-d.tsx check that experimental_InferQueryHandleData equals useSuspenseQuery's or useSuspenseInfiniteQuery's data for a plain factory, a selecting factory, an infinite factory, an infinite factory with select, a handle type and a tRPC-like procedure, and that it types a prop which useQuery's possibly undefined data doesn't fit.
  • react-query: trpcLikeProxy.test.tsx and trpcLikeProxy.test-d.tsx build a small RPC-style options proxy on the public API alone, to check that integrations like tRPC's can build on these functions. They test the start functions on the server, a JSON round trip through dehydrate and hydrate, a reader that finds the procedure from the handle's key, defined data through useSuspenseQuery, a spread select, experimental_waterfallHandle switching, and an infinite procedure that loads two pages up front.
  • The new tests clean up with using instead of beforeEach, afterEach or manual calls: using _timers = fakeTimers(), using queryClient = clearOnDispose(new QueryClient()), using _subscription = subscribe(observer, noop) and using rendered = unmountOnDispose(renderWithClient(...)). This PR adds these four helpers, each with a test, to @tanstack/query-test-utils; the first three match fix(query-core): use a hydrated query's current 'retry' options when it rejects #1's copies byte for byte. Declare a render after its client, and the render unmounts before the client clears.

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested code changes locally with pnpm run test:pr, or these tests do not apply to this pull request. (Ran oxlint, knip, test:types across all TS versions, and vitest for query-core, react-query and query-test-utils, plus the docs link check.)
  • I have followed the AI contribution policy and fully understand the code in this pull request, including any code generated with AI assistance.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).
Open in Web Open in Cursor 

@github-actions

github-actions Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

🚀 Changeset Version Preview

2 package(s) bumped directly, 22 bumped as dependents.

🟨 Minor bumps

Package Version Reason
@tanstack/query-core 5.105.2 → 5.106.0 Changeset
@tanstack/react-query 5.105.2 → 5.106.0 Changeset
@tanstack/angular-query-experimental 5.105.2 → 5.106.0 Dependent
@tanstack/eslint-plugin-query 5.105.2 → 5.106.0 Dependent
@tanstack/preact-query 5.105.2 → 5.106.0 Dependent
@tanstack/preact-query-devtools 5.105.2 → 5.106.0 Dependent
@tanstack/preact-query-persist-client 5.105.2 → 5.106.0 Dependent
@tanstack/query-async-storage-persister 5.105.2 → 5.106.0 Dependent
@tanstack/query-broadcast-client-experimental 5.105.2 → 5.106.0 Dependent
@tanstack/query-devtools 5.105.2 → 5.106.0 Dependent
@tanstack/query-persist-client-core 5.105.2 → 5.106.0 Dependent
@tanstack/query-sync-storage-persister 5.105.2 → 5.106.0 Dependent
@tanstack/react-query-devtools 5.105.2 → 5.106.0 Dependent
@tanstack/react-query-next-experimental 5.105.2 → 5.106.0 Dependent
@tanstack/react-query-persist-client 5.105.2 → 5.106.0 Dependent
@tanstack/solid-query 5.105.2 → 5.106.0 Dependent
@tanstack/solid-query-devtools 5.105.2 → 5.106.0 Dependent
@tanstack/solid-query-persist-client 5.105.2 → 5.106.0 Dependent
@tanstack/vue-query 5.105.2 → 5.106.0 Dependent

🟩 Patch bumps

Package Version Reason
@tanstack/lit-query 0.2.33 → 0.2.34 Dependent
@tanstack/svelte-query 6.3.8 → 6.3.9 Dependent
@tanstack/svelte-query-devtools 6.3.8 → 6.3.9 Dependent
@tanstack/svelte-query-persist-client 6.3.8 → 6.3.9 Dependent
@tanstack/vue-query-devtools 6.3.8 → 6.3.9 Dependent

@cursor
cursor Bot force-pushed the cursor/experimental-query-handles-271e branch from facad3e to 07baafb Compare November 1, 2026 23:54
@cursor
cursor Bot changed the base branch from cursor/hydrated-rejection-retry-271e to master November 1, 2026 23:55
@cursor cursor Bot changed the title feat: experimental query handles and 'useWaterfall' feat(query-core, react-query): add experimental query handles Nov 2, 2026
cursoragent and others added 12 commits November 2, 2026 19:56
…bles for 'using' in tests

Co-authored-by: alexander <alexander@n1s.se>
…eWaterfall'

Co-authored-by: alexander <alexander@n1s.se>
Co-authored-by: alexander <alexander@n1s.se>
…' with a plain 'experimental_waterfallHandle' whose reader starts the query

Co-authored-by: alexander <alexander@n1s.se>
Adds an 'unmountOnDispose' helper to @tanstack/query-test-utils, so a render declared after its client unmounts before the client is cleared, and splits the helper tests into one file per helper.

Co-authored-by: alexander <alexander@n1s.se>
… copy

Co-authored-by: alexander <alexander@n1s.se>
Also keep optional elements in a handle's key type, and test streaming a
pending query, infinite fetchHandle and server clients. The guide now
covers promise transports and data that goes out twice.

Co-authored-by: alexander <alexander@n1s.se>
Keep experimental_QueryHandle, experimental_QueryHandleWithData,
experimental_QueryHandleFactory and experimental_FromHandleOptions. The
guide and tests now cover a handle's data seeding the cache again after
clear(), removeQueries or resetQueries, as any initialData does.

Co-authored-by: alexander <alexander@n1s.se>
Every handle is now a plain { queryKey, input }. experimental_ensureHandle
and experimental_fetchHandle still wait for the data, but return the same
handle as experimental_prefetchHandle, and experimental_fromHandle adds no
initialData. Readers that need defined data use useSuspenseQuery.

Co-authored-by: alexander <alexander@n1s.se>
…Handle

Covers a read-site select spread over the options, a select inside the
factory, and a read-site select over a factory that already selects, for
useQuery, useSuspenseQuery and the infinite hooks.

Co-authored-by: alexander <alexander@n1s.se>
It gives the data a reader of a factory's query gets, the same type as
useSuspenseQuery's data, so a component can type a prop that takes it.

Co-authored-by: alexander <alexander@n1s.se>
@cursor
cursor Bot force-pushed the cursor/experimental-query-handles-271e branch from e3a2b96 to b6bc296 Compare November 2, 2026 20:00
@KATT

KATT commented Nov 3, 2026

Copy link
Copy Markdown
Owner Author

Moved to TanStack#12512.

@KATT KATT closed this Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants