Skip to content

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

Draft
KATT wants to merge 12 commits into
TanStack:mainfrom
KATT:cursor/experimental-query-handles-271e
Draft

KATT wants to merge 12 commits into
TanStack:mainfrom
KATT:cursor/experimental-query-handles-271e

Conversation

@KATT

@KATT KATT commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Note

We made this PR with AI help and haven't yet done the two steps in TanStack's AI Contribution Policy. We still need to check that we understand all the code, and to rewrite this description in our own words.

This PR doesn't depend on #12511 and shares no code with it, so it targets main. #12513 adds devtools support on top of this branch.

To see the idea in action, try tRPC's live demo. It switches a blog's loaders between prefetching, ensuring and fetching, so you can see what each one waits for.

Motivation

A route loader (such as TanStack Router's loader) or a Server Component already knows which data a page needs before the page renders, so it prefetches. Today the component that reads the data then has to repeat the query's key and options:

  • The loader and the component each build the same key. If they drift apart, the read misses the prefetched data and fetches again, and nothing warns you. The component can even read a different query and still type-check.
  • Options like queryFn and staleTime have to match in both places, or the loader and the component disagree on how to fetch.
  • A component that builds its own key from props is a request waterfall, but the code looks the same as one that reads prefetched data.

Query handles make the loader's call the single source of truth. The loader starts a query and returns a small serializable handle; the component gets the handle from Route.useLoaderData() and doesn't repeat the key or the options. A query whose input you only know during render gets its handle from experimental_waterfallHandle, so the waterfall shows in the code.

Usage

// A factory: one input in, query options out
const postQuery = (input: { id: string }) =>
  queryOptions({
    queryKey: ['post', input.id] as const,
    queryFn: () => fetchPost(input.id),
  })
const commentsQuery = (input: { postId: string; page: number }) =>
  queryOptions({
    queryKey: ['comments', input.postId, input.page] as const,
    queryFn: () => fetchComments(input),
  })

// The loader starts the queries and returns handles: { queryKey, input }.
// It gets queryClient from createRouter({ routeTree, context: { queryClient } })
export const Route = createFileRoute('/posts/$id')({
  loader: async ({ context: { queryClient }, params }) => ({
    // Waits for data
    post: await experimental_ensureHandle(queryClient, postQuery, {
      id: params.id,
    }),
    // Doesn't wait
    comments: experimental_prefetchHandle(queryClient, commentsQuery, {
      postId: params.id,
      page: 1,
    }),
  }),
  component: PostPage,
})

function PostPage() {
  const { post, comments } = Route.useLoaderData()
  return (
    <article>
      <Title post={post} />
      <Comments comments={comments} />
    </article>
  )
}

// Components read handles: no keys or inputs to repeat, and typed data
function Title(props: { post: experimental_QueryHandle<typeof postQuery> }) {
  const { data: title } = useSuspenseQuery({
    ...experimental_fromHandle(postQuery, props.post),
    select: (post) => post.title,
  })
  return <h1>{title}</h1>
}

// An input known only during render is a waterfall, and the name says so
function Comments(props: {
  comments: experimental_QueryHandle<typeof commentsQuery>
}) {
  const [page, setPage] = React.useState(1)
  const comments = experimental_waterfallHandle(
    commentsQuery,
    props.comments,
    (input) => ({ ...input, page }),
  )
  const { data } = useQuery({
    ...experimental_fromHandle(commentsQuery, comments),
    placeholderData: keepPreviousData,
  })
  return <button onClick={() => setPage(page + 1)}>{data?.length}</button>
}

// Type a prop that takes a query's data
function Byline(props: {
  post: experimental_InferQueryHandleData<typeof postQuery>
}) {
  return <span>{props.post.author}</span>
}

Integrations

tRPC shipped the same idea in its own package as unstable_$prefetch, unstable_$ensure, unstable_$fetch and unstable_$fromHandle (trpc/trpc#7759). Its Prefetching with handles guide has the live demo linked above. With handles in query-core, an integration doesn't need its own version. A procedure's options function is already a factory, so an integration only adds a way to find the factory from a handle's key, and readers stop naming the procedure. trpcLikeProxy.test.tsx builds such a proxy on the public API alone: it starts queries on the server, sends the handles through dehydrate and JSON, and reads them in the browser without naming the procedure again.

🎯 Changes

query-core adds these as experimental_ APIs, and react-query re-exports them. Every existing API works as before.

API

  • experimental_prefetchHandle(queryClient, factory, input) starts a query without waiting and never rejects; 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 it in the background.
  • experimental_fetchHandle waits for fresh data.
  • experimental_fromHandle(factory, handle) returns the factory's options for the handle's input and adds nothing. Spread it to add read-site options like select.
  • experimental_waterfallHandle(factory, input) builds a handle without starting anything; the reader fetches when it renders. experimental_waterfallHandle(factory, handle, switchInput) switches a loader's handle to a new input, and returns the loader's handle as is while the new input maps to the same key.
  • Types: experimental_QueryHandle, experimental_QueryHandleFactory, experimental_FromHandleOptions and experimental_InferQueryHandleData.

Behavior

  • The start functions call queryClient.query, or queryClient.infiniteQuery for options with an initialPageParam, and follow staleTime the same way on server and browser clients.
  • Every handle is a plain { queryKey, input } and carries no data. Readers get the data from the cache: useSuspenseQuery gives defined data, and useQuery types data as possibly undefined, as usual.
  • select infers both at the read site and inside the factory; a read-site select replaces the factory's. experimental_InferQueryHandleData gives the same type as useSuspenseQuery's data, including select and InfiniteData.
  • A handle's type comes from its factory's key, so as const keys keep handles from different factories apart. Only the types check this. The start functions take only the factory's input; passing a factory's extra parameters, like tRPC's queryOptions(input, opts?), is a type error, because the handle couldn't carry them.

Docs

A new guide, framework/react/guides/query-handles, covers the API above, streaming pending queries through shouldDehydrateQuery, server staleTime (with staleTime: 0 the browser refetches on mount), and nested dehydration that sends each query once per request.

Tests

  • query-core handles.test.tsx: the three start functions on browser and server clients, both waterfall forms, a custom queryKeyHashFn, infinite factories, plain handles with no data, 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 that doesn't fetch it again. waterfallHandle.test.tsx covers a reader that fetches once and a loader handle that stays until the input maps to another query.
  • Type tests in queryHandles.test-d.tsx and trpcLikeProxy.test-d.tsx: inputs, keys that keep factories apart, data for every hook and start function, select at the read site and in the factory, and experimental_InferQueryHandleData against useSuspenseQuery's data.
  • trpcLikeProxy.test.tsx: the tRPC-like proxy described above.
  • The new tests clean up with using. This PR adds four small helpers for that to @tanstack/query-test-utils, each with a test: fakeTimers, clearOnDispose, subscribe and unmountOnDispose.

✅ 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).

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>
@coderabbitai

coderabbitai Bot commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

CodeRabbit Verify

  • Run CodeRabbit Verify to test the changes in this pull request.

@changeset-bot

changeset-bot Bot commented Nov 3, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b6bc296

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 24 packages
Name Type
@tanstack/query-core Minor
@tanstack/react-query Minor
@tanstack/angular-query-experimental Minor
@tanstack/lit-query Patch
@tanstack/preact-query Minor
@tanstack/query-async-storage-persister Minor
@tanstack/query-broadcast-client-experimental Minor
@tanstack/query-devtools Minor
@tanstack/query-persist-client-core Minor
@tanstack/query-sync-storage-persister Minor
@tanstack/solid-query Minor
@tanstack/svelte-query Patch
@tanstack/vue-query Minor
@tanstack/react-query-devtools Minor
@tanstack/react-query-next-experimental Minor
@tanstack/react-query-persist-client Minor
@tanstack/preact-query-devtools Minor
@tanstack/preact-query-persist-client Minor
@tanstack/solid-query-devtools Minor
@tanstack/svelte-query-devtools Patch
@tanstack/vue-query-devtools Patch
@tanstack/solid-query-persist-client Minor
@tanstack/svelte-query-persist-client Patch
@tanstack/eslint-plugin-query Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

This branch has not been deployed

No deployments
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