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.

// 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: ledgerIdl,
name: "ledger",
canisterId: ledgerId,
})
// Sign in once - all reactors automatically use the new identity
const authentication = new AuthenticationManager({ clientManager })
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 for hosts that are unambiguously a local replica (loopback, localhost and its subdomains, dev-container tunnel domains); every other host must opt in.
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 host (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

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 invalidate the queries of every registered canister:

clientManager.updateAgent(newIdentity)

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


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,
})
// Browser: the host is inferred from window.location.origin when the page is
// served from localhost/127.0.0.1 or from an IC boundary domain
const clientManager = new ClientManager({ queryClient })
// Explicit local host — non-browser environments, or a replica on a custom port
const clientManager = new ClientManager({
queryClient,
agentOptions: { host: "http://127.0.0.1:8080" },
})
// Custom agent options
const clientManager = 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; everything the cookie carries is gated on one decision, exposed as the trustsEnvConfig getter and controlled by allowEnvConfig:

  • Agent host taken from window.location.origin when the page is served from a local or IC boundary host
  • Query signature verification disabled in development for performance
  • Root key from the cookie, accepted only on hosts that are unambiguously a local replica — loopback (the whole of 127.0.0.0/8, plus ::1), localhost and its subdomains, and the dev-container domains that tunnel a local replica. Cookies are not origin-isolated and the root key is what certificate verification is checked against, so any other host — including a custom domain — must opt in with allowEnvConfig: true.
  • Canister IDs from the cookie (PUBLIC_CANISTER_ID:<name>), used when a reactor is constructed without an explicit canisterId, and accepted on exactly the same hosts. On any other host the reactor throws instead, 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.
// In React with useEffect
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 })
// 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
}
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 identity 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
  • 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.

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:

// 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:

// 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"
function LoginButton() {
const { login, identity, isLoggingIn } = useSiwe()
// After login, update ClientManager with the SIWE identity
useEffect(() => {
if (identity) {
clientManager.updateAgent(identity)
}
}, [identity])
return (
<button onClick={login} disabled={isLoggingIn}>
{isLoggingIn ? "Signing in..." : "Sign in with Ethereum"}
</button>
)
}