preact-injectable
Dependency injection for Preact, driven by your JSX. A
<Module> owns a DI container for its subtree,
nested modules inherit from the ones above them, and one
useInject hook pulls dependencies out — with the
exact call signature of container.get.
It is the
@dmytromykhailiuk/injectable
container — hierarchical containers, class / value / factory / alias
providers, multi-providers, typed tokens — bound to the component
tree. No decorators, no reflect-metadata, no compiler
flags.
Getting started
Install#
npm i @dmytromykhailiuk/preact-injectable @dmytromykhailiuk/injectable preact
preact and
@dmytromykhailiuk/injectable are
peer dependencies — you bring them, this package
binds them together. Requires Node 18+ and
TypeScript 5.0+. Nothing to enable in
tsconfig: no decorators, no
reflect-metadata. ESM and CJS builds ship side by side
with separate .d.ts / .d.cts declarations,
and the package is side-effect-free and tree-shakeable.
Two things make up the surface
import {
createDIModule, // makes a <Module> component that owns a container
useInject, // resolves a dependency from the nearest <Module>
} from "@dmytromykhailiuk/preact-injectable";
Everything about what you register — class / value /
factory / alias providers, multi-providers, injection tokens, and
the inject()
you call inside services — comes from
@dmytromykhailiuk/injectable
and is documented there. The common types are re-exported here for
convenience.
At a glance
Quick start#
import { createDIModule, useInject } from "@dmytromykhailiuk/preact-injectable";
import { createInjectionToken } from "@dmytromykhailiuk/injectable";
class Logger {
log(message: string) {
console.log(`[log] ${message}`);
}
}
const API_URL = createInjectionToken<string>("API_URL");
// A module is a component that owns a container for everything inside it.
const AppModule = createDIModule([
Logger,
{ provide: API_URL, useValue: "https://api.example.com" },
]);
function Greeting() {
const logger = useInject(Logger); // -> Logger
const apiUrl = useInject(API_URL); // -> string (inferred from the token)
logger.log(`hello from ${apiUrl}`);
return <p>hello</p>;
}
render(
<AppModule>
<Greeting />
</AppModule>,
document.body,
);
<AppModule> provides the container;
<Greeting> resolves from it. Two names to learn —
createDIModule and useInject — and the
rest is the injectable provider vocabulary you already
know.
Model
How it works#
There is one shared Preact context that carries the
active container down the tree. Each <Module>:
The container of the nearest ancestor
<Module>, if any.
A child of that parent, so resolution is hierarchical — this module first, then up the chain.
Each provider is a singleton within the module's container.
Puts the container on the context for its descendants and
renders children.
useInject reads the nearest container from the same
context and calls container.get(...). Because hierarchy
is expressed through
nested injectable containers — not
nested context objects — a single global
useInject works everywhere, and a child module
transparently overrides or extends what its parents provide.
API
createDIModule#
createDIModule(providers) takes an array of providers
and returns a Module component.
Everything rendered inside it can resolve those providers.
const AuthModule = createDIModule([
AuthService,
TokenStore,
{ provide: SESSION, useValue: loadSession() },
]);
<AuthModule>
<Dashboard />
</AuthModule>;
Providers are registered once, when the module mounts, and each is a
singleton within that module's container — the same
instance every time you resolve it. The providers array
is anything injectable's
register() accepts: a bare class, or a
{ provide, useValue | useCreate | useExisting, multi? }
object. See the
providers guide.
Call createDIModule(...) once, at the top level — not
inside another component's render. A module created during render
gets a new identity, and a new container, on every render.
API
useInject#
useInject resolves a provider from the nearest
<Module>. Its type is Resolver —
byte-for-byte identical to container.get.
const logger = useInject(Logger); // class -> instance
const apiUrl = useInject(API_URL); // token -> T inferred from the token
const plugins = useInject([Plugin]); // [Class] -> Plugin[]
const url = useInject<string>("API_URL"); // string -> explicit generic
const maybe = useInject(Analytics, { optional: true }); // -> Analytics | undefined
A token infers its value type with no second
generic. A class returns its instance. The
[Class] tuple returns an array.
{ optional: true } widens the return type to include
undefined.
Called with no ancestor <Module>,
useInject throws
"useInject must be used within a <Module>". There
is always a container, or an explicit error — never a silent
null.
useInject resolves during render and does not
subscribe to later registrations. Pass every provider to
createDIModule up front — they are all registered at
mount, which is the normal case.
Composition
Nested modules#
Nest <Module> components and resolution becomes
hierarchical: a child checks itself first, then walks up to its
parents.
const RootModule = createDIModule([
Logger,
{ provide: API_URL, useValue: "https://prod.example.com" },
]);
const FeatureModule = createDIModule([
{ provide: API_URL, useValue: "http://localhost:3000" }, // override for this subtree
]);
<RootModule>
<Header /> {/* useInject(API_URL) -> "https://prod.example.com" */}
<FeatureModule>
<Panel /> {/* useInject(API_URL) -> "http://localhost:3000" (child wins) */}
{/* useInject(Logger) still resolves — inherited from RootModule */}
</FeatureModule>
</RootModule>;
For multi providers the arrays merge — the child's
values first, then the parents' — the same behaviour
injectable gives nested containers. This is how you
build per-feature or per-route scopes that inherit the app-wide
services above them.
API
Injection options#
useInject forwards injectable's options
unchanged:
interface InjectOptions {
host?: boolean; // resolve only from this module's container, ignore parents
skipSelf?: boolean; // skip this module, resolve from the parent chain
multi?: boolean; // treat the result as a multi-provider array
optional?: boolean; // return undefined instead of a missing value
}
useInject(Logger, { skipSelf: true }); // explicitly the parent module's Logger
useInject(Config, { host: true }); // only this module's Config
Wiring services
Constructor-style injection#
Inside a service, declare dependencies with
injectable's inject() — it resolves
against whichever module's container is building the service. No
constructor plumbing reaches the component.
import { inject } from "@dmytromykhailiuk/injectable";
class GreetingService {
private logger = inject(Logger);
private apiUrl = inject<string>(API_URL);
greet(name: string) {
this.logger.log(`hello ${name} via ${this.apiUrl}`);
}
}
const AppModule = createDIModule([
Logger,
GreetingService,
{ provide: API_URL, useValue: "https://api.example.com" },
]);
function Greeter() {
const greeting = useInject(GreetingService); // its logger + apiUrl already wired
greeting.greet("world");
return null;
}
inject() is only valid while a provider is being built
— a constructor, a field initializer, or a
useCreate factory. From a component, use
useInject.
Runtime
Lifecycle#
A module's container is created when the module mounts and
destroyed when it unmounts —
container.destroy()
clears every instance, drops subscribers, and detaches from the
parent. Remounting a module builds a fresh container, and fresh
singletons. That makes
<Module> a natural fit for per-route or
per-feature scopes that should not leak state across navigations.
Patterns
Recipes#
App root + feature scope#
const AppModule = createDIModule([ApiClient, Logger, AuthService]);
const CheckoutModule = createDIModule([CartService, { provide: FLOW, useValue: "checkout" }]);
<AppModule>
<Shell>
<CheckoutModule>
<Checkout />
</CheckoutModule>
</Shell>
</AppModule>;
Swap a real service for a fake#
const StoryModule = createDIModule([{ provide: Mailer, useValue: new FakeMailer() }]);
<AppModule>
<StoryModule>
<OrderForm /> {/* resolves the fake Mailer */}
</StoryModule>
</AppModule>;
Reference
API reference#
// Build a module component from a provider list.
createDIModule(
providers: ProviderOption[],
): (props: { children?: ComponentChildren }) => VNode;
// Resolve from the nearest <Module>. Same overloads as injectable's container.get.
const useInject: Resolver;
// useInject<T>(token: InjectionToken<T>, options?: InjectOptions): T
// useInject<T>(cls: new () => T, options?: InjectOptions): T
// useInject<T>(cls: [new () => T], options?: InjectOptions): T[]
// useInject<T = unknown>(key: string, options?: InjectOptions): T
// ...with { optional: true } widening the result to T | undefined
// The shared context (advanced interop — read the raw container).
const DIContext: Context<Container | null>;
interface ModuleProps {
children?: ComponentChildren;
}
These @dmytromykhailiuk/injectable types are
re-exported so you can type providers and tokens without a second
import: Container, Resolver,
ProviderOption, InjectOptions,
OptionalInjectOptions, InjectionToken,
Provider, ProviderClass.
Caveats
Limitations#
useInject resolves during render and does not
re-render on later registrations. Register everything via
createDIModule at mount.
Synchronous only, no constructor-type injection, and a
genuinely missing dependency resolves to
undefined rather than throwing. See
injectable's limitations.
Creating a Module inside another component's
render gives it a new container every render — call
createDIModule(...) once, outside.