Skip to content
IC Reactor

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 { createQuery, createQueryFactory } from "@ic-reactor/react"
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
})
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:

src/reactor.ts
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.

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

Use factories with TanStack Router’s file-based routing and loaders:

// routes/users/$userId.tsx
import { 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>
)
}

Pass the reactor via router context for better organization:

routes/__root.tsx
import { createRootRouteWithContext } from "@tanstack/react-router"
import type { backend } from "../reactor"
interface RouterContext {
backend: typeof backend
}
export const Route = createRootRouteWithContext<RouterContext>()({
component: RootLayout,
})
router.tsx
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
})
routes/posts.tsx
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,
})
routes.tsx
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.tsx
import { 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} />
}

Transform data at the factory level:

import { createQuery } from "@ic-reactor/react"
import { backend } from "./reactor"
// Only get what you need
const 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>
}

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="" />
}

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 />
}

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()
}

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.

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>
)
}

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 args
const aliceQuery = getUserQuery(["alice"])
const bobQuery = getUserQuery(["bob"])
// Use in components
function Friends() {
const { data: alice } = aliceQuery.useQuery()
const { data: bob } = bobQuery.useQuery()
return (
<ul>
<li>{alice?.name}</li>
<li>{bob?.name}</li>
</ul>
)
}

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 mutation
const 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.

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 instances are cached internally:

import { getUserQuery } from "./queries"
// These return the SAME object instance
const query1 = getUserQuery(["alice"])
const query2 = getUserQuery(["alice"])
console.log(query1 === query2) // true

The 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.