Queries
Query Basics
Section titled “Query Basics”A query is a declarative dependency on an asynchronous data source from an IC canister that is tied to a unique key. A query can be used to fetch any data from your canisters using query or update methods. An update method runs on the canister again every time the query refetches, so use a query only for update methods that are safe to repeat; see Update Methods in Queries.
To subscribe to a query in your components, call the useActorQuery hook with at least:
- A function name that exists on your actor
- Optionally, arguments to pass to that function
import { useActorQuery } from "../reactor/hooks"
function App() { const info = useActorQuery({ functionName: "get_user", args: ["user-123"], })}The unique key is automatically generated based on your canister ID, function name, and arguments. This key is used internally for refetching, caching, and sharing your queries throughout your application.
Query Results
Section titled “Query Results”The query result returned by useActorQuery contains all of the information about the query that you’ll need for templating and any other usage of the data:
import { useActorQuery } from "../reactor/hooks"
function UserProfile() { const result = useActorQuery({ functionName: "get_user", args: ["user-123"], })}The result object contains a few very important states you’ll need to be aware of to be productive. A query can only be in one of the following states at any given moment:
isPendingorstatus === 'pending'- The query has no data yetisErrororstatus === 'error'- The query encountered an errorisSuccessorstatus === 'success'- The query was successful and data is available
Beyond those primary states, more information is available depending on the state of the query:
error- If the query is in anisErrorstate, the error is available via theerrorproperty.data- If the query is in anisSuccessstate, the data is available via thedataproperty.isFetching- In any state, if the query is fetching at any time (including background refetching)isFetchingwill be true.
For most queries, it’s usually sufficient to check for the isPending state, then the isError state, then finally, assume that the data is available and render the successful state:
import { useActorQuery } from "../reactor/hooks"
function UserProfile({ userId }: { userId: string }) { const { isPending, isError, data, error } = useActorQuery({ functionName: "get_user", args: [userId], })
if (isPending) { return <span>Loading...</span> }
if (isError) { return <span>Error: {error.message}</span> }
// We can assume by this point that `isSuccess === true` return ( <div> <h2>{data?.name}</h2> <p>{data?.bio}</p> </div> )}If booleans aren’t your thing, you can always use the status state as well:
import { useActorQuery } from "../reactor/hooks"
function UserProfile({ userId }: { userId: string }) { const { status, data, error } = useActorQuery({ functionName: "get_user", args: [userId], })
if (status === "pending") { return <span>Loading...</span> }
if (status === "error") { return <span>Error: {error.message}</span> }
// also status === 'success', but "else" logic works, too return ( <div> <h2>{data?.name}</h2> <p>{data?.bio}</p> </div> )}TypeScript will also narrow the type of data correctly if you’ve checked for pending and error before accessing it.
Candid Result variants are unwrapped
Section titled “Candid Result variants are unwrapped”A method returning the common variant { Ok : T; Err : E } shape does not
hand you the raw variant. The Reactor’s default transformResult unwraps it, so:
dataisT— theOkpayload directly, never{ Ok: T }.- an
Erris thrown, as aCanisterErrorcarrying theErrpayload, and surfaces through the query’serrorchannel like any other failure.
Both Rust (Ok/Err) and Motoko (ok/err) casings are recognised. A return
value that is not a result variant passes through untouched.
import { useActorQuery } from "../reactor/hooks"
function Claim({ id }: { id: Uint8Array }) { // Candid: get_claim : (blob) -> (variant { Ok : nat; Err : ClaimError }) query const { data, error } = useActorQuery({ functionName: "get_claim", args: [id], })
// data -> bigint | undefined (the Ok payload, already unwrapped) // error -> CanisterError | null (.err is the Err payload)}This happens at the candid-decoded layer, after the call has succeeded at
the transport level. A rejected call or a network failure is a CallError
instead, so the two failure kinds stay distinguishable — see
Error Handling.
Declare the service type with the raw candid shape (Result<T, E>); the
unwrapping is applied on top of it, and ReactorReturnOk is the type the hook
actually gives you. You do not need an unwrap() helper of your own.
Conditional Queries
Section titled “Conditional Queries”When a query’s arguments are not known yet, pass TanStack Query’s skipToken
in their place. @ic-reactor/react re-exports it. The query waits without
calling the canister and fetches as soon as the arguments exist:
import { skipToken } from "@ic-reactor/react"import { useActorQuery } from "../reactor/hooks"
function UserProfile({ userId }: { userId: string | null }) { const { data } = useActorQuery({ functionName: "get_user", // Type-checked as [string] once userId is there: no `!`, no placeholder args: userId ? [userId] : skipToken, })}The same goes for a query factory, whose query for skipToken has only
useQuery(), and for useActorInfiniteQuery, as getArgs:
import { createQueryFactory, skipToken } from "@ic-reactor/react"import { backend } from "../reactor"
const getUser = createQueryFactory(backend, { functionName: "get_user" })
function UserName({ userId }: { userId: string | null }) { const { data } = getUser(userId ? [userId] : skipToken).useQuery()}A skipped query has a cache entry of its own under its method’s key, which no
call’s key shares, so it shows no data until its arguments arrive. Its
refetch() has nothing to run (TanStack Query answers it with a “Missing
queryFn” error), so offer a refresh only once the arguments exist. The
suspense hooks and factories do not take skipToken, since a suspense query
has to be able to run.
When the arguments are known but the query should still wait, use the
enabled option:
import { useActorQuery, useAuth } from "../reactor/hooks"
function MyProfile({ profileId }: { profileId: string }) { const { isAuthenticated } = useAuth()
const { data } = useActorQuery({ functionName: "get_profile", args: [profileId], // Only fetch when signed in enabled: isAuthenticated, })}This is particularly useful when:
- Only fetching when authenticated
- Disabling queries in certain UI states
Selecting Data
Section titled “Selecting Data”Use the select option to transform or select a portion of the data:
import { useActorQuery } from "../reactor/hooks"
function UserName({ userId }: { userId: string }) { const { data: userName } = useActorQuery({ functionName: "get_user", args: [userId], // Only return the name from the query select: (user) => user.name, }) // data is now just the user's name (string)}This is useful for:
- Extracting specific fields
- Transforming data for rendering
- Computing derived values
Stale Time
Section titled “Stale Time”Control when data is considered stale and should be 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) const { data: metadata } = useActorQuery({ functionName: "get_metadata", args: [], staleTime: Infinity, })}Refetching
Section titled “Refetching”Automatic Refetching
Section titled “Automatic Refetching”import { useActorQuery } from "../reactor/hooks"
function Feeds() { // Refetch every 10 seconds const { data: price } = useActorQuery({ functionName: "get_price", args: [], refetchInterval: 1000 * 10, })
// Refetch when window regains focus const { data: notifications } = useActorQuery({ functionName: "get_notifications", args: [], refetchOnWindowFocus: true, })}Manual Refetching
Section titled “Manual Refetching”import { useActorQuery } from "../reactor/hooks"
function RefreshUser({ userId }: { userId: string }) { const { data, refetch } = useActorQuery({ functionName: "get_user", args: [userId], })
// Trigger a refetch const handleRefresh = () => { void refetch() }}Update Methods in Queries
Section titled “Update Methods in Queries”The query hooks and factories accept update methods as well as query methods,
and they call the method every time TanStack Query runs the query function: on
mount when the data is stale, on window focus, on reconnect, on invalidation,
on each refetchInterval tick, and on a retry. For a query method that is a
cheap read. An update method goes through consensus each time, and each run is
a new call under a new request id, so the canister executes it again: the IC
cannot tell a refetch or a retry from a second call.
- Use a query only for an update method that is safe to repeat, such as a
certified read (the ckBTC minter’s
get_btc_addressreturns the same address every time). For a method that changes state — a transfer, a vote, a counter increment — useuseActorMutation,useActorMethodorcreateMutation, which call it only when you ask. - Retries. When the query sets no
retryof its own, an update method retries only aSysTransientrejection, which proves the canister never ran the call. A transport failure (the connection can drop after the replica accepted the call), aSysUnknownrejection and an HTTP error are not retried, so a lost response surfaces as an error instead of a second execution. Aretrythe query sets — as a hook option, in a factory’s config, or in the options ofReactor.fetchQuery— wins. - Refetches still run the method again. Limit them with
staleTime,refetchOnWindowFocus: falseandrefetchOnReconnect: false.
import { useActorQuery } from "../reactor/hooks"
function BtcAddress() { // An update method that only reads: safe to repeat, but each run costs cycles. const { data: address } = useActorQuery({ functionName: "get_btc_address", args: [{ owner: [], subaccount: [] }], staleTime: Infinity, refetchOnWindowFocus: false, refetchOnReconnect: false, })}How often the SysTransient retry runs follows the QueryClient’s retry
default for the query, which it narrows rather than replaces:
retry set by |
Query method | Update method |
|---|---|---|
The query itself (hook option, factory config, fetchQuery options) |
used as given | used as given |
setQueryDefaults or defaultOptions.queries of the QueryClient |
used as given | SysTransient only, as often as the default allows |
| Nothing | TanStack’s default: 3 retries in a browser for a hook, none for fetch(), prefetch() and fetchQuery |
SysTransient only, up to 3 times in a browser |
With the QueryClient defineReactor creates, whose default is reactorRetry,
an update method retries as reactorUpdateRetry does: a SysTransient
rejection up to 3 times in a browser, nothing on the server. A default of
retry: false retries nothing. Reactor.getQueryRetry(functionName, queryKey)
returns the retry a query of the method falls back to.
The last row has one case where an update method retries more than a query
method: on a QueryClient with no retry default at all, a factory’s fetch()
and prefetch(), Reactor.fetchQuery, and queryClient.fetchQuery with the
options from getQueryOptions retry a SysTransient rejection up to 3 times
in a browser, where TanStack Query would not retry a query method’s fetch.
The call provably never ran, so sending it again is safe, but a loader waits
through the backoff before it sees the error. Set retry: false on the
factory to fail at once.
Suspense Queries
Section titled “Suspense Queries”For Suspense-based data fetching, use useActorSuspenseQuery:
import { Suspense } from "react"import { useActorSuspenseQuery } from "../reactor/hooks"
function UserProfile({ userId }: { userId: string }) { // This hook suspends until data is loaded const { data } = useActorSuspenseQuery({ functionName: "get_user", args: [userId], })
// data is always defined here return ( <div> <h2>{data.name}</h2> <p>{data.bio}</p> </div> )}
// Wrap with Suspense boundaryfunction App() { return ( <Suspense fallback={<Loading />}> <UserProfile userId="user-123" /> </Suspense> )}Infinite Queries
Section titled “Infinite Queries”For paginated data, use useActorInfiniteQuery:
import { useActorInfiniteQuery } from "../reactor/hooks"
function Items() { const { data, fetchNextPage, hasNextPage, isFetchingNextPage, status } = useActorInfiniteQuery({ functionName: "get_items", getArgs: (pageParam) => [{ offset: pageParam, limit: 10 }], initialPageParam: 0, // nextOffset is a Candid opt: [] on the last page, [offset] otherwise getNextPageParam: (lastPage) => lastPage.nextOffset[0], })
return status === "pending" ? ( <p>Loading...</p> ) : status === "error" ? ( <p>Error loading data</p> ) : ( <> {data.pages.map((group, i) => ( <div key={i}> {group.items.map((item) => ( <p key={item.id}>{item.name}</p> ))} </div> ))} <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage} > {isFetchingNextPage ? "Loading more..." : hasNextPage ? "Load More" : "Nothing more to load"} </button> </> )}Query Factories
Section titled “Query Factories”For reusable query configurations, use createQuery:
import { createQuery } from "@ic-reactor/react"import { backend } from "../reactor"
// Define query onceconst userQuery = createQuery(backend, { functionName: "get_user", args: ["user-123"], staleTime: 5 * 60 * 1000,})
// Use in componentsfunction UserProfile() { const { data, isLoading } = userQuery.useQuery() // ...}
// Use in loadersasync function loader() { const user = await userQuery.fetch() return { user }}
// Invalidate cacheasync function refreshUser() { await userQuery.invalidate()}For dynamic arguments, use createQueryFactory:
import { createQueryFactory } from "@ic-reactor/react"import { backend } from "../reactor"
const getUserQuery = createQueryFactory(backend, { functionName: "get_user",})
// Create query with specific argsfunction UserProfile({ userId }: { userId: string }) { const userQuery = getUserQuery([userId]) const { data } = userQuery.useQuery() // ...}Type Safety
Section titled “Type Safety”IC Reactor provides full TypeScript support. The functionName prop autocompletes to valid canister methods, and args are type-checked:
import { useActorQuery } from "../reactor/hooks"
function UserProfile() { // ✅ TypeScript knows this method exists and args are correct const { data } = useActorQuery({ functionName: "get_user", // autocomplete available args: ["user-123"], // type checked })}Pass an argument of the wrong type and it no longer type-checks:
import { useActorQuery } from "../reactor/hooks"
function UserProfile() { const { data } = useActorQuery({ functionName: "get_user", // ❌ TypeScript error: Expected string // @ts-expect-error args: [123], })}Further Reading
Section titled “Further Reading”- Query Caching - Deep dive into TanStack Query integration
- Type Safety - Understanding the type system
- Error Handling - Handling query errors