# IC Reactor: full guide for AI coding agents > How to write correct IC Reactor code in an app: install, choose a setup, call > canisters from React and from plain code, cache and invalidate, sign in, > render on a server, handle errors, generate code, test, and the mistakes to > avoid. This guide is for code that uses the published packages. The short index of the documentation is https://ic-reactor.b3pay.net/llms.txt. Both are published from the main branch, so they can describe changes that no release carries yet; the Unreleased section of https://github.com/B3Pay/ic-reactor/blob/main/CHANGELOG.md lists those, with migration hints for behaviour changes. Each installed package also carries a guide for its own API at `node_modules/@ic-reactor//llms.txt`, written for the version installed. The rules below also ship as the `ic-reactor` Agent Skill. In Claude Code: `/plugin marketplace add B3Pay/ic-reactor`, then `/plugin install ic-reactor@ic-reactor`. For other agents: `npx skills add B3Pay/ic-reactor --skill ic-reactor`. Package versions this guide describes: - `@ic-reactor/core`: `3.13.0` - `@ic-reactor/react`: `3.13.0` - `@ic-reactor/candid`: `3.13.0` - `@ic-reactor/codegen`: `0.15.1` - `@ic-reactor/cli`: `0.15.1` - `@ic-reactor/vite-plugin`: `0.15.1` - `@ic-reactor/parser`: `0.6.0` ## What IC Reactor Is IC Reactor calls Internet Computer canisters with end-to-end TypeScript types taken from the canister's Candid service type (`_SERVICE` in generated declarations). It caches query results in TanStack Query, gives React hooks and reusable query/mutation objects, unwraps Candid `Result` values into data or a typed error, signs users in with Internet Identity, and can generate all of this from `.did` files. | Package | Use it for | | ------------------------- | ------------------------------------------------------------------------------------------- | | `@ic-reactor/core` | `ClientManager`, `Reactor`, `DisplayReactor`, errors, token helpers. No React. | | `@ic-reactor/react` | Hooks, query/mutation factories, `defineReactor`, auth. Re-exports all of core. | | `@ic-reactor/candid` | Reactors for canisters whose Candid is only known at run time; forms from Candid metadata. | | `@ic-reactor/parser` | WASM Candid parser: `didToJs`, `didToTs`, `parseDid`, `validateIDL`. | | `@ic-reactor/vite-plugin` | Generates declarations and hooks from `.did` files in a Vite app; local canister ids. | | `@ic-reactor/cli` | The `ic-reactor` command: the same generation for non-Vite projects and CI. | | `@ic-reactor/codegen` | The generation pipeline behind the CLI and the Vite plugin; apps rarely import it directly. | ## Install React apps: ```bash pnpm add @ic-reactor/react @icp-sdk/core @tanstack/react-query ``` `@ic-reactor/react` needs `@tanstack/react-query` 5.90.2 or later and React 18 or later. IC Reactor needs TypeScript 5.7 or later: its declarations, and those of `@icp-sdk/core`, use generic typed arrays such as `Uint8Array`, which TypeScript 5.7 introduced. Import the runtime (`ClientManager`, `Reactor`, ...) from `@ic-reactor/react` too: with the install above, `@ic-reactor/core` is not a direct dependency, and pnpm does not let the app import it. Non-React apps: ```bash pnpm add @ic-reactor/core @icp-sdk/core @tanstack/query-core ``` Internet Identity sign-in (optional; the auth classes ship in `@ic-reactor/react`): ```bash pnpm add @icp-sdk/auth@^10 ``` The `@icp-sdk/auth` peer is `^8.0.0 || ^10.0.0`. Install v10: it is the first release to peer `@icp-sdk/core@^6`, so a strict `npm install` resolves the set with no `overrides`. v7 and v9 are not supported; v9 still peers `@icp-sdk/core@^5`. On v8, a strict `npm install` fails with `ERESOLVE` (stale metadata, not an actual incompatibility). For npm on v8, add to `package.json`: ```json { "overrides": { "@icp-sdk/auth": { "@icp-sdk/core": "$@icp-sdk/core" } } } ``` On v10, four constructor options are dropped with a warning because v10 has no equivalent: `storage`, `keyType`, `idleOptions`, `identity`. `targets` on `login()` is ignored by v10, so the delegation is NOT restricted to those canisters; pin `^8` if you depend on canister-scoped delegations. `maxTimeToIdle` on `login()` and `disableBrowserActivity` exist only on v10. On v10 a custom `identityProvider` also needs `internetIdentityId`. Dynamic Candid: ```bash pnpm add @ic-reactor/candid @ic-reactor/core @icp-sdk/core @tanstack/query-core ``` Code generation: `pnpm add -D @ic-reactor/vite-plugin` (Vite) or `pnpm add -D @ic-reactor/cli` (anything else; Node.js 22.12 or later). The snippets below import `canisterId`, `idlFactory` and the `_SERVICE` type from `./declarations/backend`, which stands for your canister's declarations. `idlFactory` comes from the generated `.js` file and `_SERVICE` from the `.d.ts`. With the CLI or the Vite plugin those are `src/declarations//declarations/.js` and `.d.ts`, named after the `.did` file; the folder's own `index.ts` exports the generated reactor and hooks, not `idlFactory`. `canisterId` is the canister's id as text: a constant, or a value from your build's environment. ## Choose a Setup | Your app | Use | | ----------------------------------------------------------- | --------------------------------------------------------------------------------- | | React SPA, a few canisters | `defineReactor(...)` or `defineDisplayReactor(...)` at module scope | | React SPA, many canisters or a `.did` that changes often | `@ic-reactor/vite-plugin` (Vite) or `@ic-reactor/cli`, with `factories: true` | | Server-rendered React (Next.js, any SSR) | `createReactorProvider(() => defineReactor(...))` in a `"use client"` module | | React Server Component, server action, route handler | `ClientManager` + `Reactor` built inside the request; `fetchQuery` / `callMethod` | | Construction order or dependency injection must be explicit | `ClientManager` + `Reactor` or `DisplayReactor` + `createActorHooks(reactor)` | | No React (Node scripts, other frameworks, workers) | `@ic-reactor/core`: `ClientManager` + `Reactor`, imperative API | | Several canisters with one interface (ICRC ledgers) | one reactor, plus `reactor.forCanister(canisterId)` for each other canister | | Canister interface known only at run time | `@ic-reactor/candid` | `Reactor` returns raw Candid values; `DisplayReactor` returns values a UI can show and a form can send: | Candid | `Reactor` | `DisplayReactor` | | -------------------------------- | ----------------------------------- | ---------------------- | | `nat`, `int`, `nat64`, `int64` | `bigint` | `string` | | `nat8`..`nat32`, `int8`..`int32` | `number` | `number` | | `principal` | `Principal` | `string` | | `blob` | `Uint8Array` | hex `string`, no `0x` | | `opt T` | `[] \| [T]` | `T \| undefined` | | `variant { A: T }` | `{ A: T }` | `{ _type: "A", A: T }` | | `variant { A }` (no value) | `{ A: null }` | `{ _type: "A" }` | | `vec record { text; T }` | `[string, T][]` | `{ [key: string]: T }` | | `Result` (`Ok`/`Err`) | Ok value, or throws `CanisterError` | same | `DisplayReactor` adds zod (about 24 kB gzipped). Until the next major, `defineReactor` includes it too because of its deprecated `display` flag; the smallest setup is `createActorHooks(new Reactor(...))`. ## One-Call Setup `defineReactor` creates the `QueryClient` (with `reactorRetry` as its query retry), the `ClientManager`, the reactor and its hooks in one call. `defineDisplayReactor` takes the same options and builds a `DisplayReactor`. `defineReactor({ display: true })` is deprecated; never write it. Plain `defineReactor` is not deprecated, although typescript-eslint's `no-deprecated` also flags references such as `typeof defineReactor`: do not switch a raw-value setup to `defineDisplayReactor` to silence it. ```ts // src/reactor.ts import { defineDisplayReactor } from "@ic-reactor/react" import { canisterId, idlFactory, type _SERVICE } from "./declarations/backend" export const backendApp = defineDisplayReactor<_SERVICE>({ name: "backend", idlFactory, // Required unless the Vite plugin injects it on a local replica. canisterId, }) export const { reactor: backend, useActorQuery, useActorMutation, useAuth, } = backendApp ``` The result holds the six hooks of `createActorHooks` (`useActorQuery`, `useActorSuspenseQuery`, `useActorInfiniteQuery`, `useActorSuspenseInfiniteQuery`, `useActorMutation`, `useActorMethod`), `useAuth`, `useAgentState`, `useUserPrincipal`, `useIdentityAttributes`, and `reactor`, `clientManager`, `queryClient`, `authentication` and `identityAttributes`. `authentication` and `identityAttributes` are lazy getters, so the optional `@icp-sdk/auth` peer loads only once auth is used. Without the peer the app still builds; webpack-family bundlers print one `Module not found` warning for it, silenced with `ignoreWarnings: [{ module: /@ic-reactor\/react/, message: /@icp-sdk\/auth/ }]`. Without `canisterId`, the reactor reads `PUBLIC_CANISTER_ID:` from the `ic_env` cookie, which `ClientManager` trusts only on a local replica (loopback and `localhost` hosts) unless `allowEnvConfig: true` is passed. On a server or a deployed origin the constructor then throws `canisterId is required for "backend"`. A second canister shares the agent and the Internet Identity session when it gets the first result's `authentication`: ```ts // src/ledger.ts import { defineReactor } from "@ic-reactor/react" import { canisterId as ledgerId, idlFactory as ledgerIdl, type _SERVICE as Ledger, } from "./declarations/ledger" import { backendApp } from "./reactor" export const ledgerApp = defineReactor({ name: "ledger", idlFactory: ledgerIdl, canisterId: ledgerId, authentication: backendApp.authentication, }) export const ledger = ledgerApp.reactor ``` Passing `authentication` alone is enough; its `clientManager` is adopted. Passing a different `clientManager` alongside it throws, because sign-in would update one agent while the reactor calls through another. Do not build a second `AuthenticationManager` for the same app. ## Manual Setup Use this when construction order must be explicit: ```ts // src/manual.ts import { ClientManager, DisplayReactor, createActorHooks, reactorRetry, } from "@ic-reactor/react" import { QueryClient } from "@tanstack/react-query" import { canisterId, idlFactory, type _SERVICE } from "./declarations/backend" export const queryClient = new QueryClient({ defaultOptions: { queries: { retry: reactorRetry } }, }) export const clientManager = new ClientManager({ queryClient }) export const backend = new DisplayReactor<_SERVICE>({ clientManager, idlFactory, name: "backend", canisterId, }) export const { useActorQuery, useActorMutation } = createActorHooks(backend) ``` Keep the `<_SERVICE>` type argument: without it every argument and result is `unknown` (`any` on an untyped `Reactor`). A `QueryClient` you create keeps TanStack's defaults unless you set `retry: reactorRetry` as above. The hooks bind to the reactor's own `QueryClient`; a `QueryClientProvider` is optional. ## Queries and Mutations in Components ```tsx import { skipToken } from "@ic-reactor/react" import { useActorMutation, useActorQuery } from "./reactor" export function ProfileCard({ userId }: { userId?: string }) { const { data, isPending, error } = useActorQuery({ functionName: "get_profile", // No call until the id exists; never `[userId!]` args: userId ? [userId] : skipToken, }) const rename = useActorMutation({ functionName: "update_profile", // Refetch every get_profile query of this canister once it succeeds invalidateQueries: [{ functionName: "get_profile" }], // The canister returned Err: err.code is the variant name onCanisterError: (err) => alert(`Not saved: ${err.code}`), }) if (!userId) return

