Skip to content
IC Reactor

Query Caching

IC Reactor uses TanStack Query under the hood for intelligent caching and data synchronization. This guide covers everything you need to know about how caching works.

When you call useActorQuery, IC Reactor:

  1. Generates a unique cache key based on the canister ID, function name, and arguments
  2. Returns cached data immediately if available
  3. Fetches fresh data in the background if data is stale
  4. Automatically updates the UI when new data arrives

This pattern is often called “stale-while-revalidate” and provides the best user experience by showing data immediately while keeping it fresh.

Cache keys are automatically generated from your query parameters:

import { useActorQuery } from "./reactor/hooks"
function Users() {
// Query key: ['rrkah-fqaaa...', 'get_user', '["user-123"]']
const { data: user123 } = useActorQuery({
functionName: "get_user",
args: ["user-123"],
})
// Query key: ['rrkah-fqaaa...', 'get_user', '["user-456"]']
const { data: user456 } = useActorQuery({
functionName: "get_user",
args: ["user-456"],
})
}

Each unique combination of canister ID, function name, and arguments creates a separate cache entry. The whole args array is serialized into a single string segment, argsJson — arguments are never spread into separate elements. It is JSON with BigInt values written as decimal strings and the keys of every plain object sorted, so { b, a } and { a, b } share an entry, and a bare JSON.stringify(args) does not match it once a record’s fields are out of alphabetical order. A blob is written as a tag followed by its lowercase hex, so a Uint8Array, a number[] and a DisplayReactor’s hex text holding the same bytes share an entry too. A DisplayReactor writes each opt and variant in one form, whichever it was given in, so an opt given bare or as [value], none given as null, undefined or [], and a variant given with or without _type share an entry. A vec record { text; T } given to it as an object or a Map is written as its entries in order, since the order is part of what it sends. A record or variant given to it as a class instance is written as the plain object its codec sends, a field its class supplies through a getter included. Fields a record does not declare are left out and every value of reserved is written as null, since neither is sent, and a DisplayReactor writes a float or an integer of 32 bits or fewer given as text as its number, and a Principal as its text. An argument the reactor refuses, such as undefined where Candid null is required or a bigint where a DisplayReactor takes text, is written behind a tag of its own, so the call fails instead of being answered from the cache entry of an argument it takes.

The keys above are a Reactor’s: [canisterId, functionName, argsJson]. A DisplayReactor adds a { transform: "display" } segment after the function name — ['rrkah-fqaaa...', 'get_user', { transform: 'display' }, '["user-123"]'] — so a Reactor and a DisplayReactor over one canister never share a cache entry. Build keys with generateQueryKey (below) or a query object’s getQueryKey() rather than by hand.

You can add custom keys for more granular cache control:

import { useActorQuery } from "./reactor/hooks"
function VersionedData() {
const { data } = useActorQuery({
functionName: "get_data",
args: [],
queryKey: ["version", "2"], // Spread onto the end of the auto-generated key
})
// Full key: ['rrkah-fqaaa...', 'get_data', '[]', 'version', '2']
}

Use generateQueryKey to get the cache key for any query:

import { Principal } from "@icp-sdk/core/principal"
import { backend } from "./reactor"
const queryKey = backend.generateQueryKey(
{
functionName: "get_user",
args: ["user-123"],
},
{
canisterId: otherCanisterId,
effectiveCanisterId: Principal.fromText("aaaaa-aa"), // the management canister
}
)
// [otherCanisterId, 'get_user', { effectiveTarget: { canisterId: 'aaaaa-aa' } }, '["user-123"]']

Note that callConfig.canisterId replaces the reactor’s own canister ID in the first segment, and the effective target is wrapped in an effectiveTarget object. That segment is dropped entirely when the effective canister ID equals the resolved canister ID, so a call that targets only its own canister produces the plain [canisterId, functionName, argsJson] key (with the { transform: "display" } segment before argsJson on a DisplayReactor).

If a query uses callConfig, pass the same callConfig when generating, reading, or invalidating that cache key. Cache entries are partitioned by the resolved target canister, the optional effective target, and the agent.

A query sent through callConfig.agent (another identity, the anonymous agent, another network) gets an { agent: n } segment after the transform segment and before argsJson, where n numbers that agent within the running process. So the same query through that agent and through the ClientManager’s own agent are two cache entries, and each is fetched from the canister. Keys without an override, or whose override is the manager’s own agent, have no such segment. invalidateQueries({ functionName }) still matches both entries, and updateAgent() still sweeps the override’s entries with the rest of the canister’s. Each process counts from a random start, so an override entry in a dehydrated server cache, or in a cache persisted and restored later, is not found by another process’s agents.

staleTime determines how long data is considered fresh. Fresh data is never refetched:

import { useActorQuery } from "./reactor/hooks"
function AppInfo() {
// Data is fresh for 5 minutes
const { data: config } = useActorQuery({
functionName: "get_config",
args: [],
staleTime: 1000 * 60 * 5, // 5 minutes
})
// Data is never stale (static data that never changes)
const { data: metadata } = useActorQuery({
functionName: "get_metadata",
args: [],
staleTime: Infinity,
})
// Data is immediately stale (default behavior)
const { data: price } = useActorQuery({
functionName: "get_price",
args: [],
staleTime: 0, // Refetch on every mount
})
}

gcTime determines how long unused data stays in cache before being garbage collected:

import { useActorQuery } from "./reactor/hooks"
function Cached({ userId }: { userId: string }) {
// Keep unused data for 10 minutes (default is 5 minutes)
const { data: user } = useActorQuery({
functionName: "get_user",
args: [userId],
gcTime: 1000 * 60 * 10,
})
// Keep data forever
const { data: config } = useActorQuery({
functionName: "get_config",
args: [],
gcTime: Infinity,
})
}

The difference between staleTime and gcTime:

  • Stale data can be served immediately while fetching fresh data
  • Garbage collected data is removed entirely and must be fetched again

Automatically refetch data at a set interval:

import { useActorQuery } from "./reactor/hooks"
function Prices() {
// Refetch every 10 seconds
const { data: price } = useActorQuery({
functionName: "get_price",
args: [],
refetchInterval: 1000 * 10,
})
// Only refetch when window is focused
const { data: focusedPrice } = useActorQuery({
functionName: "get_price",
args: [],
refetchInterval: 1000 * 10,
refetchIntervalInBackground: false,
})
}
import { useActorQuery } from "./reactor/hooks"
function Notifications() {
const { data } = useActorQuery({
functionName: "get_notifications",
args: [],
// Refetch when window regains focus (default: true)
refetchOnWindowFocus: true,
// Refetch when component mounts (default: true)
refetchOnMount: true,
// Refetch when reconnecting to network (default: true)
refetchOnReconnect: true,
})
}

A query object and a query factory invalidate their own queries:

import { createQueryFactory } from "@ic-reactor/react"
import { backend } from "./reactor"
const getUser = createQueryFactory(backend, { functionName: "get_user" })
function RefreshButton({ userId }: { userId: string }) {
const handleRefresh = () => {
// One user's query
void getUser([userId]).invalidate()
}
const handleRefreshAll = () => {
// Every getUser query this factory made, whatever the args
void getUser.invalidate()
}
return (
<>
<button onClick={handleRefresh}>Refresh</button>
<button onClick={handleRefreshAll}>Refresh all users</button>
</>
)
}

getUser.getQueryKey() is the key prefix those queries share, for any other QueryClient call. For the client itself use backend.queryClient: the hooks bind to the reactor’s own client, and useQueryClient() throws when there is no QueryClientProvider, which is optional.

import { backend } from "./reactor"
async function refreshEverything() {
// Invalidate ALL queries for this canister
await backend.invalidateQueries()
}

invalidateQueries returns TanStack Query’s Promise<void>, which resolves once the active queries it matched have refetched; a failed refetch does not reject it. Await it when the next step should read the refetched data, and prefix a call you do not wait for with void if you lint with no-floating-promises.

import { backend } from "./reactor"
async function refreshUsers() {
// Invalidate all get_user queries (any args)
await backend.invalidateQueries({ functionName: "get_user" })
// Invalidate get_user queries for a specific overridden target canister
await backend.invalidateQueries(
{ functionName: "get_user" },
{ canisterId: otherCanisterId }
)
}
import { backend } from "./reactor"
async function refreshUser() {
// Invalidate get_user query for specific user
await backend.invalidateQueries(
{
functionName: "get_user",
args: ["user-123"],
},
{
canisterId: otherCanisterId,
}
)
}
import { backend } from "./reactor"
async function refreshVersionedData() {
// Invalidate query with custom key
await backend.invalidateQueries({
functionName: "get_data",
queryKey: ["version", "2"],
})
}

The simplest way to refetch related data after a mutation:

import { useActorMutation } from "./reactor/hooks"
function useCreatePost() {
return useActorMutation({
functionName: "create_post",
invalidateQueries: [
{ functionName: "get_posts" }, // every get_posts query, whatever its args
{ functionName: "get_post_count" },
],
})
}

An entry can also be a query object or a query factory ([postsQuery], [getUser]), or a query key. The invalidation is awaited before onSuccess runs. See Mutations.

For more control, use onSuccess. Here the new post goes straight into the cached list instead of being refetched:

import { createQuery } from "@ic-reactor/react"
import { backend } from "./reactor"
import { useActorMutation } from "./reactor/hooks"
const postsQuery = createQuery(backend, { functionName: "get_posts" })
function useCreatePost() {
return useActorMutation({
functionName: "create_post",
onSuccess: (newPost) => {
// `old` is undefined when the list is not cached yet
postsQuery.setData((old) => (old ? [...old, newPost] : old))
},
})
}

Update the UI immediately, then sync with the server:

