Skip to content

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

AuthClient

Coordinates authentication with Internet Identity, including login, logout, and session restoration

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
  • Managing AuthClient lifecycle separately
  • 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 a user logs in, the agent automatically updates, all queries invalidate, and your reactors seamlessly use the new identity.

// One ClientManager, shared by all your reactors
const clientManager = new ClientManager({ queryClient, withProcessEnv: true })
// Create multiple reactors - they all share the same agent and auth state
const backend = new Reactor<BackendService>({
clientManager,
idlFactory,
canisterId,
})
const ledger = new Reactor<LedgerService>({
clientManager,
idlFactory: ledgerIdl,
canisterId: ledgerId,
})
// Login once - all reactors automatically use the new identity
await clientManager.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,
withProcessEnv: true,
})
OptionTypeDefaultDescription
queryClientQueryClientRequiredTanStack Query client instance
authClientAuthClient-Pre-initialized AuthClient instance
withProcessEnvbooleanfalseAuto-detect network from DFX_NETWORK
withLocalEnvbooleanfalseForce local development mode
portnumber4943Local replica port
agentOptionsHttpAgentOptions{}Additional HttpAgent options
PropertyTypeDescription
agentHttpAgentThe IC HTTP agent (getter)
queryClientQueryClientTanStack Query client
agentStateAgentStateCurrent agent initialization state
authStateAuthStateCurrent authentication state
agentHostURLThe host URL of the agent
network"ic" | "local" | "remote"Current network type
isLocalbooleanWhether connected to local replica
interface AgentState {
isInitialized: boolean
isInitializing: boolean
isLocalhost: boolean
network: "ic" | "local" | "remote" | undefined
error: Error | undefined
}
interface AuthState {
identity: Identity | null
isAuthenticated: boolean
isAuthenticating: boolean
error: Error | undefined
}

Initialize the agent and trigger authentication in the background:

await clientManager.initialize()

This method:

  1. Initializes the HttpAgent (fetches root key for local networks)
  2. Triggers session restoration in the background (non-blocking)
  3. Returns the ClientManager instance

Initialize only the HttpAgent (without authentication):

await clientManager.initializeAgent()

Attempt to restore a previous authentication session:

const identity = await clientManager.authenticate()

This dynamically imports the @icp-sdk/auth module. If the module isn’t installed, it fails gracefully.


Authenticate with Internet Identity:

await clientManager.login({
identityProvider: "https://identity.ic0.app",
maxTimeToLive: BigInt(7 * 24 * 60 * 60 * 1_000_000_000), // 7 days
onSuccess: () => console.log("Logged in!"),
onError: (error) => console.error("Login failed:", error),
})

Clear authentication and reset to anonymous identity:

await clientManager.logout()

This automatically invalidates all cached queries.


Update the agent’s identity and invalidate all queries:

clientManager.updateAgent(newIdentity)

Get the current user’s Principal:

const principal = 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 authentication state changes:

const unsubscribe = clientManager.subscribeAuthState((state) => {
console.log("Auth state:", state)
// { identity, isAuthenticated, isAuthenticating, 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,
withProcessEnv: true, // Auto-detect network
})
// Create Reactor
export const backend = new Reactor<_SERVICE>({
clientManager,
idlFactory,
canisterId: import.meta.env.VITE_BACKEND_CANISTER_ID,
})
// Auto-detect based on DFX_NETWORK environment variable
const clientManager = new ClientManager({
queryClient,
withProcessEnv: true,
})
// Force local development mode
const clientManager = new ClientManager({
queryClient,
withLocalEnv: true,
port: 8080, // Custom port
})
// Custom agent options
const clientManager = new ClientManager({
queryClient,
agentOptions: {
host: "https://icp-api.io",
verifyQuerySignatures: true,
},
})
import { AuthClient } from "@icp-sdk/auth/client"
const authClient = await AuthClient.create()
const clientManager = new ClientManager({
queryClient,
authClient, // Pass existing AuthClient
})
// In React with useEffect
useEffect(() => {
const unsubAuth = clientManager.subscribeAuthState((state) => {
if (state.isAuthenticated) {
console.log("User logged in:", state.identity?.getPrincipal().toText())
}
})
const unsubAgent = clientManager.subscribeAgentState((state) => {
if (state.isInitialized) {
console.log("Agent ready, network:", state.network)
}
})
return () => {
unsubAuth()
unsubAgent()
}
}, [])
// Initialize on app start
await clientManager.initialize()
// Check if already authenticated
if (clientManager.authState.isAuthenticated) {
console.log("Welcome back!", clientManager.getUserPrincipal().toText())
}
// Login handler
async function handleLogin() {
await clientManager.login({
onSuccess: () => {
console.log("Logged in!")
// Queries are automatically invalidated
},
onError: (error) => {
console.error("Login failed:", error)
},
})
}
// Logout handler
async function handleLogout() {
await clientManager.logout()
// Queries are automatically invalidated
}
const clientManager = new ClientManager({
queryClient,
withProcessEnv: true,
})
// 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 authentication state changes (login/logout), ClientManager automatically invalidates all cached queries. This ensures:

  • Fresh data is fetched for the new identity
  • No cached data leaks between users
  • Queries re-run with the new principal
// This happens automatically on identity change:
queryClient.invalidateQueries()

The @icp-sdk/auth package is an optional peer dependency. The auth module is only dynamically imported when you call login() or authenticate(). This keeps your bundle size small if you don’t need authentication.

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

While ClientManager includes built-in support for Internet Identity, it’s designed to work 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:

ProviderDescriptionStatus
SIWESign-In With Ethereum — use MetaMask, WalletConnect, etc.🔮 Planned
SIWSSign-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>
)
}