Sign in first

if (isPending) return

Loading…

if (error) return

{error.message}

return ( ) } ``` - `args` is always the method's argument tuple (`[userId]`, `[]` or omitted for no arguments), in the reactor's form (display strings above). - `data` is the `Ok` payload of a `Result`: an `Err` becomes a `CanisterError` in `error`, so never write `"Ok" in data`. - A query hook takes TanStack's options too (`select`, `staleTime`, `refetchInterval`, `enabled`, ...) and `callConfig` (`canisterId`, `agent`, `effectiveCanisterId`) for one call. - `skipToken` works in `useActorQuery`, `useActorInfiniteQuery` (as `getArgs: owner ? (page) => [...] : skipToken`) and `createQueryFactory`. The suspense hooks, `createQuery` and `createSuspenseQuery` do not take it. Keep `enabled` for conditions that are not about missing args. Never call `refetch()` on a skipped query. - Call state-changing update methods only through `useActorMutation`, `useActorMethod` or `createMutation`. A query hook or factory runs its method again on every refetch (mount, window focus, reconnect, invalidation), and each run of an update executes on the canister. A query of an update method is fine only when repeating it is safe (a certified read). - `useActorMethod` handles a query or an update method with one hook and an imperative `call()`; use it only when that unified shape helps. ## Reusable Query and Mutation Objects Define query and mutation objects once and use them in components, loaders, actions and tests. In a client-only app they live at module scope; in a server-rendered app build them inside the `createReactorProvider` factory. ```ts // src/queries.ts import { createMutation, createQuery, createQueryFactory, } from "@ic-reactor/react" import { backend } from "./reactor" export const postsQuery = createQuery(backend, { functionName: "get_posts" }) export const getPost = createQueryFactory(backend, { functionName: "get_post" }) export const createPost = createMutation(backend, { functionName: "create_post", invalidateQueries: [postsQuery, { functionName: "get_posts_count" }], }) export const likePost = createMutation(backend, { functionName: "like_post" }) ``` | Need | Use | | ------------------------------ | ---------------------------------------------------------------------------- | | A query method without args | `createQuery` (or `createSuspenseQuery`) | | A query method whose args vary | `createQueryFactory` (or `createSuspenseQueryFactory`), called with the args | | Paginated reads | `createInfiniteQuery`, `createSuspenseInfiniteQuery` (and their factories) | | An update method | `createMutation` | Inside React: `postsQuery.useQuery()`, `getPost([id]).useQuery()`, `createPost.useMutation(options)`; `.useSuspenseQuery()`, `.useInfiniteQuery()` and `.useSuspenseInfiniteQuery()` on the other kinds. Outside React: ```ts import { createPost, getPost, postsQuery } from "./queries" export function postLoader(postId: string) { // Cached and deduplicated; refetches as the new principal after a sign-in return getPost([postId]).fetch() } export function createPostAction(title: string) { // Resolves with the Ok payload after the factory's invalidateQueries ran; // an Err rejects with a CanisterError return createPost.execute([title]) } export function refreshPosts() { return postsQuery.invalidate() // resolves once mounted queries refetched } ``` A query object also has `.prefetch()`, `.getCacheData()`, `.setData()`, `.getQueryKey()`, `.cancel()`, `.reset()` and `.optimisticUpdate()` (`.prefetch()` and `.setData()` are not on infinite query objects). A query factory function has `getQueryKey()` (the prefix of all its queries) and `invalidate()`. `createQuery`, `createSuspenseQuery` and the query factories take `callConfig` as the hooks do: `createQuery(ledger, { functionName: "icrc1_symbol", callConfig: { canisterId: ckbtcId } })`. Paginated reads take TanStack's `initialPageParam` and `getNextPageParam`, plus `getArgs`, which turns a page param into the method's argument tuple: ```ts import { createInfiniteQuery } from "@ic-reactor/react" import { backend } from "./reactor" // list_posts : (record { offset : nat; limit : nat }) // -> (record { posts : vec Post; next : opt nat }) query export const postPages = createInfiniteQuery(backend, { functionName: "list_posts", initialPageParam: "0", // a DisplayReactor takes a nat as text getArgs: (offset) => [{ offset, limit: "20" }] as const, getNextPageParam: (lastPage) => lastPage.next, // undefined: no next page }) // In a component: // const { data, fetchNextPage, hasNextPage } = postPages.useInfiniteQuery() // const posts = data?.pages.flatMap((page) => page.posts) ?? [] // In a loader: await postPages.fetch() ``` Keep `as const` on what `getArgs` returns: without it the args are inferred as an array rather than a tuple, and `createInfiniteQuery` rejects them. `useActorInfiniteQuery` takes the same fields. ## Cache Invalidation and Optimistic Updates A mutation's `invalidateQueries` (on `createMutation`, `.useMutation()`, `useActorMutation` and `useActorMethod`) takes, per entry: - a query object: `[postsQuery]`; - a query factory, covering every args instance: `[getPost]`; - a method of the mutation's own reactor, type-checked: `{ functionName: "get_post" }` for every query of the method, or `{ functionName: "get_post", args: [id] }` for one. It is keyed at the canister the mutation was sent to. The invalidation is awaited before `onSuccess`, so `onSuccess` reads fresh data. Never hand-write a key such as `["get_posts"]`: every key starts with the canister id, so it matches nothing. A key is the canister id and the method name, then segments for a non-default transform (a `DisplayReactor`), another agent or effective target given in `callConfig`, and the args. Build one with `reactor.generateQueryKey(...)` or `query.getQueryKey()` if you need it, and never add the canister id to a `queryKey` yourself. `query.invalidate()`, a factory's `invalidate()` and `reactor.invalidateQueries(...)` return a `Promise` that resolves once the active matches have refetched (a failed refetch does not reject it). Prefix a call you do not wait for with `void`. For optimistic UI, return the query's `optimisticUpdate(updater)` from `onMutate`, roll back in `onError`, and invalidate in `onSettled`: ```tsx import { getPost, likePost } from "./queries" export function LikeButton({ postId }: { postId: string }) { const { data: post } = getPost([postId]).useQuery() const like = likePost.useMutation({ // Show the new count at once; undo it if the call fails onMutate: ([id]) => getPost([id]).optimisticUpdate((cached) => ({ ...cached, likes: (BigInt(cached.likes) + 1n).toString(), })), onError: (_error, _args, update) => update?.rollback(), onSettled: (_data, _error, [id]) => getPost([id]).invalidate(), }) return ( ) } ``` The updater gets the cached raw (pre-`select`) value and is skipped when nothing is cached. `rollback()` does nothing after a sign-in or sign-out, since the snapshot was the previous principal's. Do not hand-roll `cancelQueries` / `setQueryData` snapshots. When you need the `QueryClient` itself, use `reactor.queryClient`; `useQueryClient()` throws without a `QueryClientProvider`. ## Inside React vs Outside React Only call hooks inside React components or custom hooks: `useActorQuery`, `useActorMutation`, `useActorMethod`, `useAuth`, `.useQuery()`, `.useMutation()`, `.useSuspenseQuery()`, `.useInfiniteQuery()`. Loaders, actions, services, scripts and non-hook tests use: - query objects: `.fetch()`, `.prefetch()`, `.invalidate()`, `.getCacheData()`, `.setData()`, `.cancel()`, `.reset()`, `.optimisticUpdate()` - mutation objects: `.execute(args)` - reactors: `.fetchQuery()`, `.getQueryData()`, `.invalidateQueries()`, `.callMethod()` `query.fetch()` and `reactor.fetchQuery()` fetch again, at most three times, when a sign-in or sign-out switches the principal mid-fetch (after that they reject with a `CallError`). Wrap a fetch you write against the `QueryClient` yourself (`queryClient.fetchQuery`, `fetchInfiniteQuery`, `ensureQueryData`) in `clientManager.fetchAcrossIdentitySwitch(() => ...)`: unwrapped, a switch can resolve it with the previous principal's cached data. A query object's `.prefetch()` fetches again the same way and never rejects, and a query method's `call()` and `refetch()` from `useActorMethod` resolve with the new principal's answer (`undefined` if that fetch fails). Loaders and services still use `query.fetch()` or `reactor.fetchQuery()`: hooks belong in components. ## Several Canisters of One Interface For a multi-token wallet, build one reactor and derive the others: `reactor.forCanister(canisterId)` returns a memoized sibling of the same class on the same `ClientManager`, with the same interface and validators, whose calls and query keys use its own canister. The same id always gives the same object, so it is safe in render and in `useMemo` dependencies. A single query can instead take `callConfig: { canisterId }`. ```tsx import { useMemo } from "react" import { createActorHooks, formatTokenAmount, type ReactorArgsOf, } from "@ic-reactor/react" import { ledger } from "./ledger" type Account = ReactorArgsOf[0] export function Balance({ tokenId, account, }: { tokenId: string account: Account }) { const token = useMemo( () => createActorHooks(ledger.forCanister(tokenId)), [tokenId] ) const { data: balance } = token.useActorQuery({ functionName: "icrc1_balance_of", args: [account], }) const { data: decimals } = token.useActorQuery({ functionName: "icrc1_decimals", }) if (balance === undefined || decimals === undefined) return null return ( {formatTokenAmount(balance, decimals, { maxFractionDigits: 4 })} ) } ``` Do not retarget a shared reactor with `setCanisterId`, and do not build one reactor per canister by hand. ## Types From a Reactor Read types off the reactor instead of copying shapes: - `ReactorArgsOf[0]`: the first argument in the reactor's form (raw or display) - `ReactorDataOf`: what `callMethod`, a mutation and `execute()` resolve with - `ReactorErrorOf`: a hook's `error` type - `ServiceOf`, `TransformOf` They take a reactor instance's type (any `Reactor`, `DisplayReactor` or subclass), not a query or mutation object. ## Token Amounts and Principals Ledger amounts are base units (e8s for ICP): a `bigint` from a `Reactor`, integer text from a `DisplayReactor`. Never convert them with `Number`, `parseFloat`, `Math.pow` or `toFixed`: `Math.floor(Number("0.29") * 10 ** 8)` is 28999999. ```ts import { isPrincipalText, parseTokenAmount } from "@ic-reactor/react" import { Principal } from "@icp-sdk/core/principal" import { ledger } from "./ledger" export async function send(to: string, text: string, decimals: number) { if (!isPrincipalText(to.trim())) throw new Error("Not a principal") const amount = parseTokenAmount(text, decimals) // "0.29", 8 -> 29000000n return ledger.callMethod({ functionName: "icrc1_transfer", args: [ { to: { owner: Principal.fromText(to.trim()), subaccount: [] }, amount, fee: [], memo: [], from_subaccount: [], created_at_time: [], }, ], }) } ``` - `formatTokenAmount(value, decimals, options?)` takes the value and `icrc1_decimals` as a query returns them. Options: `maxFractionDigits`, `minFractionDigits`, `trimTrailingZeros`, `roundingMode`, `locale`, `useGrouping`. It truncates past `maxFractionDigits` unless `roundingMode: "halfExpand"`, and writes plain text (`"1234.5"`) unless given a `locale`. - `parseTokenAmount(text, decimals)` returns a `bigint`. It throws a `TypeError` for text that is not digits with at most one `.` and a `RangeError` for more fraction digits than the token has or a negative amount (`{ allowNegative: true }` for an `int`). Send `amount.toString()` to a `DisplayReactor`. - `isPrincipalText(text.trim())` validates typed principal text; do not wrap `Principal.fromText` in a `try`. A `DisplayReactor` takes the text, a `Reactor` takes `Principal.fromText(text)`. - `jsonToString(value)` prints a raw result, with bigints as digits, principals as text and blobs as hex. All four are plain functions in core, re-exported by `@ic-reactor/react` and by its `react-server` entry. ## Server Rendering (SSR and RSC) A reactor owns its `QueryClient`, and query keys carry no caller principal. A reactor at module scope on a server is therefore one cache shared by every request, and a caller-scoped result (`get_my_profile`, a balance of self) is served to the next visitor. `AuthenticationManager` signs in on its `ClientManager`'s agent, so it belongs in the same request as that manager. Build the reactor, `ClientManager`, `AuthenticationManager` and any query or mutation objects inside the request. A bare `defineReactor(...)` in a module body is still module scope. Client components in a server-rendered app use `createReactorProvider`: ```tsx "use client" import { createReactorProvider, defineReactor, skipToken, } from "@ic-reactor/react" import { canisterId, idlFactory, type _SERVICE } from "../declarations/backend" // The factory runs once per mounted provider: once per request on a server export const { ReactorProvider, useReactor } = createReactorProvider(() => defineReactor<_SERVICE>({ name: "backend", idlFactory, canisterId }) ) // app/layout.tsx (a Server Component) wraps the app: // {children} export function MyProfile() { const { useActorQuery, useAuth } = useReactor() const { isAuthenticated, isAuthenticating, login } = useAuth() const { data } = useActorQuery({ functionName: "get_my_profile", args: isAuthenticated ? [] : skipToken, }) // true until the stored session has been checked, and in every server render if (isAuthenticating) return

