createQuery
createQuery creates a reusable query object with pre-configured options. Perfect for route loaders, centralized query definitions, and cache manipulation outside React components.
Import
Section titled “Import”import { createQuery, createQueryFactory } from "@ic-reactor/react"Basic Usage
Section titled “Basic Usage”import { createQuery } from "@ic-reactor/react"import { backend } from "./reactor"
const userProfileQuery = createQuery(backend, { functionName: "get_profile", args: [currentUserId], staleTime: 5 * 60 * 1000, // 5 minutes})Configuration
Section titled “Configuration”| Option | Type | Default | Description |
|---|---|---|---|
functionName |
string |
Required | The canister method to call |
args |
Args |
[] |
Arguments for the method |
select |
(data) => Selected |
- | Transform data before returning |
staleTime |
number |
300000 |
Time before data is stale (ms) |
queryKey |
QueryKey |
Auto | Custom query key segments |
callConfig |
CallConfig |
- | Canister, agent or effective target for the call |
The config also takes TanStack Query’s query options. networkMode, retry,
retryDelay and meta decide how the canister call runs, so they apply to
fetch() and prefetch() as well as to useQuery(). The rest, such as
enabled, placeholderData, initialData and gcTime, apply to the hook
only. The config’s select is applied by the query object itself, so
fetch(), getCacheData() and useQuery() all return the selected value;
setData still takes the raw value.
callConfig goes where it goes in useActorQuery: to the call and into the
query key. A canisterId there sends the query to another canister of the
same interface and caches it under that canister, so one reactor can back
query objects for several ledgers. The examples below use a Reactor of an
ICRC-1 ledger, built next to backend; being a raw Reactor, it takes an
account’s owner as a Principal:
import { defineReactor } from "@ic-reactor/react"import { canisterId, idlFactory, type _SERVICE as LedgerService,} from "./declarations/ledger"
export const { reactor: ledger } = defineReactor<LedgerService>({ name: "ledger", idlFactory, canisterId,})import { createQueryFactory } from "@ic-reactor/react"import { ledger } from "./reactor"
const ckbtcBalance = createQueryFactory(ledger, { functionName: "icrc1_balance_of", callConfig: { canisterId: "mxzaz-hqaaa-aaaar-qaada-cai" },})getQueryKey(), invalidate() and the other cache controls act on that
entry, and a factory’s own getQueryKey() is rooted at that canister. For a
whole reactor per canister, with every query and mutation on it, see
forCanister.
Return Value
Section titled “Return Value”The factory returns an object with these utilities:
| Property | Type | Description |
|---|---|---|
fetch |
() => Promise<T> |
Cache-first fetch — use in route loaders |
prefetch |
() => Promise<void> |
Fire-and-forget cache warm-up |
useQuery |
(options?) => UseQueryResult |
React hook for components |
invalidate |
() => Promise<void> |
Invalidate cache (refetches if active) |
getQueryKey |
() => QueryKey |
Get the TanStack Query key |
getCacheData |
(select?) => T |
Read from cache without fetching |
setData |
(updater) => T | undefined |
Write raw data into cache |
cancel |
() => Promise<void> |
Cancel this query’s fetch in flight |
reset |
() => Promise<void> |
Reset to initial state; mounted hooks refetch |
optimisticUpdate |
(updater) => Promise<{ rollback }> |
Cancel, write updater(cached), return a rollback |
Examples
Section titled “Examples”TanStack Router Integration
Section titled “TanStack Router Integration”Use factories with TanStack Router’s file-based routing and loaders:
// routes/users/$userId.tsximport { createFileRoute } from "@tanstack/react-router"import { createQueryFactory } from "@ic-reactor/react"import { backend } from "../../reactor"import { redirect } from "@tanstack/react-router"
// Create factory (can be in a separate file)const getUserQuery = createQueryFactory(backend, { functionName: "get_user", select: (user) => user.profile, staleTime: 5 * 60 * 1000,})
export const Route = createFileRoute("/users/$userId")({ // Prefetch in loader - runs before component mounts loader: async ({ params }) => { const user = await getUserQuery([params.userId]).fetch()
if (!user) { throw redirect({ to: "/404" }) }
return { user } },
// Optional: Define pending component pendingComponent: () => <div>Loading user...</div>,
// Main component component: UserPage,})
function UserPage() { const params = Route.useParams()
// Data is already cached from loader - instant render! The type still // includes `undefined`, as it does for every `useQuery()`. const { data: user, isRefetching } = getUserQuery([params.userId]).useQuery()
return ( <div> <h1>{user?.name}</h1> {isRefetching && <span>Refreshing...</span>} </div> )}TanStack Router with Route Context
Section titled “TanStack Router with Route Context”Pass the reactor via router context for better organization:
import { createRootRouteWithContext } from "@tanstack/react-router"import type { backend } from "../reactor"
interface RouterContext { backend: typeof backend}
export const Route = createRootRouteWithContext<RouterContext>()({ component: RootLayout,})import { createRouter } from "@tanstack/react-router"import { backend } from "./reactor"import { routeTree } from "./routeTree.gen"
export const router = createRouter({ routeTree, context: { backend }, defaultPreloadStaleTime: 0, // Let TanStack Query handle caching})import { createFileRoute } from "@tanstack/react-router"import { createQuery } from "@ic-reactor/react"
export const Route = createFileRoute("/posts")({ loader: async ({ context }) => { const postsQuery = createQuery(context.backend, { functionName: "get_posts", }) return { posts: await postsQuery.fetch() } }, component: PostsPage,})React Router 6/7 Integration
Section titled “React Router 6/7 Integration”import { createBrowserRouter, type LoaderFunctionArgs } from "react-router-dom"import { createQueryFactory } from "@ic-reactor/react"import { backend } from "./reactor"
const getUserQuery = createQueryFactory(backend, { functionName: "get_user",})
async function loader({ params }: LoaderFunctionArgs) { if (!params.userId) throw new Response("Not Found", { status: 404 }) return { user: await getUserQuery([params.userId]).fetch() }}
export const router = createBrowserRouter([ { path: "/users/:userId", loader, Component: UserPage, },])
// UserPage.tsximport { skipToken } from "@ic-reactor/react"import { useLoaderData, useParams } from "react-router-dom"
function UserPage() { // Initial data from loader const { user: initialUser } = useLoaderData() as Awaited< ReturnType<typeof loader> > const { userId } = useParams()
// Subscribe to updates const { data: user } = getUserQuery(userId ? [userId] : skipToken).useQuery({ initialData: initialUser, })
return <Profile user={user} />}Select Transformation
Section titled “Select Transformation”Transform data at the factory level:
import { createQuery } from "@ic-reactor/react"import { backend } from "./reactor"
// Only get what you needconst userNameQuery = createQuery(backend, { functionName: "get_user", args: [userId], select: (user) => user.name, // Only extract name})
function UserName() { // data is just the name string const { data: userName } = userNameQuery.useQuery()
return <span>{userName}</span>}Chained Select in Hook
Section titled “Chained Select in Hook”Chain additional transformations in the hook:
import { createQuery } from "@ic-reactor/react"import { backend } from "./reactor"
const userQuery = createQuery(backend, { functionName: "get_user", args: [userId], select: (user) => user.profile, // First transformation})
function Avatar() { // Further transform in the component const { data: avatarUrl } = userQuery.useQuery({ select: (profile) => profile.avatarUrl, // Chained transformation })
return <img src={avatarUrl} alt="" />}Conditional Fetching
Section titled “Conditional Fetching”Use enabled option for conditional queries:
import { createQuery } from "@ic-reactor/react"import { backend, useAuth } from "./reactor"
const myProfileQuery = createQuery(backend, { functionName: "get_my_profile", args: [],})
function ProtectedComponent() { const { isAuthenticated } = useAuth()
const { data } = myProfileQuery.useQuery({ enabled: isAuthenticated, // Only fetch when authenticated })
return isAuthenticated ? <ProfileCard profile={data} /> : <LoginPrompt />}Cache Manipulation
Section titled “Cache Manipulation”Read and manipulate cache outside React:
import { createQuery } from "@ic-reactor/react"import { backend } from "./reactor"
const userQuery = createQuery(backend, { functionName: "get_user", args: [userId],})
async function manageCache() { // Read from cache (returns undefined if not cached) const cachedUser = userQuery.getCacheData()
// Read with transformation const cachedName = userQuery.getCacheData((user) => user.name)
// Force invalidation (refetches if query is active) await userQuery.invalidate()
// Write directly into cache userQuery.setData(updatedUser)
// Or use an updater function. `old` is undefined when nothing is cached yet. userQuery.setData((old) => (old ? { ...old, name: "Alice" } : old))
// Cancel the fetch in flight, or reset the entry to its initial state await userQuery.cancel() await userQuery.reset()}Optimistic Updates
Section titled “Optimistic Updates”optimisticUpdate cancels the query’s fetch in flight, so an older answer
cannot land on top, writes what the updater returns for the cached value, and
resolves with a rollback. Return it from a mutation’s onMutate:
import { createMutation } from "@ic-reactor/react"import { backend } from "./reactor"import { getUserQuery } from "./queries"
const renameUser = createMutation(backend, { functionName: "rename_user", onMutate: ([userId, name]) => getUserQuery([userId]).optimisticUpdate((user) => ({ ...user, name })), onError: (_error, _args, update) => update?.rollback(), invalidateQueries: [getUserQuery],})The updater gets the raw data, before the config’s select, and is typed
from the method. It is not called when nothing is cached, and rollback()
then does nothing. rollback() writes back the replaced value with its
original fetch time, and invalidates it again if it was invalidated. After a
sign-in or sign-out it does nothing: the value was the previous principal’s.
Refetch the query once the mutation settles, as invalidateQueries does
here, since the fetch optimisticUpdate cancels may be one an invalidation
started.
cancel(), reset() and optimisticUpdate() act on this query’s own entry
only. invalidate() matches by key prefix, so on a query object without args
it also reaches the same method’s queries with args; these three do not.
Prefetching on Hover
Section titled “Prefetching on Hover”Prefetch data when the user hovers over a link. Use prefetch() (fire-and-forget)
rather than fetch() here — you don’t need to await a result, just warm the cache:
import { Link } from "@tanstack/react-router"import { getUserQuery } from "./queries"
function UserLink({ userId }: { userId: string }) { const handleMouseEnter = () => { // Fire-and-forget cache warm-up getUserQuery([userId]).prefetch() }
return ( <Link to={`/users/${userId}`} onMouseEnter={handleMouseEnter}> View User </Link> )}createQueryFactory
Section titled “createQueryFactory”For dynamic arguments (like route params), use the factory variant:
import { createQueryFactory } from "@ic-reactor/react"import { backend } from "./reactor"
const getUserQuery = createQueryFactory(backend, { functionName: "get_user", staleTime: 5 * 60 * 1000,})
// Create query instances with different argsconst aliceQuery = getUserQuery(["alice"])const bobQuery = getUserQuery(["bob"])
// Use in componentsfunction Friends() { const { data: alice } = aliceQuery.useQuery() const { data: bob } = bobQuery.useQuery()
return ( <ul> <li>{alice?.name}</li> <li>{bob?.name}</li> </ul> )}Every Instance at Once
Section titled “Every Instance at Once”The factory function itself has getQueryKey() and invalidate(). They cover
every query it returns, whatever the args:
import { createMutation } from "@ic-reactor/react"import { backend } from "./reactor"import { getUserQuery } from "./queries"
async function refreshUsers() { // Refetch every user on screen await getUserQuery.invalidate()
// The key prefix those queries share, for QueryClient calls of your own return backend.queryClient.getQueriesData({ queryKey: getUserQuery.getQueryKey(), })}
// Or hand the factory to a mutationconst renameUser = createMutation(backend, { functionName: "rename_user", invalidateQueries: [getUserQuery],})| Property | Type | Description |
|---|---|---|
getQueryKey |
() => QueryKey |
The method’s key prefix, shared by every instance |
invalidate |
() => Promise<void> |
Invalidate every instance (active ones refetch) |
The prefix is the canister (the config’s callConfig.canisterId, else the
reactor’s) and the method, so it also matches a query of the same method made
elsewhere, such as with useActorQuery. A config queryKey comes after the
args in each instance’s key, so it does not narrow the prefix.
Waiting for Args
Section titled “Waiting for Args”The factory also takes TanStack Query’s skipToken, re-exported by
@ic-reactor/react, in place of args. It returns a query with only
useQuery(), which waits without calling the canister, so a component whose
args are not known yet needs no placeholder args and no enabled:
import { createQueryFactory, skipToken } from "@ic-reactor/react"import type { Principal } from "@icp-sdk/core/principal"import { ledger } from "./reactor"
// `ledger` is a Reactor of the ICRC-1 ledger, so an account's owner is a// Principal (a DisplayReactor's takes the principal's text)const getBalance = createQueryFactory(ledger, { functionName: "icrc1_balance_of",})
function Balance({ owner }: { owner?: Principal }) { const { data } = getBalance( owner ? [{ owner, subaccount: [] }] : skipToken ).useQuery() return <span>{data?.toString() ?? "…"}</span>}The skipped query has a cache entry of its own under the factory’s
getQueryKey() prefix, which no call’s key shares. Its imperative members
(fetch(), invalidate(), the cache controls) are left out, since there is
no call or entry until the args are known: check the args before reaching for
them. createSuspenseQueryFactory does not take skipToken.
Factory Caching
Section titled “Factory Caching”Factory instances are cached internally:
import { getUserQuery } from "./queries"
// These return the SAME object instanceconst query1 = getUserQuery(["alice"])const query2 = getUserQuery(["alice"])
console.log(query1 === query2) // trueThe cache holds the 256 most recently used argument sets. That covers any realistic working set, but a factory called with an open-ended range of arguments — one query per principal in a long-lived session, a search-as-you-type filter, a cursor-keyed list — will eventually evict the oldest entries rather than grow without bound.
Eviction costs memoization, not correctness: a query object is a closure over the reactor and its config, so one rebuilt after eviction behaves identically and only its reference identity differs. It matters only if you compare instances or put one in a dependency array and cycle through more than 256 distinct argument sets.
See Also
Section titled “See Also”- createSuspenseQuery — Suspense version
- createInfiniteQuery — Paginated queries
- useActorQuery — Direct hook usage
- Queries Guide — Query patterns