@dmytromykhailiuk/preact-injectable
v1.0.0
Published
Dependency injection for Preact — bind @dmytromykhailiuk/injectable to the component tree. Hierarchical container modules via context, plus a fully-typed useInject hook. No decorators.
Downloads
81
Maintainers
Readme
@dmytromykhailiuk/preact-injectable
Full documentation: open Docs
Dependency injection for Preact — the @dmytromykhailiuk/injectable container, wired to the component tree.
If you like Angular/NestJS providers but want them in a Preact app, this is that model driven by your JSX: a <Module> owns a DI container for its subtree, nested modules inherit from the ones above them, and a single useInject hook pulls dependencies out — with the exact call signature of container.get. No decorators, no reflect-metadata, no compiler flags.
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>;
}
// <AppModule> provides the container; <Greeting> resolves from it.
render(
<AppModule>
<Greeting />
</AppModule>,
document.body,
);The whole surface is two things: createDIModule (build a module component) and useInject (resolve inside it). Everything about what you register — class / value / factory / alias providers, multi-providers, injection tokens — comes from @dmytromykhailiuk/injectable and is documented there.
Contents
- Install
- How it works
createDIModuleuseInject- Nested modules
- Injection options
- Constructor-style injection with
inject() - Lifecycle
- Recipes
- API reference
- Limitations
- Development
Install
npm i @dmytromykhailiuk/preact-injectable @dmytromykhailiuk/injectable preactpreact 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.
import {
createDIModule, // makes a <Module> component that owns a container
useInject, // resolves a dependency from the nearest <Module>
} from "@dmytromykhailiuk/preact-injectable";The provider vocabulary you pass to createDIModule — createInjectionToken, useValue / useCreate / useExisting, multi, and the inject() you call inside services — all comes straight from @dmytromykhailiuk/injectable. The common types are re-exported here for convenience (Container, ProviderOption, InjectionToken, InjectOptions, Resolver, …).
How it works
There is one shared Preact context that carries the active container down the tree. Each <Module>:
- reads the container of the nearest ancestor
<Module>from that context; - creates its own container as a child of it (
createContainer(parent)) — so resolution is hierarchical; - registers its providers;
- provides the container to its descendants;
- 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.
createDIModule
createDIModule(providers) takes an array of providers and returns a Module component. Everything rendered inside that component 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 provider is a singleton within that module's container (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 injectable providers guide.
The returned component accepts only children. Define it once at module scope (not inside another component's render), so its identity — and its container — stay stable.
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 -> needs an explicit generic
const maybe = useInject(Analytics, { optional: true }); // -> Analytics | undefinedA token infers its value type with no second generic. A class returns its instance. The [Class] tuple returns an array (multi sugar). { optional: true } widens the return type to include undefined.
Called outside of any <Module>, useInject throws:
useInject must be used within a <Module>. Did you forget to wrap your tree in a component from createDIModule()?
useInjectperforms a static lookup during render — it resolves against the current container and does not subscribe to later registrations. Pass every provider tocreateDIModuleup front (they are all registered at mount), which is the normal case.
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, child 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.
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 resolving to a missing value
}useInject(Logger, { skipSelf: true }); // explicitly the parent module's Logger
useInject(Config, { host: true }); // only this module's ConfigConstructor-style injection with inject()
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. See inject() vs container.get().
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). This makes <Module> a natural fit for per-route or per-feature scopes that should not leak state across navigations.
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 (Storybook / tests)
const StoryModule = createDIModule([{ provide: Mailer, useValue: new FakeMailer() }]);
<AppModule>
<StoryModule>
<OrderForm /> {/* resolves the fake Mailer */}
</StoryModule>
</AppModule>;Provide a per-render value
function RequestScope({ req, children }) {
// Define the module once, outside render, when the providers are static.
// For a per-value provider, pass it through a token registered at the root.
return <>{children}</>;
}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;
}The following @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.
Limitations
- Static resolution.
useInjectresolves during render and does not re-render on later registrations. Register all providers viacreateDIModuleat mount (the normal case). - Inherits
injectable's model. Synchronous only, no decorators, no constructor-type injection, and a genuinely missing dependency resolves toundefinedrather than throwing. See theinjectablelimitations. - Define modules at module scope. Creating a
Moduleinside another component's render gives it a new identity (and a new container) every render — hoistcreateDIModule(...)out.
Development
npm run playground # a runnable Preact demo of nested modules + useInject
npm test # the full test suite (vitest + @testing-library/preact)
npm run test:coverage
npm run typecheck # tsc --noEmit
npm run lint # biome
npm run verify # lint + typecheck + test + buildLicense
MIT © Dmytro Mykhailiuk
