Skip to content
IC Reactor

createReactorProvider

createReactorProvider builds your reactors inside the React tree instead of at module scope, once per mounted provider, and returns a useReactor() hook that hands them to components with their full types. It is the setup a server-rendered app (Next.js, or any React SSR) needs, and it replaces the provider such apps used to write by hand.

import { createReactorProvider } from "@ic-reactor/react"
src/reactor.tsx
"use client"
import { createReactorProvider, defineReactor } from "@ic-reactor/react"
import { canisterId, idlFactory, type _SERVICE } from "./declarations/todo"
export const { ReactorProvider, useReactor } = createReactorProvider(() =>
defineReactor<_SERVICE>({ name: "todo", idlFactory, canisterId })
)
// src/app/layout.tsx (a server component)
import { ReactorProvider } from "../reactor"
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<ReactorProvider>{children}</ReactorProvider>
</body>
</html>
)
}
// Any client component below the provider
"use client"
import { useReactor } from "../reactor"
export function Todos() {
const { useActorQuery, useAuth } = useReactor()
const { isAuthenticated } = useAuth()
const { data } = useActorQuery({ functionName: "getAllTodos" })
if (!isAuthenticated) return <p>Sign in to see your todos</p>
return (
<ul>
{data?.map((todo) => (
<li key={String(todo.id)}>{todo.description}</li>
))}
</ul>
)
}

useReactor() returns exactly what the factory built, so useActorQuery keeps its own signature: functionName is checked against the service, args are typed for that method and data is the method’s result. No wrapper and no cast is involved.

  • Server-rendered apps. A module-scope reactor is created once per server process and shared by every request. It owns its QueryClient, and query keys carry no caller principal, so one visitor’s caller-scoped result (get_my_balance, my_profile) can be served to the next. The provider’s factory runs in a useState initializer, and a server render is a tree of its own, so each request builds its own reactors, cache and AuthenticationManager.
  • Reactors that belong to part of the UI, such as a widget that builds its own stack each time it opens, or a canister chosen by the user (see Props and rebuilding).

A client-only SPA can keep its reactors at module scope; see React Setup.

Parameter Type Description
factory (props: TProps) => TValue Builds the value. Runs once per mounted provider, with the provider’s props other than children.
options CreateReactorProviderOptions Optional. See Options.

The factory can return anything the app sets up: a defineReactor or defineDisplayReactor result, a record of them, reactors and managers built by hand, or query and mutation objects from createQuery and createMutation.

Property Type Description
ReactorProvider (props: TProps & { children?: ReactNode }) => ReactElement Builds the value when it mounts and provides it to the tree below.
useReactor () => TValue and (key) => TValue[key] Hook returning the value, or one of its properties by name. Throws outside the provider.

Rename them when you destructure, to read well at the call site:

export const { ReactorProvider: TodoProvider, useReactor: useTodo } =
createReactorProvider(() => defineReactor<_SERVICE>({ ... }))
Option Type Default Description
queryClientProvider boolean true Render a QueryClientProvider for the value’s QueryClient, when the value holds exactly one. Pass false to keep your own.

The reactor hooks do not need a QueryClientProvider: each binds to its reactor’s own QueryClient. The provider renders one so that code below it that reads the context, such as useQueryClient(), React Query Devtools and HydrationBoundary, works with the same cache the reactor hooks fill. It looks for the QueryClient on the value and its own properties: a defineReactor result, a reactor and a ClientManager each bring their manager’s, and a QueryClient counts as itself. With several distinct clients it renders none. Below the provider it takes the place of an outer QueryClientProvider, so a plain useQuery there caches in the reactors’ QueryClient too; pass queryClientProvider: false if other queries must stay in a client of their own.

Return a record. Reactors that share an AuthenticationManager share its ClientManager, so one sign-in covers all of them:

