Getting started
This page takes an app from nothing to a first read and a first write. It uses
the ICRC-1 ledger interface (icrc1.did) and the ICP ledger on mainnet.
1. Install
Section titled “1. Install”ic-reactor 4 is published under npm’s beta dist-tag, and latest is still
3.x. Install the @ic-reactor/* packages as @ic-reactor/core@beta, never
bare.
The core packages and the runtime they sit on:
npm install @ic-reactor/core@beta @icp-sdk/core @tanstack/query-coreFor React, add the provider and TanStack’s React bindings. For Internet
Identity sign-in, add @icp-sdk/auth:
npm install @ic-reactor/react@beta @tanstack/react-querynpm install @icp-sdk/authThe generator is a development tool:
@candid-core/schema and @candid-core/cli are installed exactly
(--save-exact) because the generated module imports the schema runtime, and
the CLI has to pair with that runtime. @ic-reactor/core lists the schema
version it needs in its peerDependencies; @ic-reactor/vite-plugin lists the
CLI version.
2. Generate the module
Section titled “2. Generate the module”Everything the client knows about a canister comes from a module that
candid-core-cli gen writes from the canister’s .did file:
npx candid-core-cli gen icrc1.did -o src/generatedThe module is named after the .did file: this writes src/generated/icrc1.ts
and, beside it, src/generated/icrc1.envelope.json. Do not edit either. The
module exports a TypeScript type and a schema for each Candid type, the
service schema actor, and type Actor, which lists each method as
(arg0: ...) => Promise<...>.
Several files can be generated at once:
npx candid-core-cli gen a.did b.did -o src/canisters.
In CI, add --check. It writes nothing and exits 1 when the files on disk
differ from what the generator would write:
npx candid-core-cli gen icrc1.did -o src/generated --checkOr generate with the Vite plugin
Section titled “Or generate with the Vite plugin”In a Vite app, the plugin runs the same generator when vite dev or
vite build starts, and again when the .did file changes. Set outDir to
src/generated to match the imports on this page (without it the plugin
writes to src/canisters):
import { icReactor } from "@ic-reactor/vite-plugin"import { defineConfig } from "vite"
export default defineConfig({ plugins: [ icReactor({ canisters: { icrc1: { didFile: "icrc1.did", outDir: "src/generated" }, }, }), ],})The plugin also sets up a local environment. See The Vite plugin.
3. Set up a client and a canister
Section titled “3. Set up a client and a canister”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",})createClient does no work until it is used. identity: "anonymous" is a
client that only reads: it refuses every update before sending anything.
client.canister() returns an object with one async method per Candid method.
It returns the same object each time, so it is safe to call in a render.
Read The client for the other networks and for signing users in.
4. A first read, outside React
Section titled “4. A first read, outside React”Call the methods directly:
import { formatUnits } from "@ic-reactor/core"import { ledger } from "./ledger"
const decimals = await ledger.icrc1_decimals() // 8const fee = await ledger.icrc1_fee() // 10000n: a bigint, never a number
console.log(formatUnits(fee, decimals)) // "0.0001"Amounts are bigint in base units. formatUnits turns one into text, and
parseUnits goes the other way. See Values and units.
A direct call never touches a cache. To read through the client’s cache outside
React, use client.queryClient.fetchQuery(client.queryOptions(...)); see
Reads.
5. A first read, in React
Section titled “5. A first read, in React”Put a ReactorProvider around the tree. Its client prop is a factory: the
provider calls it once and owns the client it creates, disposing it when the
provider unmounts.
import { createClient } from "@ic-reactor/core"import { ReactorProvider, useClient } from "@ic-reactor/react"import { useQuery } from "@tanstack/react-query"import { actor, type Actor } from "./generated/icrc1"
export function App() { return ( <ReactorProvider client={() => createClient({ network: "ic", identity: "anonymous" })} > <Fee /> </ReactorProvider> )}
function Fee() { const client = useClient() const ledger = client.canister<Actor>(actor, { id: "ryjl3-tyaaa-aaaaa-aaaba-cai", }) const fee = useQuery(client.queryOptions(ledger, "icrc1_fee"))
if (fee.isPending) return <p>Loading</p> if (fee.isError) return <p>{fee.error.message}</p> return <p>Fee: {fee.data.toString()} base units</p>}useQuery is TanStack Query’s own hook. client.queryOptions(ledger, method, arg) gives it the key, the function and the retry rule. Build the options in
render, from the useClient() of that render.
fee.error is a ReactorError. See
Errors and mayHaveExecuted.
6. A first write
Section titled “6. A first write”A write needs a signed-in caller. Give the client an auth factory instead of
an identity: users then sign in and out, and the client calls as whoever is
signed in. The factory runs once, in a browser only.
import { createClient } from "@ic-reactor/core"import { ReactorProvider, useAuth, useClient } from "@ic-reactor/react"import { AuthClient } from "@icp-sdk/auth/client"import { useMutation } from "@tanstack/react-query"import { useState } from "react"import { actor, type Actor, type TransferArg } from "./generated/icrc1"
export function App({ arg }: { arg: TransferArg }) { return ( <ReactorProvider client={() => createClient({ network: "ic", auth: () => new AuthClient() }) } > <Send arg={arg} /> </ReactorProvider> )}
function Send({ arg }: { arg: TransferArg }) { const client = useClient() const { status, signIn } = useAuth() const [signInError, setSignInError] = useState<string>() const ledger = client.canister<Actor>(actor, { id: "ryjl3-tyaaa-aaaaa-aaaba-cai", }) const transfer = useMutation(client.mutationOptions(ledger, "icrc1_transfer"))
if (status !== "signed-in") return ( <> <button onClick={() => // signIn() rejects when the sign-in fails or the user closes it. signIn().catch((error: unknown) => setSignInError(String(error))) } > Sign in </button> {signInError && <p>{signInError}</p>} </> ) return ( <> <button disabled={transfer.isPending} onClick={() => transfer.mutate(arg)} > Send </button> {transfer.error && ( <p> {transfer.error.mayHaveExecuted ? "Unknown outcome: check the balance before sending again" : transfer.error.message} </p> )} </> )}Three things in this snippet matter:
icrc1_transferreturnsvariant { Ok : nat; Err : TransferError }. The mutation resolves with theOkvalue, and anErrrejects as aReactorErrorof kind"canister_err"with theErrvalue inerr.- A failed write either certainly had no effect, or may have executed.
error.mayHaveExecutedsays which. When it istrue, do not send the transfer again blindly: read the balance first. - There is no
retryon the mutation, and there must not be one. The client itself re-sends an update, at most twice, only after a failure that proves the canister never got it.
After the write, the client invalidates the ledger’s reads, so a mounted balance refetches by itself. See Writes.
- The client: networks,
identityandauth, disposal, one client per request on a server. - Reads and Writes: the rules behind
queryOptionsandmutationOptions. - Errors and mayHaveExecuted: what to do for each kind of failure.
- Auth: signing in and out, and what happens to reads when the caller changes.
- Examples: four small apps to read and run.