Skip to content
IC Reactor

ClientManager

ClientManager is the central orchestrator that unifies three essential concerns of IC development into a single, coherent system:

HttpAgent

Manages the IC HTTP agent for all canister communication, handling network detection and initialization

Identity

Holds the active identity and swaps it into the agent when it changes. Internet Identity sign-in itself lives in AuthenticationManager (@ic-reactor/react)

QueryClient

Integrates TanStack Query for automatic caching, deduplication, and background data updates

Without ClientManager, connecting to the IC typically involves:

  • Creating and configuring an HttpAgent manually
  • Passing agents to actors and keeping them in sync after login/logout
  • Manually invalidating cached data when identity changes

ClientManager solves this by providing a unified interface where all these concerns are managed together. When the identity changes, the agent is updated, the affected queries invalidate, and your reactors seamlessly use the new identity.

import { ClientManager, Reactor } from "@ic-reactor/core"
import { AuthenticationManager } from "@ic-reactor/react"
import { QueryClient } from "@tanstack/query-core"
import {
canisterId,
idlFactory,
type _SERVICE as BackendService,
} from "./declarations/backend"
import {
canisterId as ledgerCanisterId,
idlFactory as ledgerIdlFactory,
type _SERVICE as LedgerService,
} from "./declarations/ledger"
const queryClient = new QueryClient()
// One ClientManager, shared by all your reactors
const clientManager = new ClientManager({ queryClient })
// Create multiple reactors - they all share the same agent and identity
const backend = new Reactor<BackendService>({
clientManager,
idlFactory,
name: "backend",
canisterId,
})
const ledger = new Reactor<LedgerService>({
clientManager,
idlFactory: ledgerIdlFactory,
name: "ledger",
canisterId: ledgerCanisterId,
})
const authentication = new AuthenticationManager({ clientManager })
// Sign in once - all reactors automatically use the new identity
async function handleLogin() {
await authentication.login()
}
import { ClientManager } from "@ic-reactor/core"
import { ClientManager } from "@ic-reactor/core"
import { QueryClient } from "@tanstack/query-core"
const queryClient = new QueryClient()
const clientManager = new ClientManager({
queryClient,
})
Option Type Default Description
queryClient QueryClient Required TanStack Query client instance
agentOptions HttpAgentOptions {} Options forwarded to the HttpAgent (host, identity, rootKey, retryTimes, verifyQuerySignatures, …)
allowEnvConfig boolean host-dependent Whether to trust the ic_env cookie: its root key, its Internet Identity provider, and the canister IDs a reactor resolves by name. Defaults to true only when both the agent host and the page are unambiguously a local replica (loopback, localhost and its subdomains); anything else must opt in, even on Codespaces or Gitpod. Without the option, the root key alone is judged by the agent host only (see Canister Environment).
allowEnvRootKey boolean — Deprecated. The former name of allowEnvConfig. Still honoured, and still scoped to what it always granted: the root key alone, not the identity provider or the canister ID.

Network detection needs no flag — see Network Configuration.

Property Type Description
agent HttpAgent The IC HTTP agent (getter)
queryClient QueryClient TanStack Query client
agentState AgentState Current agent initialization state
agentHost URL | undefined The host URL of the agent
network "ic" | "local" | "remote" Current network type
isLocal boolean Whether connected to local replica
trustsEnvConfig boolean Whether the ic_env cookie is trusted for this agent host and page (getter)
explicitRootKey Uint8Array | undefined The agentOptions.rootKey you passed, which initialize() keeps (getter)
interface AgentState {
isInitialized: boolean
isInitializing: boolean
isLocalhost: boolean
network: string | undefined // "ic" | "local" | "remote"
error: Error | undefined
}

Initialize the agent:

await clientManager.initialize()

This method:

  1. Initializes the HttpAgent (fetches root key for local networks)
  2. Returns the ClientManager instance

When the agent host is local, Codespaces or Gitpod (network is not "ic"), initialize() fetches the root key from the replica’s /api/v2/status and replaces the key the agent holds: IC_ROOT_KEY from the ic_env cookie, or mainnet’s when agentOptions.shouldFetchRootKey is false and no key was passed. It rejects if /api/v2/status can’t be reached.

