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:

import { useAuth } from "../reactor/hooks"
function LoginButton() {
const { login } = useAuth()
return (
<button
onClick={() =>
// 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)
},
})
}
>
Login with Internet Identity
</button>
)
}

Create a wrapper for protected routes:

import { useAuth } from "../reactor/hooks"
import { Navigate, Route, 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. defineDisplayReactor builds one together with its QueryClient, ClientManager and hooks, and takes the same options as defineReactor:

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

To keep the explicit setup of step 1, construct a DisplayReactor with the same options in place of the Reactor.

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 { 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.
// In a server component this resolves to the `react-server` entry: the core
// classes are there, the hooks are not.
import { ClientManager, Reactor } from "@ic-reactor/react"
import { QueryClient } from "@tanstack/react-query"
import { canisterId, idlFactory, type _SERVICE } from "../declarations/backend"
export default async function Page() {
const clientManager = new ClientManager({ queryClient: new QueryClient() })
const reactor = new Reactor<_SERVICE>({
name: "backend",
clientManager,
idlFactory,
canisterId,
})
const data = await 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 ClientManager — however your server derives it.

Components that use hooks need their reactors built inside the React tree too. createReactorProvider builds them once per mounted provider, in a useState initializer, and a server render is a tree of its own, so each request gets its own reactors, cache and AuthenticationManager. useReactor() returns what the factory built with its full type:

src/reactor.tsx
"use client"
import { createReactorProvider, defineReactor } from "@ic-reactor/react"
import { canisterId, idlFactory, type _SERVICE } from "../declarations/backend"
export const { ReactorProvider, useReactor } = createReactorProvider(() =>
defineReactor<_SERVICE>({ name: "backend", idlFactory, canisterId })
)
// app/layout.tsx (a server component)
import { ReactorProvider } from "../src/reactor"
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<ReactorProvider>{children}</ReactorProvider>
</body>
</html>
)
}
// A client component below the provider
"use client"
import { useReactor } from "../src/reactor"
export function Profile() {
const { useActorQuery } = useReactor()
const { data } = useActorQuery({ functionName: "get_my_profile" })
return <h1>{data?.name}</h1>
}

The first render needs no effect, so the server’s HTML is complete, and the browser builds its own value from the same props to hydrate it. The provider also renders a QueryClientProvider for the reactors’ QueryClient, so useQueryClient(), React Query Devtools and a HydrationBoundary below it use the cache the hooks fill.

When the tree unmounts, the provider releases the Internet Identity client its AuthenticationManager built: a v10 client keeps listening to the page until it is disposed, so each remount would otherwise leave one behind. A provider you write yourself has to do the same, from its cleanup:

import { useEffect, useState } from "react"
function useReactorContext() {
const [value] = useState(createReactorContext)
useEffect(() => () => value.authentication.dispose(), [value])
return value
}

dispose() only forgets the client, and the next sign-in builds a new one, so this is safe under StrictMode, which runs the cleanup and the effect again on the same managers. A client passed in as authClient is left alone. Both Next.js examples, Pages Router and App Router, use createReactorProvider.

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, server action or route handler resolves @ic-reactor/react to its react-server entry, which Next.js (Turbopack and webpack) selects through the export condition of that name. That entry exports everything @ic-reactor/core does — Reactor, DisplayReactor, ClientManager, the error classes and utilities — plus the validation helpers (mapValidationErrors, getFieldError, …), and nothing that imports React. Importing a hook, defineReactor, createActorHooks, a query or mutation factory, or the auth classes there fails next build with “Export defineReactor doesn’t exist in target module”; hooks belong in a "use client" module, and server code calls the reactor itself (reactor.fetchQuery(), reactor.callMethod()) where client code would use a factory’s .fetch() or .execute(). TypeScript does not read the condition, so the editor does not flag it first. A server-component bundler that ignores react-server loads the full entry and rejects its hooks: import from @ic-reactor/core there, and list it in your own package.json, since a transitive dependency does not resolve under pnpm.
  • 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, as it is below createReactorProvider’s provider. 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>