Experimental React adapter for the @illuma/core dependency injection container.
The adapter's job is to make React's tree and the container's tree the same tree: a component subtree gets its own container, resolution walks upwards through React's context, and a container lives exactly as long as the component that provides it. It also ships a one-hook bridge to @illuma/signals so a service's state can drive a render.
- React bindings – context-based dependency injection for React components
- Scopes – child containers for a component subtree, disposed with it
- Lifecycle –
onMount/onUnmounthooks for services that own a resource - Signals bridge –
useSignalsubscribes a component to a signal - Diagnostics – opt-in reporting of providers nothing ever injected
- Testkit – a container the test owns, with a ready-made wrapper
yarn add @illuma/react-experimental @illuma/core @illuma/signalsAll three peers are required. @illuma/signals is only used by the /signals
entry point, but it is declared as a plain peer dependency, so a package manager
will ask for it either way.
Requires @illuma/core 2.5.0 or newer: scopes are built on the container's
weakParentLink option, so that a container React builds during a render it later
throws away can be collected instead of being retained by its parent forever.
@illuma/react-experimental– dependency injection bindings and React integration@illuma/react-experimental/signals– re-exports@illuma/signalsand adds theuseSignalhook@illuma/react-experimental/testkit– container-backed wrapper for testing components
example/ is a small application that puts every section of this
document into one tree — a root container, a scope per screen, a service that
owns a subscription, signals rendered through useSignal, a subtree that
rebinds one token, and a test that swaps that token for a fake. It builds, runs
and tests:
bun run build # the package
cd example && npx vite # http://localhost:5175
cd example && npx vitest run # 6 testsWrap your application (or a part of it) with IllumaRoot to provide a root dependency injection container.
import type { Provider } from '@illuma/core';
import { IllumaRoot } from '@illuma/react-experimental';
import { Logger, UserService } from './services';
const appProviders: Provider[] = [Logger, UserService];
export const App = () => (
<IllumaRoot providers={appProviders}>
<MyComponent />
</IllumaRoot>
);Keep that array module-level. providers is read once, when the container is
built; a fresh literal on every render would be silently ignored, and the
adapter says so in development.
A detailed guide on how @illuma/core DI system works can be found in the Docs.
Use the useDependency hook to resolve services from the container.
import { useDependency } from '@illuma/react-experimental';
import { UserService } from './services';
export const UserProfile = () => {
const userService = useDependency(UserService);
// Use the service
return <div>User: {userService.getCurrentUser().name}</div>;
};Services and other providers live outside of React's render cycle, so they won't cause unnecessary re-renders when their state changes.
You can manipulate services directly from React (or elsewhere) without worrying about render cycles, but if you want to trigger a re-render based on a service's state, you should consider using signals or another state management solution of your choice (like Tanstack, Zustand, Jotai, etc.) in combination with your services.
You can create a child container for a specific component subtree using ProviderGroup or createComponent.
Services provided here will be visible only to children of this component, similar to Angular's component providers.
Option 1: ProviderGroup
import { ProviderGroup } from '@illuma/react-experimental';
export const FeatureSection = () => (
<ProviderGroup providers={[FeatureService]}>
<FeatureComponent />
</ProviderGroup>
);Option 2: createComponent (HOC)
import { createComponent, useDependency } from '@illuma/react-experimental';
const FeatureComponent = createComponent(() => {
const service = useDependency(FeatureService);
return <div>...</div>;
}, [FeatureService]);Child containers inherit all providers from their parents (respecting React's context hierarchy), but you can also override specific providers for a subtree.
import type { Provider } from '@illuma/core';
import { ProviderGroup } from '@illuma/react-experimental';
const providers: Provider[] = [
{ provide: UserService, useClass: MockUserService },
];
export const FeatureSection = () => (
<ProviderGroup providers={providers}>
<FeatureComponent />
</ProviderGroup>
);
export const Dashboard = () => (
<ProviderGroup providers={[UserService]}>
<FeatureSection />
<DashboardComponent />
</ProviderGroup>
);In this example, FeatureSection and its children will use MockUserService, while Dashboard and its children (DashboardComponent) will use the original UserService.
Overriding means shadowing in a child container. Listing two providers for one token in the same container is an error, not a last-one-wins — which is worth knowing when you lay out a provider array you also intend to reuse in tests.
useDependency forwards the container's modifiers.
const maybe = useDependency(OptionalService, { optional: true }); // null if unprovided
const own = useDependency(FeatureService, { self: true }); // this group only
const outer = useDependency(ThemeService, { skipSelf: true }); // start at the parentoptional only covers a token nobody provides. A service whose constructor throws still
throws — a broken dependency is a bug, not an absent one.
Two rules matter, and both come from the container rather than from React.
A constructor runs more than once. The container executes each factory once
against proxy dependencies to measure the graph, then once for real — and React
is free to build a container it later discards, which buys another pair. Under
StrictMode a provider's constructor is observed to run three times for the one
instance that survives. The number is not a contract; the rule it forces is.
A constructor may build fields and inject dependencies, and must cause nothing
to happen.
Resources belong to the mount, not to the constructor. React may render a component,
build its container, and then discard the whole attempt without ever committing it — and
it does exactly that in StrictMode, under Suspense, and whenever a concurrent render is
interrupted. A container from a discarded render is collected silently, so anything a
constructor had opened would never be closed. Open it on mount instead.
import { makeInjectable } from '@illuma/core';
import { LIFECYCLE_NODE } from '@illuma/react-experimental';
import { signal } from '@illuma/react-experimental/signals';
class _ClockService {
private _timer?: ReturnType<typeof setInterval>;
public readonly seconds = signal(0);
public onMount() {
this._timer = setInterval(() => this.seconds.update((n) => n + 1), 1000);
}
public onUnmount() {
clearInterval(this._timer);
}
}
export const ClockService = makeInjectable(_ClockService);
// Register it as a lifecycle node so the group calls the hooks:
<ProviderGroup providers={[ClockService, { provide: LIFECYCLE_NODE, alias: ClockService }]}>onMount runs when the group commits, onUnmount when it goes away. Hooks fire only for
nodes registered in that same container, so a nested group never re-mounts its ancestors'.
A service with hooks therefore needs two entries for one class. Provider arrays nest, so the pair can travel as a single exported constant:
export const clockProviders: Provider = [
ClockService,
{ provide: LIFECYCLE_NODE, alias: ClockService },
];A container lives exactly as long as the component that provides it. Not as long
as its effects: React tears effects down whenever a subtree stops being active,
and <Activity mode="hidden"> does that while deliberately keeping the subtree's
state. A hidden tab keeps its useState, so it keeps its services too — hide one
with a half-filled form in it, show it again, and the form is still there.
<Activity mode={visible ? 'visible' : 'hidden'}>
<ProviderGroup providers={[DraftService]}>
<Editor />
</ProviderGroup>
</Activity>onMount and onUnmount still bracket activity, so they fire on every hide and
show — which is what a resource wants. LifecycleRef.beforeDestroy fires when the
container is really going away, once the component is gone for good.
Teardown then happens when the collector reaches the discarded scope, so it is
prompt but not synchronous. Where that matters — a server request, a test — build
the container yourself and destroy it explicitly:
<IllumaRoot container={...}> never destroys what it did not create.
One thing to know about a hidden subtree: a signal read through useSignal is
served from the last value it announced, and nothing announces while the
subscription is detached. The first frame after a reveal can therefore show the
value from when the subtree was hidden, corrected on the next commit. Reading the
signal directly instead would break React's requirement that two snapshot reads
agree, and that costs an infinite render loop rather than one extra frame.
The reactivity engine is not part of this package. It lives in
@illuma/signals, knows nothing about
React, and is documented there — signal, computed, linkedSignal,
resource, external, untracked and their options.
What this package adds is the bridge, plus a re-export so you only need one import path in React code:
// both work; the second saves you a second dependency in the import list
import { signal, computed } from '@illuma/signals';
import { signal, computed } from '@illuma/react-experimental/signals';Subscribes a component to a signal and re-renders it when the value changes.
Built on useSyncExternalStore, with the same read used as the server snapshot,
so it is safe under renderToString.
import { useSignal } from '@illuma/react-experimental/signals';
import { useDependency } from '@illuma/react-experimental';
export const Counter = () => {
const service = useDependency(CounterService);
const count = useSignal(service.count);
const double = useSignal(service.double);
return (
<div>
<div>{count} / {double}</div>
<button onClick={() => service.increment()}>+1</button>
</div>
);
};It takes a signal, not a value, and throws if handed anything else.
Put the signals on the service and keep derivations there too. React then subscribes to a finished value instead of recomputing one on every render, and the same state is reachable from code that has no component around it.
import { makeInjectable } from '@illuma/core';
import { computed, signal } from '@illuma/signals';
class _CounterService {
public readonly count = signal(0);
public readonly double = computed(() => this.count() * 2);
public increment() {
this.count.update((c) => c + 1);
}
}
export type CounterService = _CounterService;
export const CounterService = makeInjectable(_CounterService);Creating signals in a constructor or a field initializer is fine: they are values, not effects, and the copy built during the container's measuring pass is simply discarded.
Effects never run on a server, so nothing there can own a container's lifetime. Build one
per request and hand it to IllumaRoot, which then only publishes it — never bootstraps,
rebuilds, or destroys it.
const container = new NodeContainer({ instant: false });
container.provide(requestProviders);
container.bootstrap();
try {
return renderToString(
<IllumaRoot container={container}>
<App />
</IllumaRoot>,
);
} finally {
container.destroy();
}No mount happens, so no onMount does either: a server render resolves services
but takes none of the resources they own.
createTestScope builds a container the test owns, and returns a wrapper for
@testing-library/react. Swapping one token restages the whole graph beneath it.
import { createTestScope } from '@illuma/react-experimental/testkit';
const scope = createTestScope({
providers: [{ provide: ApiService, useClass: FakeApi }],
});
render(<TodoList />, { wrapper: scope.wrapper });
expect(scope.container.get(ApiService)).toBeInstanceOf(FakeApi);
scope.destroy();The container outlives the tree, so it can still be inspected after an unmount.
There is no overrideProvider: the test scope is a root container, and a
container rejects a second provider for a token it already has. Spreading your
application's whole provider list and appending an override will throw. Split
the list instead, so the bindings a test replaces are not in the part it reuses:
export const appProviders: Provider[] = [Logger, UserService];
export const platformProviders: Provider[] = [{ provide: ApiService, useClass: HttpApi }];
export const rootProviders: Provider[] = [appProviders, platformProviders];const scope = createTestScope({
providers: [appProviders, { provide: ApiService, useClass: FakeApi }],
});Opt in during development to be told which providers nothing ever injected.
import { enableReactDiagnostics } from '@illuma/react-experimental';
if (import.meta.env.DEV) enableReactDiagnostics();Output goes through Illuma.setLogger, sharing one control surface with the core's own
diagnostics. It is a no-op in production builds.