export const { ReactorProvider, useReactor } = createReactorProvider(() => {
const backend = defineReactor<Backend>({
name: "backend",
idlFactory: backendIdl,
canisterId: backendId,
})
const ledger = defineDisplayReactor<Ledger>({
name: "ledger",
idlFactory: ledgerIdl,
canisterId: ledgerId,
authentication: backend.authentication,
})
const profile = createQuery(backend.reactor, { functionName: "get_profile" })
return { backend, ledger, profile }
})
function Supply() {
// A DisplayReactor's nat arrives as text.
const { data } = useReactor("ledger").useActorQuery({
functionName: "icrc1_total_supply",
})
return <span>{data}</span>
}
function SignIn() {
const { login, isAuthenticated } = useReactor("backend").useAuth()
return isAuthenticated ? null : (
<button onClick={() => login()}>Sign in</button>
)
}
function Profile() {
const { data } = useReactor("profile").useQuery()
return <h1>{data?.name}</h1>
}

Query and mutation objects built in the factory belong to that tree too, which is what a server-rendered app needs: a module-scope createQuery is bound to a module-scope reactor.

The factory receives the provider’s props, other than children. They are read once, when the provider mounts, like the initial value of useState. To build a new value, give the provider a new key: React unmounts the old tree, which releases the old value, and mounts a new one.

export const { ReactorProvider: LedgerProvider, useReactor: useLedger } =
createReactorProvider(({ canisterId }: { canisterId: string }) =>
defineDisplayReactor<Ledger>({ name: "ledger", idlFactory, canisterId })
)
function TokenPage({ canisterId }: { canisterId: string }) {
return (
<LedgerProvider key={canisterId} canisterId={canisterId}>
<TokenDetails />
</LedgerProvider>
)
}

The provider’s props are typed from the factory’s parameter, so a required prop is required on the provider. Optional props work too, with defaults in the parameter: ({ host = "https://icp-api.io" }: { host?: string } = {}) => ... lets <ReactorProvider> be rendered with or without host.

The whole tree below remounts, not only the value, because a TanStack query observer keeps the QueryClient it started with. Props are also how a server component hands runtime configuration, such as a host read from a server-only environment variable, to the reactors a client component builds. To switch between canisters of one interface without rebuilding, pass callConfig: { canisterId } to the query hooks instead; the query key follows that canister.

  • Server render. The factory runs for each request’s render, and nothing runs in an effect, so the first render is complete HTML. The auth hooks show their fixed server state (signed out, isAuthenticating: true) and queries without data render their pending state.
  • Hydration. The browser builds its own value from the same props and renders the same first pass, so it hydrates without a mismatch.
  • Unmount. The provider calls dispose() on every AuthenticationManager built for the value, which releases the Internet Identity client it built: those constructed while the factory ran, and the one each defineReactor result in the value builds on first use. A defineReactor result whose tree never used authentication has built no manager, and none is built to be disposed.
  • StrictMode. dispose() only forgets the client, and the next sign-in builds a new one, so StrictMode’s extra cleanup and effect on the same value leave the session and its restore intact. StrictMode also calls a useState initializer twice; the second call reuses the value the first built, so the factory runs once.
  • Suspense. A suspense hook below the provider may suspend its first render. React renders the same element again once the data arrives, and the provider reuses the value that render built, so the query is not sent again from an empty cache. That holds with the Suspense boundary above the provider, and while hydrating with none at all.

Before createReactorProvider, an SSR app built its context by hand and then forwarded each hook out of it. A hook that is generic over the method name has no parameter tuple a typed wrapper can name, so those forwarders needed casts:

// ❌ No longer needed
const [value] = useState(createReactorContext)
useEffect(() => () => value.authentication.dispose(), [value])
export const useQueryTodo: TodoHooks["useActorQuery"] = (...args: any[]) =>
(useICReactor().todo.useActorQuery as any)(...args)
// ✅
export const { ReactorProvider, useReactor } = createReactorProvider(() =>
defineReactor<_SERVICE>({ name: "todo", idlFactory, canisterId })
)
const { useActorQuery } = useReactor()