A key you pass as agentOptions.rootKey is kept: initialize() fetches nothing, so it resolves even when /api/v2/status is unreachable, and every call, before and after it, is verified against your key. Pass one to pin a key obtained out of band, such as a PocketIC or testnet key, or that of the network behind a local proxy. A key that does not match the replica fails every certified call. On a mainnet host nothing is fetched and a supplied key stays.

Session restoration is not part of this call — it belongs to AuthenticationManager.authenticate() (or the useAuth hook, which calls it for you).


Initialize the HttpAgent. initialize() delegates to this method, so the two are interchangeable:

await clientManager.initializeAgent()

Swap the agent’s identity and, when the principal changes, sweep the queries of every registered canister:

clientManager.updateAgent(newIdentity)

On a switch to another principal, in-flight queries for those canisters are cancelled first, so results signed by the previous identity cannot land in the cache (see Automatic Query Invalidation). Queries that do not belong to a registered canister — the rest of your app’s REST/GraphQL data — are left untouched.

When the new identity has the principal already installed — a renewed delegation, a sign-in while signed in, an identity attribute request — the cache is kept: nothing is cancelled or removed, and only queries whose last fetch failed are refetched. The first updateAgent() after construction always sweeps.


Run an imperative fetch of a registered canister’s query, and run it again when the principal switches while it runs:

const pages = await clientManager.fetchAcrossIdentitySwitch(() =>
clientManager.queryClient.fetchInfiniteQuery(options)
)

updateAgent() cancels those queries when the principal changes. A query with a mounted observer refetches afterwards, but an imperative fetch has no observer. TanStack rejects its promise with a CancelledError, or, when the entry already held data before the fetch began (a stale entry that fetchQuery or fetchInfiniteQuery refetches), resolves it with that data, which is the previous principal’s. Through this method it resolves with the answer for the identity installed now. Reactor.fetchQuery(), the query factories’ fetch() and prefetch(), and a query method’s call() and refetch() from useActorMethod already run through it.

Whatever the fetch settles with while the principal stays is passed on as is, including a cancellation from your own cancelQueries(). When it has run again three times and another switch overtook each of those runs too, it rejects with a CallError rather than return an answer that may belong to a previous principal. fetch is called once per run, so it must start a new fetch each time.


Get the current user’s Principal. This forwards agent.getPrincipal(), so it returns a promise:

const principal = await clientManager.getUserPrincipal()

Subscribe to agent state changes:

const unsubscribe = clientManager.subscribeAgentState((state) => {
console.log("Agent state:", state)
// { isInitialized, isInitializing, isLocalhost, network, error }
})
// Later
unsubscribe()

Subscribe to identity changes:

const unsubscribe = clientManager.subscribe((identity) => {
console.log("New identity:", identity.getPrincipal().toText())
})

Register a canister ID for tracking:

clientManager.registerCanisterId(canisterId, "backend")

This is called automatically when creating a Reactor.


Get all registered canister IDs:

const canisterIds = clientManager.connectedCanisterIds()
src/reactor/index.ts
import { ClientManager, Reactor } from "@ic-reactor/core"
import { QueryClient } from "@tanstack/query-core"
import { idlFactory, type _SERVICE } from "../declarations/backend"
// Create QueryClient with defaults
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60_000, // 1 minute
gcTime: 5 * 60_000, // 5 minutes
retry: 3,
},
},
})
// Create ClientManager
export const clientManager = new ClientManager({
queryClient,
})
// Create Reactor
export const backend = new Reactor<_SERVICE>({
clientManager,
idlFactory,
name: "backend",
canisterId: import.meta.env.VITE_BACKEND_CANISTER_ID,
})
import { ClientManager } from "@ic-reactor/core"
// Browser: the host is inferred from window.location.origin when the page is
// served from localhost/127.0.0.1, a Codespaces or Gitpod domain, or an IC
// boundary domain
const browserManager = new ClientManager({ queryClient })
// Explicit local host — non-browser environments, or a replica on a custom port
const localManager = new ClientManager({
queryClient,
agentOptions: { host: "http://127.0.0.1:8080" },
})
// Custom agent options
const mainnetManager = new ClientManager({
queryClient,
agentOptions: {
host: "https://icp-api.io",
verifyQuerySignatures: true,
},
})