Checking session…

if (!isAuthenticated) return return

{data?.name}

} ``` - `ReactorProvider` and `useReactor` are what the app destructures, not package exports. Call `createReactorProvider` at module scope, never inside a component, and take hooks from `useReactor()` rather than calling `createActorHooks` or `createAuthHooks` in a component. - The factory may return a record (`{ backend, ledger, posts }`, with query objects built inside it), read with `useReactor("ledger")`. `useReactor()` is typed as the factory's return: never write `as any` hook forwarders. - The factory receives the provider's props other than `children`, read once per mount; give the provider a new `key` to build again. - On unmount the provider disposes the `AuthenticationManager`s built for its value and leaves managers built elsewhere alone. When the value holds exactly one `QueryClient` (every reactor in it built on one `ClientManager`, as passing `authentication` on does), it renders a `QueryClientProvider` for it, so `useQueryClient()` and `HydrationBoundary` below it use the cache the hooks fill; pass `{ queryClientProvider: false }` to keep your own. - A provider mounted by a transition (router navigation, `startTransition`) needs a `` boundary inside it around suspending components. A React Server Component, server action or route handler imports the core runtime from `@ic-reactor/react`: its `react-server` export condition resolves to an entry that loads no React. ```tsx // app/Supply.tsx: a React Server Component import { ClientManager, Reactor, formatTokenAmount } from "@ic-reactor/react" import { QueryClient } from "@tanstack/react-query" import { canisterId, idlFactory, type _SERVICE } from "../declarations/ledger" export default async function Supply() { // Built inside the request: a module-scope reactor would share its cache // with every visitor const clientManager = new ClientManager({ queryClient: new QueryClient(), agentOptions: { host: "https://ic0.app" }, }) const ledger = new Reactor<_SERVICE>({ name: "ledger", clientManager, idlFactory, canisterId, }) const [supply, decimals] = await Promise.all([ ledger.fetchQuery({ functionName: "icrc1_total_supply" }), ledger.fetchQuery({ functionName: "icrc1_decimals" }), ]) return

