Migrating from 3.x
ic-reactor 4 is a smaller package than 3.x. It does not wrap each canister in a
typed handle or generate hooks for it. A tool, candid-core-cli gen, writes a
TypeScript module from your .did file, and @ic-reactor/core calls
canisters through that module. The React hooks are TanStack Query’s own: the
client builds their options.
The 3.x line is documented at ic-reactor.b3pay.net/v3. It gets security fixes only until 4.0 is released.
What changed in 4
Section titled “What changed in 4”- One generated module.
npx candid-core-cli gen icrc1.did -o src/generatedwritessrc/generated/icrc1.ts, with a type and a schema for each Candid type, plus the service’sactorschema and itsActortype. It replaces theidlFactoryand_SERVICEdeclarations. Never edit it. See Getting started. - One client.
createClienttakes a network and either an identity or an auth, and owns itsQueryClient.client.canister<Actor>(actor, { id })gives an object with one async method per Candid method. See The client. - TanStack Query for React.
client.queryOptionsandclient.mutationOptionsgive the options foruseQuery,useMutationand the rest.@ic-reactor/reactexportsReactorProvider,useClientanduseAuth(and the type of the provider’s props), and nothing else. See Reads and Writes. - One value shape. Candid values come back as TypeScript values:
bigintfor anat, text for a principal,T | nullfor anopt,{ tag, value }for a variant. There is no second “display” form. See Values and units. - One error type. Every rejection is a
ReactorErrorwith akindand amayHaveExecutedflag. See Errors. - Auth is an interface. The client accepts any
AuthLike, and@icp-sdk/auth10’sAuthClientis one as it is. See Auth. - Fewer packages.
@ic-reactor/parser,@ic-reactor/codegenand@ic-reactor/cliare replaced bycandid-core-cli gen.@ic-reactor/candidstays at 3.x. The Vite plugin,icReactorin@ic-reactor/vite-plugin, is kept and now runscandid-core-clifor you. See The Vite plugin.
ic-reactor 4 is in beta. latest on npm is still 3.x, so install the 4 line
as @ic-reactor/core@beta and @ic-reactor/react@beta.
Moving an app
Section titled “Moving an app”- Install the 4 packages and generate the module for each
.didfile (Getting started). - Build one client for the tab, or one per request on a server (The client).
- Replace each canister object with
client.canister<Actor>(actor, { id }), and each hook of the old API with a TanStack hook overclient.queryOptionsorclient.mutationOptions. - Replace the error checks with
isReactorErrorandkind, and decide for each write what to do whenmayHaveExecutedistrue. - Replace the sign-in code with
auth: () => new AuthClient()anduseAuth. - Replace amount formatting with
formatUnitsandparseUnits. - Search your code for the names in the table below. None of them exists in 4.
Removed in 4.0 → use X
Section titled “Removed in 4.0 → use X”The table lists the 34 names that 3.x apps import from @ic-reactor/core and
@ic-reactor/react, in the order of the repository’s
scripts/removed-v3-names.js. A name that is not in the table is not in 4
either, unless a page of this site names it. The recipes below the table show
the common ones as before and after.
| 3.x name | Use in 4 |
|---|---|
ClientManager |
createClient({ network, identity | auth }) |
formatTokenAmount |
formatUnits(value, decimals, { maxFractionDigits }) |
AuthenticationManager |
auth: () => new AuthClient(), with AuthClient from @icp-sdk/auth/client (version 10), and useAuth() to read it |
createAuthHooks |
useAuth() |
createMutation |
useMutation(client.mutationOptions(canister, method)) |
createQuery |
useQuery(client.queryOptions(canister, method, vars)); outside React, client.queryClient.fetchQuery(client.queryOptions(...)) |
createActorHooks |
useClient(), then client.canister<Actor>(actor, { id }) and the TanStack hooks |
createSuspenseQuery |
useSuspenseQuery(client.queryOptions(...)): see the recipe |
DisplayReactor |
Nothing: values already have one shape. Use formatUnits and parseUnits for amounts |
Reactor |
client.canister<Actor>(actor, { id }) |
createReactorProvider |
<ReactorProvider client={() => createClient(...)}> and useClient() |
parseTokenAmount |
parseUnits(text, decimals) |
defineReactor |
createClient, then client.canister<Actor>(actor, { id }) |
isPrincipalText |
isPrincipal(text) from @candid-core/schema |
generateKey |
client.queryKey(canister, method?, vars?) |
createQueryFactory |
client.queryOptions(canister, method, vars), called where you need the options |
CanisterError |
ReactorError with kind: "canister_err" and the typed err. ReactorError is a type, not a class: there is no instanceof |
createSuspenseQueryFactory |
As createSuspenseQuery |
isCanisterError |
isReactorError(error) && error.kind === "canister_err" |
createInfiniteQuery |
useInfiniteQuery with a key that extends client.queryKey(...), and pages fetched through client.queryOptions(...), which refuses a read for a caller that is not the live one: see the infinite queries recipe on Reads |
DisplayOf |
Nothing: there is one value shape |
ReactorArgs |
Parameters<Actor["method"]>, with the generated Actor |
identityAttributeKeys |
scopedKeys({ openIdProvider, keys }) from @icp-sdk/auth/client. For an issuer that is not Google, Apple or Microsoft, write the string openid:<issuer>:<key> yourself |
IdentityAttributeOpenIdProvider |
OpenIdProvider from @icp-sdk/auth/client, which is "google" | "apple" | "microsoft". A custom issuer is not a value of it |
IdentityAttributesManager |
authClient.requestAttributes({ keys, nonce }), then decode data: see Auth |
createIdentityAttributeHooks |
As IdentityAttributesManager |
skipToken |
skipToken from @tanstack/react-query. @ic-reactor/react no longer exports it |
reactorRetry |
Built in: client.queryOptions sets retry, and the client’s QueryClient has the same default. Do not set your own |
ReactorArgsOf |
Parameters<Actor[M]>, where M is a method name of Actor |
defineDisplayReactor |
createClient, then client.canister<Actor>(actor, { id }) |
createSuspenseInfiniteQueryFactory |
useSuspenseInfiniteQuery with a key that extends client.queryKey(...), as for createInfiniteQuery |
isCallError |
isReactorError(error) && error.kind !== "canister_err" |
ReactorErrorOf |
ReactorError<E>, where E is the generated error type, such as ReactorError<TransferError> |
jsonToString |
JSON.stringify with a replacer that writes a bigint as text. Principals are text already. A cache saved with dehydrate keeps bigint and Uint8Array values without a helper |
Recipe: ClientManager and defineReactor
Section titled “Recipe: ClientManager and defineReactor”3.x built a QueryClient, a ClientManager and a Reactor for each canister,
by hand or in one call with defineReactor. In 4 you create one client, then
ask it for each canister.
import { defineReactor } from "@ic-reactor/react"import { canisterId, idlFactory, type _SERVICE } from "./declarations/ledger"
export const { reactor: ledger, useActorQuery, useActorMutation, useAuth,} = defineReactor<_SERVICE>({ name: "ledger", idlFactory, canisterId })import { createClient } from "@ic-reactor/core"import { actor, type Actor } from "./generated/icrc1"
export const client = createClient({ network: "ic", identity: "anonymous" })export const ledger = client.canister<Actor>(actor, { id: "ryjl3-tyaaa-aaaaa-aaaba-cai",})identity: "anonymous" makes a read-only client. For an app that signs users
in, pass auth instead: see the auth recipe. A
QueryClient is no longer yours to build: it is client.queryClient. On a
server, build the client inside the request, not at module scope.
Direct calls change in the same way. 3.x went through the reactor’s
callMethod and fetchQuery. In 4 the canister object has one method per
Candid method, and a cached read goes through the client’s QueryClient:
import { principal } from "@candid-core/schema"import { createClient } from "@ic-reactor/core"import { actor, type Actor } from "./generated/icrc1"
const client = createClient({ network: "ic", identity: "anonymous" })const ledger = client.canister<Actor>(actor, { id: "ryjl3-tyaaa-aaaaa-aaaba-cai",})const account = { owner: principal("aaaaa-aa"), subaccount: null }
// A direct call: never cached.const balance = await ledger.icrc1_balance_of(account)
// A cached read, shared with the hooks.const cached = await client.queryClient.fetchQuery( client.queryOptions(ledger, "icrc1_balance_of", account))console.log(balance, cached)Recipe: hooks for a canister
Section titled “Recipe: hooks for a canister”createActorHooks, createQuery and createMutation made hooks that knew one
canister. In 4 the component asks for the client, builds the options, and
passes them to TanStack’s hooks. The variables are one value: nothing for a
method with no arguments, the value for one, a tuple for more.
// 3.xconst { data } = useActorQuery({ functionName: "icrc1_balance_of", args: owner ? [{ owner, subaccount: [] }] : skipToken,})const send = useActorMutation({ functionName: "icrc1_transfer" })send.mutate([arg])// 4import type { Principal } from "@candid-core/schema"import { formatUnits } from "@ic-reactor/core"import { useClient } from "@ic-reactor/react"import { skipToken, useMutation, useQuery } from "@tanstack/react-query"import { actor, type Actor, type TransferArg } from "./generated/icrc1"
export function Balance({ owner }: { owner: Principal | undefined }) { const client = useClient() const ledger = client.canister<Actor>(actor, { id: "ryjl3-tyaaa-aaaaa-aaaba-cai", }) const balance = useQuery( client.queryOptions( ledger, "icrc1_balance_of", owner ? { owner, subaccount: null } : skipToken ) ) return ( <p>{balance.data === undefined ? "…" : formatUnits(balance.data, 8)}</p> )}
export function Send({ arg }: { arg: TransferArg }) { const client = useClient() const ledger = client.canister<Actor>(actor, { id: "ryjl3-tyaaa-aaaaa-aaaba-cai", }) const transfer = useMutation(client.mutationOptions(ledger, "icrc1_transfer")) return ( <button disabled={transfer.isPending} onClick={() => transfer.mutate(arg)}> Send </button> )}Call useClient() in the body of every component that builds options, on
every render. Do not keep its result in state or a ref: the client it returns
follows the signed-in caller. See SSR and hydration for why.
The mutation invalidates the canister’s reads by itself, so the
invalidateQueries option of the 3.x mutation hook is not needed. To refresh
other reads, the invalidates option replaces that default with the reads you
name: see Writes.
Recipe: suspense reads
Section titled “Recipe: suspense reads”createSuspenseQuery, createSuspenseQueryFactory and the hook
useActorSuspenseQuery become useSuspenseQuery, which takes the options of
client.queryOptions(...) as they are. A suspense read cannot wait for its
variables: options built with skipToken, or with variables typed
V | SkipToken, keep skipToken in the type of queryFn, and
useSuspenseQuery refuses them.
import { formatUnits } from "@ic-reactor/core"import { useClient } from "@ic-reactor/react"import { useSuspenseQuery } from "@tanstack/react-query"import { Suspense } from "react"import { actor, type Actor } from "./generated/icrc1"
function Fee() { const client = useClient() const ledger = client.canister<Actor>(actor, { id: "ryjl3-tyaaa-aaaaa-aaaba-cai", }) const fee = useSuspenseQuery(client.queryOptions(ledger, "icrc1_fee")) return <p>Fee: {formatUnits(fee.data, 8)}</p>}
export function Page() { return ( <Suspense fallback={<p>Loading…</p>}> <Fee /> </Suspense> )}Put a Suspense boundary above every useSuspenseQuery reader. Without one,
a server-rendered page can lose its server HTML or render nothing when a
signed-in user reloads. See SSR and hydration.
Recipe: sign in and useAuth
Section titled “Recipe: sign in and useAuth”3.x built an AuthenticationManager and turned it into hooks, or got them from
defineReactor. In 4, pass an auth factory to createClient. The client
calls it once, in a browser, the first time something needs it. AuthClient
from @icp-sdk/auth 10 fits as it is, with no adapter. Read the session with
useAuth.
// 3.xconst { login, logout, isAuthenticated, principal } = useAuth()// principal: Principal | null// 4import { createClient } from "@ic-reactor/core"import { useAuth } from "@ic-reactor/react"import { AuthClient } from "@icp-sdk/auth/client"
export const client = createClient({ network: "ic", auth: () => new AuthClient(),})
export function SessionButton() { const { status, principal, signIn, signOut } = useAuth() return status === "signed-in" ? ( <button onClick={() => signOut().catch(console.error)}> Sign out {principal} </button> ) : ( <button onClick={() => signIn().catch(console.error)}>Sign in</button> )}The names change: login is signIn, logout is signOut,
isAuthenticated is status === "signed-in", and principal is text, not a
Principal object. status is "anonymous", "signed-in", "expired" or
"signed-in-elsewhere". The separate hooks for the agent state and the user
principal are gone: useAuth has both. See Auth.
Recipe: the provider
Section titled “Recipe: the provider”createReactorProvider built the reactors once for each mounted tree and
handed them out through useReactor(). In 4, ReactorProvider takes a factory
that returns a client, and useClient() returns it. Because it is a function,
the provider belongs in a "use client" module when a server renders your
layout.
"use client"
import { createClient } from "@ic-reactor/core"import { ReactorProvider } from "@ic-reactor/react"import { AuthClient } from "@icp-sdk/auth/client"import type { ReactNode } from "react"
export function Providers({ children }: { children: ReactNode }) { return ( <ReactorProvider client={() => createClient({ network: "ic", auth: () => new AuthClient() }) } > {children} </ReactorProvider> )}A factory that creates the client gives the provider the client, and the
provider disposes it when it unmounts. That is the right shape for a server:
the factory runs once for each request. For a client shared with code outside
React, create it at module scope and pass client={() => client}. The
provider then only borrows it. See The client and
@ic-reactor/react.
Recipe: amounts
Section titled “Recipe: amounts”Both helpers keep exact digits and never go through Number. They take the
token’s decimals, which a ledger’s icrc1_decimals returns.
// 3.xformatTokenAmount(150_000_000n, 8) // "1.5"parseTokenAmount("1.5", 8) // 150000000n// 4import { formatUnits, parseUnits } from "@ic-reactor/core"
formatUnits(150_000_000n, 8) // "1.5"formatUnits(123_456_789n, 8, { maxFractionDigits: 2 }) // "1.23"parseUnits("1.5", 8) // 150000000nformatUnits drops trailing zeros and truncates toward zero, never up.
parseUnits throws a TypeError for text that is not a plain decimal, such as
"1,5" or "1e3", and a RangeError for more fraction digits than
decimals or a negative amount. To group digits for a locale, pass the result
to Intl.NumberFormat. See Values and units.
Recipe: errors
Section titled “Recipe: errors”3.x had two classes for a failed call: CanisterError when the canister
returned Err, and CallError for everything else. 4 has one ReactorError
type, and kind tells them apart. ReactorError is a type, so you cannot use
instanceof: test with isReactorError.
// 3.xif (isCanisterError(error)) return `Refused: ${error.code}`if (isCallError(error)) return "The call failed"// 4import type { Canister } from "@ic-reactor/core"import { isReactorError } from "@ic-reactor/core"import type { Actor, TransferArg } from "./generated/icrc1"
export async function send(ledger: Canister<Actor>, arg: TransferArg) { try { await ledger.icrc1_transfer(arg) return "Sent" } catch (error) { if (!isReactorError(error)) throw error if (error.kind === "canister_err") return "Refused by the ledger" if (error.mayHaveExecuted) return "Unknown outcome: read the balance again" return "Not sent" }}isReactorError(error) && error.kind !== "canister_err" is the 4 form of
isCallError: bare isReactorError also matches a canister_err. The 3.x
error.code was the variant tag of the Err value. In 4 the Err value is
error.err, typed, and for a variant it is { tag, value }. error.code still
exists, but only on a few errors the client makes itself. The error of a
useMutation or useQuery is typed by the method, so you can read err
without a cast:
import { useClient } from "@ic-reactor/react"import { useMutation } from "@tanstack/react-query"import { actor, type Actor, type TransferArg, type TransferError,} from "./generated/icrc1"
export function Send({ arg }: { arg: TransferArg }) { const client = useClient() const ledger = client.canister<Actor>(actor, { id: "ryjl3-tyaaa-aaaaa-aaaba-cai", }) const transfer = useMutation(client.mutationOptions(ledger, "icrc1_transfer")) const error = transfer.error // ReactorError<TransferError> | null const tag: TransferError["tag"] | undefined = error?.kind === "canister_err" ? error.err.tag : undefined return ( <button onClick={() => transfer.mutate(arg)}> Send{tag ? `: ${tag}` : ""} </button> )}See Errors and mayHaveExecuted for every kind and what to do about each.
Recipe: identity attributes
Section titled “Recipe: identity attributes”3.x had identityAttributeKeys, a manager and hooks around signed identity
attributes. In 4 you use @icp-sdk/auth 10 directly: build the scoped keys
with scopedKeys and call requestAttributes on your AuthClient. The client
does not expose its auth, so keep your own reference to the AuthClient and
hand it over with auth: () => authClient.
import { createClient } from "@ic-reactor/core"import { AuthClient, scopedKeys } from "@icp-sdk/auth/client"
export const authClient = new AuthClient({ openIdProvider: "google" })export const client = createClient({ network: "ic", auth: () => authClient })
// `nonce` returns the 32-byte nonce your canister issued for this request.export async function requestEmail(nonce: () => Promise<Uint8Array>) { const keys = scopedKeys({ openIdProvider: "google", keys: ["email"] }) return authClient.requestAttributes({ keys, nonce })}The result is { data, signature }. The Auth page shows
how to decode data, and why the canister that issued the nonce must verify
both before it trusts the values.
Recipe: query keys
Section titled “Recipe: query keys”generateKey turned an argument list into a string for a cache key. In 4 the
client builds the keys, and they include the caller, so one principal’s data is
never shown under another’s. Never write one by hand.
import { principal } from "@candid-core/schema"import { createClient } from "@ic-reactor/core"import { actor, type Actor } from "./generated/icrc1"
const client = createClient({ network: "ic", identity: "anonymous" })const ledger = client.canister<Actor>(actor, { id: "ryjl3-tyaaa-aaaaa-aaaba-cai",})const account = { owner: principal("aaaaa-aa"), subaccount: null }
client.queryKey(ledger) // every read of this canister by the current callerclient.queryKey(ledger, "icrc1_balance_of") // every read of that methodclient.queryKey(ledger, "icrc1_balance_of", account) // that read aloneA prefix key refetches or invalidates a group. getQueryData and
setQueryData need the exact key. See Reads.
Recipe: types and small helpers
Section titled “Recipe: types and small helpers”The generated Actor replaces the argument and error type helpers, and the
helpers that stay in TanStack or candid-core are imported from there:
import { isPrincipal } from "@candid-core/schema"import type { ReactorError } from "@ic-reactor/core"import { skipToken } from "@tanstack/react-query"import type { Actor, TransferError } from "./generated/icrc1"
// The arguments of a method, as a tuple: [arg0: TransferArg].export type TransferArgs = Parameters<Actor["icrc1_transfer"]>
// The error of a call whose canister error is TransferError.export type SendError = ReactorError<TransferError>
// Text that may be a principal, tested before use.export const owner = (text: string) => (isPrincipal(text) ? text : skipToken)JSON.stringify stands in for jsonToString once a replacer writes bigint
as text:
export const toJson = (value: unknown) => JSON.stringify( value, (_key, item) => (typeof item === "bigint" ? item.toString() : item), 2 )Where to read next
Section titled “Where to read next”- Getting started for the first read and write.
- The client, Reads and Writes for what replaced the hooks.
- Errors and mayHaveExecuted for the error kinds.
- Auth for sign-in and identity attributes.
- SSR and hydration for Next.js and other servers.
- The examples for four complete apps written for 4.