When your app is served by the @icp-sdk toolchain (e.g., icp-cli) or by @ic-reactor/vite-plugin, the dev server injects an ic_env cookie carrying the local canister IDs and root key. ClientManager reads that cookie automatically in the browser. Host detection needs no option to enable. What the cookie carries is trusted only for local hosts, and allowEnvConfig overrides that decision for all of it:

  • Agent host taken from window.location.origin when the page is served from a local host, a Codespaces or Gitpod domain (network "remote"), or an IC boundary host. In a web worker, which has no window, it is taken from the worker’s location.origin, the origin of the page that started it
  • Query signature verification disabled by default in a development build when the agent host is a local replica, whose root key the agent fetches from the replica itself. A development build pointed at mainnet verifies query signatures like a production build. An explicit verifyQuerySignatures wins either way
  • Root key from the cookie, accepted when the agent host is unambiguously a local replica — loopback (the whole of 127.0.0.0/8, plus ::1), localhost and its subdomains. Cookies are not origin-isolated and the root key is what certificate verification is checked against, so any other agent host — including a custom domain — must opt in with allowEnvConfig: true. This check reads the agent host alone.
  • Codespaces (*.app.github.dev) and Gitpod (*.gitpod.io) are not trusted automatically. Every workspace on them is a subdomain of a parent that is not a public suffix, so a page in someone else’s workspace can set ic_env for yours. Their agent fetches the root key from the replica instead; canister IDs come from your configuration (the codegen canisterId), or from the cookie once you pass allowEnvConfig: true.
  • Canister IDs from the cookie (PUBLIC_CANISTER_ID:<name>), used when a reactor is constructed without an explicit canisterId, and accepted only when the page is served from such a host too, not just the agent host. That is the decision the trustsEnvConfig getter reports, and the Internet Identity provider follows it as well. Otherwise the reactor throws, naming the option to set. A substituted canister ID is not something certificate verification can catch — the attacker names a real canister, and its responses verify against the real mainnet root key — so it fails closed rather than silently talking to someone else’s canister.
import { useEffect } from "react"
// In React with useEffect
export function useClientManagerLogging() {
useEffect(() => {
const unsubIdentity = clientManager.subscribe((identity) => {
console.log("New identity:", identity.getPrincipal().toText())
})
const unsubAgent = clientManager.subscribeAgentState((state) => {
if (state.isInitialized) {
console.log("Agent ready, network:", state.network)
}
})
return () => {
unsubIdentity()
unsubAgent()
}
}, [])
}

Sign-in lives on AuthenticationManager from @ic-reactor/react; ClientManager only receives the resulting identity.

import { AuthenticationManager } from "@ic-reactor/react"
const authentication = new AuthenticationManager({ clientManager })
async function start() {
// Initialize the agent on app start
await clientManager.initialize()
// Restore a previous session, if any
await authentication.authenticate()
if (authentication.authState.isAuthenticated) {
console.log(
"Welcome back!",
(await clientManager.getUserPrincipal()).toText()
)
}
}
// Login handler
async function handleLogin() {
await authentication.login({
onSuccess: () => {
console.log("Logged in!")
// Queries for connected canisters are automatically invalidated
},
onError: (error) => {
console.error("Login failed:", error)
},
})
}
// Logout handler
async function handleLogout() {
await authentication.logout()
// Queries for connected canisters are automatically invalidated
}
import { ClientManager } from "@ic-reactor/core"
const clientManager = new ClientManager({ queryClient })
// Check current network
console.log(clientManager.isLocal) // true if local
console.log(clientManager.network) // "local", "remote", or "ic"
console.log(clientManager.agentHost?.toString()) // The host URL

When the principal changes, updateAgent() sweeps the cached queries of every canister registered with this ClientManager:

  • In-flight queries are cancelled, so a response signed by the previous identity cannot land after the switch. A fetchQuery() or factory fetch() that was waiting on one runs again for the new identity and resolves with that answer, as does a query method’s call() or refetch() from useActorMethod, and a factory prefetch() runs again too (see fetchAcrossIdentitySwitch)
  • Inactive entries are removed — not just invalidated. Query keys carry no principal, so a merely-stale entry (a balance whose component has unmounted, a cached profile) would stay readable through getQueryData or fetchQuery under the new identity
  • Active entries are invalidated, so their mounted observers refetch as the new identity. They keep showing the previous identity’s data only for the length of that refetch
