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:
// 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.
Custom Query Keys
Section titled “Custom Query Keys”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']Accessing Query Keys
Section titled “Accessing Query Keys”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.
Stale Time
Section titled “Stale Time”staleTime determines how long data is considered fresh. Fresh data is never refetched:
// Data is fresh for 5 minutesconst { 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})Garbage Collection Time
Section titled “Garbage Collection Time”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 foreverconst { 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
Automatic Refetching
Section titled “Automatic Refetching”Refetch Interval
Section titled “Refetch Interval”Automatically refetch data at a set interval:
// Refetch every 10 secondsconst { data } = useActorQuery({ functionName: "getPrice", args: [], refetchInterval: 1000 * 10,})
// Only refetch when window is focusedconst { data } = useActorQuery({ functionName: "getPrice", args: [], refetchInterval: 1000 * 10, refetchIntervalInBackground: false,})Refetch Triggers
Section titled “Refetch Triggers”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,})Manual Invalidation
Section titled “Manual Invalidation”Using QueryClient
Section titled “Using QueryClient”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 Canister Queries
Section titled “Invalidate All Canister Queries”// Invalidate ALL queries for this canisterbackend.invalidateQueries()Invalidate by Function Name
Section titled “Invalidate by Function Name”// Invalidate all getUser queries (any args)backend.invalidateQueries({ functionName: "getUser" })
// Invalidate getUser queries for a specific overridden target canisterbackend.invalidateQueries( { functionName: "getUser" }, { canisterId: otherCanisterId })Invalidate Specific Query
Section titled “Invalidate Specific Query”// Invalidate getUser query for specific userbackend.invalidateQueries({ functionName: "getUser", args: ["user-123"],}, { canisterId: otherCanisterId,})Invalidate by Custom Query Key
Section titled “Invalidate by Custom Query Key”// Invalidate query with custom keybackend.invalidateQueries({ functionName: "getData", 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 { backend } from "./reactor"
const { mutate } = useActorMutation({ functionName: "createPost", invalidateQueries: [ backend.generateQueryKey({ functionName: "getPosts" }), backend.generateQueryKey({ functionName: "getPostCount" }), ],})Using onSuccess
Section titled “Using onSuccess”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] ) }, })}Optimistic Updates
Section titled “Optimistic Updates”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>}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. 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.
Prefetching
Section titled “Prefetching”Prefetch data before it’s needed:
import { backend } from "./reactor"
// In a route loaderasync function postsLoader() { await backend.queryClient.prefetchQuery( backend.getQueryOptions({ functionName: "getPosts", args: [], }) ) return null}
// Or on hoverfunction 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> )}Initial Data
Section titled “Initial Data”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,})Placeholder Data
Section titled “Placeholder Data”Show placeholder data that doesn’t get cached:
const { data } = useActorQuery({ functionName: "getPost", args: [postId], placeholderData: { id: postId, title: "Loading...", content: "", },})Debug Caching
Section titled “Debug Caching”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
Further Reading
Section titled “Further Reading”- TanStack Query Documentation — Complete TanStack Query docs
- Queries — IC Reactor query patterns
- Mutations — IC Reactor mutation patterns