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:

// Query key: ['rrkah-fqaaa...', 'getUser', '["user-123"]']
const { data } = useActorQuery({
functionName: "getUser",
args: ["user-123"],
})
// Query key: ['rrkah-fqaaa...', 'getUser', '["user-456"]']
const { data } = useActorQuery({
functionName: "getUser",
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 with JSON.stringify (BigInt values become decimal strings), so the key is always [canisterId, functionName, argsJson] — arguments are never spread into separate elements.

You can add custom keys for more granular cache control:

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

Use generateQueryKey to get the cache key for any query:

import { backend } from "./reactor"
const queryKey = backend.generateQueryKey({
functionName: "getUser",
args: ["user-123"],
}, {
canisterId: otherCanisterId,
effectiveCanisterId: managementCanisterId,
})
// [otherCanisterId, 'getUser', { 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.

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 and optional effective target.

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

// Data is fresh for 5 minutes
const { data } = useActorQuery({
functionName: "getConfig",
args: [],
staleTime: 1000 * 60 * 5, // 5 minutes
})
// Data is never stale (static data that never changes)
const { data } = useActorQuery({
functionName: "getMetadata",
args: [],
staleTime: Infinity,
})
// Data is immediately stale (default behavior)
const { data } = useActorQuery({
functionName: "getPrice",
args: [],
staleTime: 0, // Refetch on every mount
})

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

// Keep unused data for 10 minutes (default is 5 minutes)
const { data } = useActorQuery({
functionName: "getUser",
args: [userId],
gcTime: 1000 * 60 * 10,
})
// Keep data forever
const { data } = useActorQuery({
functionName: "getConfig",
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:

// Refetch every 10 seconds
const { data } = useActorQuery({
functionName: "getPrice",
args: [],
refetchInterval: 1000 * 10,
})
// Only refetch when window is focused
const { data } = useActorQuery({
functionName: "getPrice",
args: [],
refetchInterval: 1000 * 10,
refetchIntervalInBackground: false,
})
const { data } = useActorQuery({
functionName: "getNotifications",
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,
})

Invalidate queries directly with the QueryClient:

import { useQueryClient } from "@tanstack/react-query"
import { backend } from "./reactor"
function RefreshButton() {
const queryClient = useQueryClient()
const handleRefresh = () => {
// Invalidate a specific query
queryClient.invalidateQueries({
queryKey: backend.generateQueryKey({
functionName: "getUser",
args: ["user-123"],
}),
})
}
return <button onClick={handleRefresh}>Refresh</button>
}
// Invalidate ALL queries for this canister
backend.invalidateQueries()
// Invalidate all getUser queries (any args)
backend.invalidateQueries({ functionName: "getUser" })
// Invalidate getUser queries for a specific overridden target canister
backend.invalidateQueries(
{ functionName: "getUser" },
{ canisterId: otherCanisterId }
)
// Invalidate getUser query for specific user
backend.invalidateQueries({
functionName: "getUser",
args: ["user-123"],
}, {
canisterId: otherCanisterId,
})
// Invalidate query with custom key
backend.invalidateQueries({
functionName: "getData",
queryKey: ["version", "2"],
})

The simplest way to refetch related data after a mutation:

import { backend } from "./reactor"
const { mutate } = useActorMutation({
functionName: "createPost",
invalidateQueries: [
backend.generateQueryKey({ functionName: "getPosts" }),
backend.generateQueryKey({ functionName: "getPostCount" }),
],
})

For more control, use onSuccess:

import { useQueryClient } from "@tanstack/react-query"
import { backend } from "./reactor"
function CreatePost() {
const queryClient = useQueryClient()
const { mutate } = useActorMutation({
functionName: "createPost",
onSuccess: (newPost) => {
// Invalidate posts list
queryClient.invalidateQueries({
queryKey: backend.generateQueryKey({ functionName: "getPosts" }),
})
// Or directly update the cache
queryClient.setQueryData(
backend.generateQueryKey({ functionName: "getPosts" }),
(old: Post[]) => [...old, newPost]
)
},
})
}

Update the UI immediately, then sync with the server:

import { useQueryClient } from "@tanstack/react-query"
import { backend } from "./reactor"
function LikeButton({ postId }: { postId: string }) {
const queryClient = useQueryClient()
const queryKey = backend.generateQueryKey({
functionName: "getPost",
args: [postId],
})
const { mutate } = useActorMutation({
functionName: "likePost",
onMutate: async () => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey })
// Snapshot the previous value
const previous = queryClient.getQueryData(queryKey)
// Optimistically update
queryClient.setQueryData(queryKey, (old: Post) => ({
...old,
likes: old.likes + 1,
}))
// Return context with previous value
return { previous }
},
onError: (err, variables, context) => {
// Rollback on error
if (context?.previous) {
queryClient.setQueryData(queryKey, context.previous)
}
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({ queryKey })
},
})
return <button onClick={() => mutate([postId])}>Like</button>
}

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. A canister Err variant, a validation failure, or a Candid encode error 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"
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: reactorRetry,
},
},
})

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

Prefetch data before it’s needed:

import { backend } from "./reactor"
// In a route loader
async function postsLoader() {
await backend.queryClient.prefetchQuery(
backend.getQueryOptions({
functionName: "getPosts",
args: [],
})
)
return null
}
// Or on hover
function PostLink({ postId }: { postId: string }) {
const handleMouseEnter = () => {
backend.queryClient.prefetchQuery(
backend.getQueryOptions({
functionName: "getPost",
args: [postId],
})
)
}
return (
<Link to={`/posts/${postId}`} onMouseEnter={handleMouseEnter}>
View Post
</Link>
)
}

Provide initial data while fresh data loads:

const { data } = useActorQuery({
functionName: "getSettings",
args: [],
initialData: {
theme: "light",
notifications: true,
},
// Don't count initial data as fresh
initialDataUpdatedAt: 0,
})

Show placeholder data that doesn’t get cached:

const { data } = useActorQuery({
functionName: "getPost",
args: [postId],
placeholderData: {
id: postId,
title: "Loading...",
content: "",
},
})

Enable React Query DevTools to inspect the cache:

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