Skip to content
IC Reactor

Queries

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.

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:

  • isPending or status === 'pending' - The query has no data yet
  • isError or status === 'error' - The query encountered an error
  • isSuccess or status === '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 an isError state, the error is available via the error property.
  • data - If the query is in an isSuccess state, the data is available via the data property.
  • isFetching - In any state, if the query is fetching at any time (including background refetching) isFetching will 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.

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:

  • data is T — the Ok payload directly, never { Ok: T }.
  • an Err is thrown, as a CanisterError carrying the Err payload, and surfaces through the query’s error channel 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.

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

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

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,
})
}
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,
})
}
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()
}
}

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_address returns the same address every time). For a method that changes state — a transfer, a vote, a counter increment — use useActorMutation, useActorMethod or createMutation, which call it only when you ask.
  • Retries. When the query sets no retry of its own, an update method retries only a SysTransient rejection, which proves the canister never ran the call. A transport failure (the connection can drop after the replica accepted the call), a SysUnknown rejection and an HTTP error are not retried, so a lost response surfaces as an error instead of a second execution. A retry the query sets — as a hook option, in a factory’s config, or in the options of Reactor.fetchQuery — wins.
  • Refetches still run the method again. Limit them with staleTime, refetchOnWindowFocus: false and refetchOnReconnect: 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.

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 boundary
function App() {
return (
<Suspense fallback={<Loading />}>
<UserProfile userId="user-123" />
</Suspense>
)
}

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>
</>
)
}

For reusable query configurations, use createQuery:

import { createQuery } from "@ic-reactor/react"
import { backend } from "../reactor"
// Define query once
const userQuery = createQuery(backend, {
functionName: "get_user",
args: ["user-123"],
staleTime: 5 * 60 * 1000,
})
// Use in components
function UserProfile() {
const { data, isLoading } = userQuery.useQuery()
// ...
}
// Use in loaders
async function loader() {
const user = await userQuery.fetch()
return { user }
}
// Invalidate cache
async 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 args
function UserProfile({ userId }: { userId: string }) {
const userQuery = getUserQuery([userId])
const { data } = userQuery.useQuery()
// ...
}

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],
})
}