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.
How It Works
Section titled “How It Works”When you call useActorQuery, IC Reactor:
- Generates a unique cache key based on the canister ID, function name, and arguments
- Returns cached data immediately if available
- Fetches fresh data in the background if data is stale
- 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
Section titled “Cache Keys”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.
Custom Query Keys
Section titled “Custom Query Keys”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']}Accessing Query Keys
Section titled “Accessing Query Keys”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.
Stale Time
Section titled “Stale Time”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 })}Garbage Collection Time
Section titled “Garbage Collection Time”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
Automatic Refetching
Section titled “Automatic Refetching”Refetch Interval
Section titled “Refetch Interval”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, })}Refetch Triggers
Section titled “Refetch Triggers”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, })}Manual Invalidation
Section titled “Manual Invalidation”Using Query Objects
Section titled “Using Query Objects”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.
Invalidate All Canister Queries
Section titled “Invalidate All Canister Queries”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.
Invalidate by Function Name
Section titled “Invalidate by Function Name”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 } )}Invalidate Specific Query
Section titled “Invalidate Specific Query”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, } )}Invalidate by Custom Query Key
Section titled “Invalidate by Custom Query Key”import { backend } from "./reactor"
async function refreshVersionedData() { // Invalidate query with custom key await backend.invalidateQueries({ functionName: "get_data", queryKey: ["version", "2"], })}Refetch After Mutation
Section titled “Refetch After Mutation”Using invalidateQueries
Section titled “Using invalidateQueries”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.
Using onSuccess
Section titled “Using onSuccess”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)) }, })}Optimistic Updates
Section titled “Optimistic Updates”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>}Query Object Cache Controls
Section titled “Query Object Cache Controls”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 }.
QueryClient Configuration
Section titled “QueryClient Configuration”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, }, },})Smart Retry Logic
Section titled “Smart Retry Logic”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.
Prefetching
Section titled “Prefetching”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 loaderasync function postsLoader() { await postsQuery.prefetch() return null}
// Or on hoverfunction 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: [] }) ) )}Initial Data
Section titled “Initial Data”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, })}Placeholder Data
Section titled “Placeholder Data”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, }, })}Debug Caching
Section titled “Debug Caching”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
Further Reading
Section titled “Further Reading”- TanStack Query Documentation — Complete TanStack Query docs
- Queries — IC Reactor query patterns
- Mutations — IC Reactor mutation patterns