Skip to content
IC Reactor

React Setup

This guide covers the complete setup of IC Reactor in a React application, including authentication with Internet Identity.

  1. Set up your central configuration:

    src/reactor/index.ts
    import { ClientManager, Reactor } from "@ic-reactor/react"
    import { QueryClient } from "@tanstack/react-query"
    import { idlFactory, type _SERVICE } from "../declarations/backend"
    export const queryClient = new QueryClient()
    export const clientManager = new ClientManager({ queryClient })
    export const backend = new Reactor<_SERVICE>({
    clientManager,
    idlFactory,
    name: "backend",
    canisterId: import.meta.env.VITE_BACKEND_CANISTER_ID,
    })
  2. src/reactor/hooks.ts
    import {
    AuthenticationManager,
    createActorHooks,
    createAuthHooks,
    } from "@ic-reactor/react"
    import { backend, clientManager } from "./index"
    // Actor hooks for your canister
    export const {
    useActorQuery,
    useActorMutation,
    useActorSuspenseQuery,
    useActorInfiniteQuery,
    } = createActorHooks(backend)
    // Auth hooks are bound to an AuthenticationManager, not the ClientManager
    export const authentication = new AuthenticationManager({ clientManager })
    export const {
    useAuth,
    useUserPrincipal,
    useAgentState,
    } = createAuthHooks(authentication)
  3. While not required (as hooks are already bound to your reactor and its internal queryClient), you can wrap your application with QueryClientProvider to use additional TanStack Query features or to enable the DevTools:

    src/App.tsx
    import { QueryClientProvider } from "@tanstack/react-query"
    import { ReactQueryDevtools } from "@tanstack/react-query-devtools"
    import { queryClient } from "./reactor"
    import Router from "./Router"
    function App() {
    return (
    <QueryClientProvider client={queryClient}>
    <Router />
    <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
    )
    }
    export default App

The useAuth hook provides login and logout functionality:

import { useAuth } from "../reactor/hooks"
function LoginButton() {
const { login, logout, isAuthenticated, isAuthenticating } = useAuth()
if (isAuthenticating) {
return <button disabled>Connecting...</button>
}
return isAuthenticated ? (
<button onClick={logout}>Logout</button>
) : (
<button onClick={() => login()}>Login with Internet Identity</button>
)
}

For accessing the useAgentState connection state:

import { useAgentState } from "../reactor/hooks"
function ConnectionStatus() {
const {
isInitialized, // boolean
isInitializing, // boolean
network, // 'ic' | 'local' | 'remote'
} = useAgentState()
if (isInitializing) {
return <span>Connecting to network...</span>
}
return <span>Connected to {network}</span>
}

A convenience hook for getting the current user’s principal. See useUserPrincipal reference:

import { useUserPrincipal } from "../reactor/hooks"
function UserBadge() {
const principal = useUserPrincipal()
if (!principal) return null
return <div className="badge">{principal.toText().slice(0, 8)}...</div>
}
import { useAuth, useUserPrincipal } from "../reactor/hooks"
function AuthSection() {
const { login, logout, isAuthenticated, isAuthenticating } = useAuth()
const principal = useUserPrincipal()
if (isAuthenticating) {
return <div className="auth">Connecting to Internet Identity...</div>
}
return (
<div className="auth">
{isAuthenticated ? (
<>
<span>Welcome, {principal?.toText().slice(0, 12)}...</span>
<button onClick={logout}>Logout</button>
</>
) : (
<button onClick={() => login()}>Login with Internet Identity</button>
)}
</div>
)
}

Customize the login flow:

const { login } = useAuth()
// With options
login({
identityProvider: "https://id.ai/authorize",
maxTimeToLive: BigInt(7 * 24 * 60 * 60 * 1_000_000_000), // 7 days
onSuccess: () => {
console.log("Logged in successfully!")
navigate("/dashboard")
},
onError: (error) => {
console.error("Login failed:", error)
},
})

Create a wrapper for protected routes:

import { useAuth } from "../reactor/hooks"
import { Navigate, useLocation } from "react-router-dom"
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isAuthenticating } = useAuth()
const location = useLocation()
if (isAuthenticating) {
return <LoadingSpinner />
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return children
}
// Usage in router
;<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>

Working with multiple canisters:

src/reactor/index.ts
import { ClientManager, Reactor } from "@ic-reactor/react"
import { QueryClient } from "@tanstack/react-query"
import {
idlFactory as ledgerIdl,
type _SERVICE as Ledger,
} from "../declarations/ledger"
import {
idlFactory as backendIdl,
type _SERVICE as Backend,
} from "../declarations/backend"
export const queryClient = new QueryClient()
export const clientManager = new ClientManager({ queryClient })
// Ledger canister
export const ledger = new Reactor<Ledger>({
clientManager,
name: "ledger",
idlFactory: ledgerIdl,
canisterId: import.meta.env.VITE_LEDGER_CANISTER_ID,
})
// Backend canister
export const backend = new Reactor<Backend>({
clientManager,
name: "backend",
idlFactory: backendIdl,
canisterId: import.meta.env.VITE_BACKEND_CANISTER_ID,
})
src/reactor/hooks.ts
import {
AuthenticationManager,
createActorHooks,
createAuthHooks,
} from "@ic-reactor/react"
import { ledger, backend, clientManager } from "./index"
export const authentication = new AuthenticationManager({ clientManager })
export const ledgerHooks = createActorHooks(ledger)
export const backendHooks = createActorHooks(backend)
export const authHooks = createAuthHooks(authentication)
// Export individual hooks for convenience
export const {
useActorQuery: useLedgerQuery,
useActorMutation: useLedgerMutation,
} = ledgerHooks
export const {
useActorQuery: useBackendQuery,
useActorMutation: useBackendMutation,
} = backendHooks
export const { useAuth, useUserPrincipal } = authHooks

For easier handling of Candid types, use DisplayReactor:

import { DisplayReactor } from "@ic-reactor/react"
import { idlFactory, type _SERVICE } from "../declarations/backend"
const backend = new DisplayReactor<_SERVICE>({
clientManager,
idlFactory,
name: "backend",
canisterId: "...",
})
// Now BigInt values are returned as strings,
// Principals as text, etc.

Sessions are automatically persisted in IndexedDB. Authentication hooks like useAuth automatically initialize the agent and restore the session on first use.

To restore the session yourself on app load (optional), call authentication.authenticate()clientManager.initialize() only initializes the agent:

src/App.tsx
import { useEffect } from "react"
import { authentication } from "./reactor/hooks"
function App() {
useEffect(() => {
authentication.authenticate()
}, [])
return <YourApp />
}

Or check agent state:

import { useAgentState } from "./reactor/hooks"
function App() {
const { isInitialized, isInitializing, error } = useAgentState()
if (isInitializing) {
return <LoadingScreen />
}
if (error) {
return <ErrorScreen error={error} />
}
return <YourApp />
}

Build the reactor inside the request, not at module scope.

Every setup on this page defines the reactor at module scope, which is exactly right for a client-only SPA — but on a server it is a data leak. A reactor owns its QueryClient, and module scope outlives the request: every request served by the same server module graph shares one reactor and one cache. No segment of a reactor query key identifies the caller, so a cached result for a caller-scoped method (get_my_balance, a deposit address, my_profile) is handed to whichever request asks next:

import { defineReactor } from "@ic-reactor/react"
import { QueryClient } from "@tanstack/react-query"
import { canisterId, idlFactory, type _SERVICE } from "../declarations/backend"
// ❌ Shared by every request on the server
export const app = defineReactor<_SERVICE>({
name: "backend",
idlFactory,
canisterId,
})
// ✅ Per request: nothing is shared between users
export default async function Page() {
const app = defineReactor<_SERVICE>({
name: "backend",
idlFactory,
canisterId,
queryClient: new QueryClient(),
})
const data = await app.reactor.fetchQuery({ functionName: "get_my_profile" })
return <Profile data={data} />
}

The fresh reactor’s agent is anonymous. A per-request QueryClient isolates the cache; it does not carry the caller’s identity. For a caller-scoped method like get_my_profile to answer as the request’s user, thread that request’s identity into the reactor — agentOptions: { identity } on the defineReactor call — however your server derives it.

Two further constraints on the Next.js App Router specifically:

  • Hooks are client-only, like every React hook — call them from a "use client" module. A server component may import Reactor / ClientManager and make imperative calls; that path works.
  • Hooks bind to their reactor’s own QueryClient rather than to a QueryClientProvider, so HydrationBoundary prefetch does not feed them unless the provider’s client is that reactor’s client. Next.js also evaluates a shared module twice on the server (the RSC and SSR graphs), so a module-scope reactor is two different instances there.

If none of that applies — a client-only SPA — module-scope reactors are exactly right and none of this is a concern.

  1. Centralize configuration — Keep reactor setup in a dedicated reactor/ folder
  2. Use environment variables — Store canister IDs in .env files
  3. Enable DevTools — Use React Query DevTools in development
  4. Handle auth state — Always check isAuthenticating before showing login UI
  5. Name your actors — Use the name option for better debugging
  6. Use Suspense boundaries — Wrap components using useActorSuspenseQuery in <Suspense>