// This happens automatically on identity change, for each connected canister:
queryClient.cancelQueries({ queryKey: [canisterId] })
queryClient.removeQueries({ queryKey: [canisterId], type: "inactive" })
queryClient.invalidateQueries({ queryKey: [canisterId] })

Because the scope is the canister ID — the first segment of every reactor query key — non-reactor queries in a shared QueryClient are not refetched or removed on sign-in or sign-out.

A new identity for the principal already installed is not a switch: the cached answers are already that principal’s, so the sweep is skipped. Only the entries whose last fetch failed are invalidated, since the previous identity (an expired delegation, say) may be why they failed. A fetch already in flight, signed by the previous identity, is left to finish, and its answer is kept; if it fails instead, its entry is invalidated too. The first updateAgent() after construction always sweeps, whatever it installs.

The @icp-sdk/auth package is an optional peer dependency. ClientManager never imports it. AuthenticationManager loads it through a dynamic import("@icp-sdk/auth/client"), so bundlers put it in a separate chunk and drop it entirely from apps that never construct one.

Terminal window
# Only install if you need authentication
npm install @icp-sdk/auth

While AuthenticationManager covers Internet Identity, ClientManager itself is provider-agnostic and works with any identity provider. You can integrate alternative authentication methods by updating the agent’s identity.

When using external authentication (wallets, SIWE, NFID, etc.), update the agent after authenticating:

async function signInWithProvider() {
// Authenticate with your external provider
const identity = await externalAuthProvider.getIdentity()
// Update ClientManager - all reactors automatically use the new identity
clientManager.updateAgent(identity)
}

This pattern works with any provider that produces a valid Identity object.

We’re exploring first-class support for popular authentication standards:

Provider Description Status
SIWE Sign-In With Ethereum — use MetaMask, WalletConnect, etc. 🔮 Planned
SIWS Sign-In With Solana — use Phantom, Solflare, etc. 🔮 Planned

Here’s how SIWE integration works with ic-siwe-js:

ic-siwe-js builds on @dfinity/identity, and TypeScript treats that package’s DelegationIdentity and Principal as different types from the ones in @icp-sdk/core: each declares private fields, so clientManager.updateAgent(identity) does not compile with the identity it returns. toIdentity below forwards the two methods an Identity needs, getPrincipal and transformRequest, and converts the principal, so ClientManager signs with the SIWE delegation. The cast in transformRequest only crosses the two packages’ copies of the same request type.

import { useEffect } from "react"
import type { Identity } from "@icp-sdk/core/agent"
import { Principal } from "@icp-sdk/core/principal"
// 1. Wrap your app with SiweIdentityProvider
import { SiweIdentityProvider } from "ic-siwe-js/react"
function App() {
return (
<SiweIdentityProvider canisterId={siweProviderCanisterId}>
<YourApp />
</SiweIdentityProvider>
)
}
// 2. Use the useSiwe hook to login and get identity
import { useSiwe } from "ic-siwe-js/react"
type SiweIdentity = NonNullable<ReturnType<typeof useSiwe>["identity"]>
type SiweRequest = Parameters<SiweIdentity["transformRequest"]>[0]
function toIdentity(siweIdentity: SiweIdentity): Identity {
return {
getPrincipal: () =>
Principal.fromText(siweIdentity.getPrincipal().toText()),
transformRequest: (request) =>
siweIdentity.transformRequest(request as unknown as SiweRequest),
}
}
function LoginButton() {
const { login, identity, isLoggingIn } = useSiwe()
// After login, update ClientManager with the SIWE identity
useEffect(() => {
if (identity) {
clientManager.updateAgent(toIdentity(identity))
}
}, [identity])
return (
<button onClick={login} disabled={isLoggingIn}>
{isLoggingIn ? "Signing in..." : "Sign in with Ethereum"}
</button>
)
}