import { createQueryFactory } from "@ic-reactor/react"
import { backend } from "./reactor"
import { useActorMutation } from "./reactor/hooks"
const getPost = createQueryFactory(backend, { functionName: "get_post" })
function LikeButton({ postId }: { postId: string }) {
const { mutate } = useActorMutation({
functionName: "like_post",
// Cancel the post's fetch in flight, write the new value, keep a rollback
onMutate: ([postId]) =>
getPost([postId]).optimisticUpdate((post) => ({
...post,
likes: post.likes + 1n,
})),
// Roll back on error
onError: (_error, _args, update) => update?.rollback(),
// Always refetch after error or success
onSettled: (_data, _error, [postId]) => getPost([postId]).invalidate(),
})
return <button onClick={() => mutate([postId])}>Like</button>
}

Every query object (createQuery, createSuspenseQuery, the infinite variants and factory instances) acts on its own cache entry:

Method What it does
optimisticUpdate(updater) Cancels the fetch in flight, writes updater(cached), resolves with { rollback() }
cancel() Cancels the fetch in flight; the entry keeps the value it held before
reset() Clears the entry (or restores initialData); a mounted hook refetches, a suspense one suspends
setData(updater) Writes a raw value, or updater(cached), with no cancel and no rollback (not on infinite ones)
invalidate() Marks it stale; a mounted hook refetches

cancel(), reset() and optimisticUpdate() match the query’s key exactly, so other args of the same method are left alone. An infinite query’s optimisticUpdate gets and returns its { pages, pageParams }.

Set global defaults for all queries:

import { QueryClient } from "@tanstack/react-query"
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60, // 1 minute default
gcTime: 1000 * 60 * 5, // 5 minutes
retry: 2, // Retry failed queries twice
retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
refetchOnWindowFocus: true,
},
mutations: {
retry: 1,
},
},
})

Retrying helps only when the failure could change next time — a transport fault, a transient replica rejection, an HTTP 5xx, 408 or 429 answer. A canister Err variant, a validation failure, a Candid encode error, or an HTTP 4xx refusal such as an expired delegation’s is deterministic, and retrying it spends several attempts and seconds of backoff to reach the same failure. IC Reactor ships this classification as reactorRetry, which is already the query default in the QueryClient that defineReactor creates. Opt in on a QueryClient you construct yourself:

import { reactorRetry } from "@ic-reactor/react"
import { QueryClient } from "@tanstack/react-query"
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: reactorRetry,
},
},
})

For custom attempt counts, build your own predicate on isRetryableReactorError — see Error Handling — Retry Configuration.

An update method run through a query is narrowed further: without a retry of its own, it retries only a SysTransient rejection, since any other retry could execute it a second time. See Update Methods in Queries.

Prefetch data before it’s needed:

import { Link } from "react-router-dom"
import { createQuery, createQueryFactory } from "@ic-reactor/react"
import { backend } from "./reactor"
const postsQuery = createQuery(backend, { functionName: "get_posts" })
const getPost = createQueryFactory(backend, { functionName: "get_post" })
// In a route loader
async function postsLoader() {
await postsQuery.prefetch()
return null
}
// Or on hover
function PostLink({ postId }: { postId: string }) {
const handleMouseEnter = () => {
void getPost([postId]).prefetch()
}
return (
<Link to={`/posts/${postId}`} onMouseEnter={handleMouseEnter}>
View Post
</Link>
)
}

prefetch() never rejects, and it fetches again for the new principal when a sign-in or sign-out cancels it mid-flight, as fetch() does. A prefetch you write against the reactor’s QueryClient yourself gets no such second chance: wrap it in clientManager.fetchAcrossIdentitySwitch, or the entry can end up holding the previous principal’s data.

import { backend } from "./reactor"
async function prefetchPosts() {
await backend.clientManager.fetchAcrossIdentitySwitch(() =>
backend.queryClient.prefetchQuery(
backend.getQueryOptions({ functionName: "get_posts", args: [] })
)
)
}

Provide initial data while fresh data loads:

import { useActorQuery } from "./reactor/hooks"
function Settings() {
const { data } = useActorQuery({
functionName: "get_settings",
args: [],
initialData: {
theme: "light",
notifications: true,
},
// Don't count initial data as fresh
initialDataUpdatedAt: 0,
})
}

Show placeholder data that doesn’t get cached:

import { useActorQuery } from "./reactor/hooks"
function PostPreview({ postId }: { postId: string }) {
const { data } = useActorQuery({
functionName: "get_post",
args: [postId],
placeholderData: {
id: postId,
title: "Loading...",
content: "",
likes: 0n,
},
})
}

Enable React Query DevTools to inspect the cache:

import { QueryClientProvider } from "@tanstack/react-query"
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}

The DevTools show:

  • All cached queries
  • Their status (fresh, stale, fetching)
  • Cache keys
  • Last updated time
  • Ability to manually trigger refetches