{formatTokenAmount(supply, decimals, { maxFractionDigits: 0 })}

} ``` Hooks, `defineReactor`, `defineDisplayReactor`, `createReactorProvider`, `createActorHooks`, the query/mutation factories, `skipToken`, the auth classes and the identity-attribute helpers are missing exports in the `react-server` graph (`next build`: "Export X doesn't exist in target module"). When the RSC bundler ignores the `react-server` condition, add `@ic-reactor/core` as a dependency and import the runtime from it instead. Generated canister modules (hooks and `factories: true` objects) import hooks, so a server component must not import them. Module scope stays correct in client-only apps. ## Authentication `defineReactor` already returns `useAuth`, `useAgentState`, `useUserPrincipal`, `useIdentityAttributes`, `authentication` and `identityAttributes`. With a manual setup: ```ts import { AuthenticationManager, IdentityAttributesManager, createAuthHooks, createIdentityAttributeHooks, hexToUint8Array, } from "@ic-reactor/react" import { backend, clientManager } from "./manual" export const authentication = new AuthenticationManager({ clientManager }) export const { useAuth, useAgentState, useUserPrincipal } = createAuthHooks(authentication) export const identityAttributes = new IdentityAttributesManager(authentication) export const { useIdentityAttributes } = createIdentityAttributeHooks(identityAttributes) // In a click handler: pass the nonce as a callback so the popup opens within // the user gesture. A DisplayReactor returns a blob as hex. export const requestEmail = () => identityAttributes.request({ keys: ["email"], nonce: async () => hexToUint8Array( await backend.callMethod({ functionName: "register_begin" }) ), }) ``` - `createAuthHooks` takes an `AuthenticationManager`, never a `ClientManager`, and returns exactly `useAuth`, `useAgentState` and `useUserPrincipal`. `useIdentityAttributes` comes only from `createIdentityAttributeHooks`. - `useAuth()` returns `login`, `logout`, `authenticate`, `isAuthenticated`, `isAuthenticating`, `principal`, `identity` and `error`. Read the principal from it (or `useUserPrincipal()`), not from `identity.getPrincipal()` in your own state. - `useAuth()` reports `isAuthenticating: true` from the first render, and in every server render, until the first session restore settles (a failed restore counts as settled). Guard a protected route with `if (isAuthenticating)` before `if (!isAuthenticated)`. - Awaiting a nonce before `request()` ends the user gesture, and the browser blocks the Internet Identity window; pass the nonce as a callback. With a raw `Reactor`, the blob is already a `Uint8Array`: `nonce: () => backend.callMethod({ ... })`. - `authentication.dispose()` releases the auth client the manager built. It does not sign out, and a later `login()` builds a new client, so it is safe in a StrictMode effect cleanup. `createReactorProvider` calls it for you. - On `@icp-sdk/auth` v10 a manager follows sign-outs and account switches made in other tabs. - A sign-in, sign-out or account switch cancels the fetches in flight of every canister the `ClientManager` has a reactor for, removes their inactive cache entries and refetches mounted queries as the new principal. A renewed delegation for the same principal keeps the cache. Local Internet Identity: `localInternetIdentityProvider(port, canisterId?, authorizePath?)` builds `http://.localhost:/authorize`, and `authentication.prepareClient()` probes the canister for `/authorize` or the legacy `/#authorize`. Only Internet Identity builds up to `release-2026-03-16` serve a local frontend; a login that hangs on the gateway's "Response Verification Error" page is an unsupported build. ## Errors and Retries | Error | Source | Handle with | | ----------------- | ----------------------------------------------------------- | ---------------------------------------------------------------- | | `CanisterError` | the canister returned `Err` | `onCanisterError` on mutations, `isCanisterError(e)` elsewhere | | `CallError` | network, agent, certificate, a trap, a validator that threw | `onError`, `isCallError(e)` | | `ValidationError` | a `DisplayReactor` validator refused the arguments | `isValidationError(e)`, `mapValidationErrors(e)` for form fields | ```ts import { isCallError, isCanisterError, isValidationError, } from "@ic-reactor/react" import { backend } from "./reactor" export async function rename(name: string) { try { return await backend.callMethod({ functionName: "update_profile", args: [{ name }], }) } catch (error) { if (isCanisterError(error)) { // The canister's Err value: error.err, variant name: error.code return `Refused: ${error.code}` } if (isValidationError(error)) return `Invalid: ${error.message}` if (isCallError(error)) return `Network or agent failure: ${error.message}` throw error } } ``` `CanisterError` carries the canister's error value on `.err`, not `.detail`, `.data` or `.message`. `.code` is the variant key, or the `code` field when the payload has the API shape (`code`, `message`, `details`). `.details` is that payload's `details` field as decoded, never a `Map`. The guards also recognise errors from another copy of the package; prefer them to `instanceof`. Retries: - `reactorRetry` (predicate `isRetryableReactorError`) is the query retry of the `QueryClient` that `defineReactor` and `defineDisplayReactor` create. In a browser it retries transport, certificate, HTTP 5xx/408/429 and `SysTransient`/`SysUnknown` failures up to 3 times, and never a `CanisterError`, a `ValidationError`, an encode/decode failure or another HTTP 4xx (an expired delegation's 400, a 403). - A query of an update method with no `retry` of its own retries only a `SysTransient` rejection, within the `QueryClient`'s retry default. - Mutations retry nothing unless `retry` is set on them or as the `QueryClient`'s `mutations.retry` default, which also reaches `createMutation().execute()` and `useActorMethod` update calls. Each retry of an update is a new call the canister runs, so use `retry: reactorUpdateRetry` (predicate `isRetryableUpdateError`), which retries only `SysTransient`, never a number. - `reactorRetry` and `reactorUpdateRetry` return `false` wherever there is no `window` (Node scripts, server rendering, web workers), as TanStack Query retries nothing on a server by default; the agent still resends a failed HTTP request itself (3 times by default). For query retries in a Node script, pass the predicate: `retry: (count, error) => count < 3 && isRetryableReactorError(error)`. ## Code Generation Use generated code when there are many canisters, `.did` files change often, or you want consistent typed exports. | Tool | Use when | | ------------------------- | --------------------------------------------------------------------- | | `@ic-reactor/vite-plugin` | Vite app: generates on start and on `.did` changes, injects local ids | | `@ic-reactor/cli` | Any other project, CI, or explicit `ic-reactor generate` runs | | `@ic-reactor/codegen` | Custom tooling only (`runCanisterPipeline`) | ```ts // vite.config.ts import { defineConfig, loadEnv } from "vite" import { icReactor } from "@ic-reactor/vite-plugin" export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), "") return { plugins: [ icReactor({ canisters: [ { name: "backend", didFile: "./backend/backend.did", factories: true, // Written into the generated reactor, and used under `vite dev` // too in place of the local id: set it per environment canisterId: env.CANISTER_ID_BACKEND, }, ], }), ], } }) ``` The CLI reads the same per-canister fields from `ic-reactor.json` (`pnpm exec ic-reactor init`, then `pnpm exec ic-reactor generate`). Each canister's output directory holds: - `declarations/`: the IDL factory and `_SERVICE` types; - `index.generated.ts` (managed, rewritten on every run): the reactor (`backendReactor`, a `DisplayReactor` unless `mode` names another class) and six bound hooks named after the canister (`useBackendQuery`, `useBackendMutation`, ...); - `index.factories.generated.ts` (managed, only with `factories: true` and `target: "react"`): a `Query` per query method (`createQuery`, or `createQueryFactory` when it takes args) and a `Mutation` (`createMutation`) per update or oneway method, each `/* @__PURE__ */` so unused ones are tree-shaken; - `index.ts` (created once, yours to edit): re-exports `index.generated.ts`, and the factories file with `factories: true`. Put custom factories, invalidation wiring and helpers here; an export with the same name replaces the generated one. Never edit the managed files. Prefer `factories: true` over hand-written per-method factory modules. The generated modules are module scope, so they are client-only; on a server build the reactor per request. Without `canisterId`, the generated reactor reads its id from the `ic_env` cookie, which works only on a local replica (or with `allowEnvConfig: true`), so importing it throws `canisterId is required` in Node, during SSR and on a deployed origin. ## Dynamic Candid and the Parser Use `@ic-reactor/candid` for explorers, dev tools and forms over canisters whose interface is known only at run time: `CandidAdapter` fetches and parses Candid, `CandidReactor` / `CandidDisplayReactor` call it dynamically, and `MetadataReactor` / `MetadataDisplayReactor` produce form and result metadata. `forCanister` works on all four. `@ic-reactor/parser` is a dependency of candid and loads on first parse. To parse Candid yourself, import it from its single entrypoint: ```ts import init, { didToJs, parseDid, validateIDL } from "@ic-reactor/parser" await init() // required on the web build (browsers, bundlers); a no-op on Node const schema = parseDid("service : { greet : (text) -> (text) query }") console.log(schema.service?.methods) // [{ name: "greet", mode: "query", ... }] ``` There are no `/web`, `/nodejs` or `/bundler` subpaths; conditional exports pick the browser or Node build. Do not deep-import `dist/`. `validateIDL` throws the parser's message for invalid Candid instead of returning `false`, and `didToJs` / `didToTs` return source strings. ## Testing Run the real reactor and hooks against the fake replica from `@ic-reactor/react/testing` (`@ic-reactor/core/testing` without React). Do not stub a reactor with `as unknown as Reactor`, and do not hard-code query keys in tests. ```ts import { afterEach, beforeEach, expect, it } from "vitest" import { QueryClient } from "@tanstack/react-query" import { ClientManager, Reactor, isCanisterError } from "@ic-reactor/react" import { createTestCanister, installFakeReplica, type FakeReplica, } from "@ic-reactor/react/testing" import { canisterId, idlFactory, type _SERVICE } from "./declarations/backend" let replica: FakeReplica let backend: Reactor<_SERVICE> beforeEach(() => { // Install the fake before any ClientManager or agent is built replica = installFakeReplica({ canisters: { [canisterId]: createTestCanister<_SERVICE>(idlFactory, { // Handlers get the decoded args and { caller } and return raw Candid greet: ([name]) => `Hello, ${name}!`, update_profile: () => ({ Err: { NotFound: null } }), }), }, }) backend = new Reactor<_SERVICE>({ clientManager: new ClientManager({ queryClient: new QueryClient(), agentOptions: { host: replica.host }, }), name: "backend", canisterId, idlFactory, }) }) afterEach(() => replica.restore()) it("greets", async () => { await expect( backend.fetchQuery({ functionName: "greet", args: ["Ada"] }) ).resolves.toBe("Hello, Ada!") }) it("turns an Err into a CanisterError", async () => { const error = await backend .callMethod({ functionName: "update_profile", args: [{ name: "" }] }) .catch((e: unknown) => e) expect(isCanisterError(error) && error.code).toBe("NotFound") }) ``` - A handler returns `{ Err: ... }` for a `CanisterError`, and throws for the `CallError` of a trap. - For a module-scope `defineReactor(...)`, install the fake at the top of the test file (or in a setup file) and `await import(...)` the components after it. With no `host` on either side, both use the page origin in jsdom or happy-dom. Reset a shared reactor between tests with `queryClient.clear()`. - Generated code is module scope too: its entry imports your `clientManager`. Import `idlFactory` and `_SERVICE` from the canister's `declarations/` folder (it builds nothing), key the fake by the `canisterId` the generator wrote into `index.generated.ts`, and `await import(...)` the entry and the components after the fake. Vitest runs `vite.config.ts` (unless a `vitest.config.ts` replaces it), so the Vite plugin generates again in mode `test`: set the variable a `loadEnv` `canisterId` comes from in `.env.test`, or importing the regenerated entry throws `canisterId is required`. - Test a signed-in user with `clientManager.updateAgent(identity)`, for example `Ed25519KeyIdentity.generate()`; the kit does not simulate Internet Identity. - The kit signs with `@noble/curves`, an optional peer that `@icp-sdk/core` already installs; add it as a devDependency under a strict install. ## Do Not - Call hooks in loaders, actions, services or scripts. - Create hooks (`createActorHooks`, `createAuthHooks`) on every render: build them at module scope, in a `useMemo` keyed by what they depend on, or take them from `useReactor()`. - Put a state-changing update method in a query hook or query factory (`useActorQuery`, `createQuery`, `createQueryFactory`, the infinite and suspense variants); use a mutation. - Give an update mutation, `useActorMethod` or a `mutations.retry` default a numeric `retry`; use `retry: reactorUpdateRetry`. - Hand-write query keys (`["get_posts"]`) or add the canister id to a `queryKey`; pass query objects, query factories or `{ functionName, args? }` to `invalidateQueries`. - Write `args: [userId!]`, placeholder args, or `as any` plus `enabled` for args not known yet; pass `skipToken`. - Check `"Ok" in data`: a `Result` is already unwrapped, and an `Err` throws. - Do token math with `Number(x) / 10 ** decimals`, `parseFloat`, `Math.pow` or `toFixed`; use `formatTokenAmount` / `parseTokenAmount`. - Retarget a shared reactor with `setCanisterId`; use `reactor.forCanister(canisterId)`. - Write `defineReactor({ display: true })`; use `defineDisplayReactor`. - Build a reactor, `ClientManager` or `AuthenticationManager` at module scope in a server-rendered app, or import hooks into a server component. - Pass a `ClientManager` to `createAuthHooks`, or build a second `AuthenticationManager` instead of passing `authentication` on. - Use `useQueryClient()` for cache writes, or hand-roll `setQueryData` snapshots for optimistic UI; use `reactor.queryClient` and `optimisticUpdate`. - Stub a `Reactor` with `as unknown as Reactor` in tests. - Edit `index.generated.ts` or `index.factories.generated.ts`, or repeat per-method factories that `factories: true` generates. - Leave out the `<_SERVICE>` type argument, or cast hooks to `any`. ## Links - Documentation: https://ic-reactor.b3pay.net/v3/ - Index for agents: https://ic-reactor.b3pay.net/llms.txt - Changelog: https://github.com/B3Pay/ic-reactor/blob/main/CHANGELOG.md - Agent skill: https://github.com/B3Pay/ic-reactor/tree/main/skill-packages/ic-reactor - React setup: https://ic-reactor.b3pay.net/v3/framework/react-setup.md - Queries: https://ic-reactor.b3pay.net/v3/framework/queries.md - Mutations: https://ic-reactor.b3pay.net/v3/framework/mutations.md - Authentication: https://ic-reactor.b3pay.net/v3/guides/authentication.md - Error handling: https://ic-reactor.b3pay.net/v3/guides/error-handling.md - Testing: https://ic-reactor.b3pay.net/v3/guides/testing.md - createReactorProvider: https://ic-reactor.b3pay.net/v3/reference/createReactorProvider.md - Next.js App Router example: https://ic-reactor.b3pay.net/v3/examples/nextjs-app-router.md