preact-injectable
preact-injectable 1.0.0

Dependency injection for Preact. Hierarchical container modules via context, a typed useInject hook. No decorators.

Contents

Contents

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.

preact context hierarchical modules typed useInject peer dependency two functions no decorators

Getting started

Install#

sh
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

ts
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#

tsx
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>:

reads the parentfrom context

The container of the nearest ancestor <Module>, if any.

creates its owncreateContainer(parent)

A child of that parent, so resolution is hierarchical — this module first, then up the chain.

registers providersonce, at mount

Each provider is a singleton within the module's container.

provides + renderschildren

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.

tsx
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.

Define modules at module scope

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 Resolverbyte-for-byte identical to container.get.

tsxsame overloads as 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.

Outside a module it throws

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.

Static lookup

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.

tsx
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:

ts
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
}
tsx
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.

tsx
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 unmountscontainer.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#

tsx
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#

tsx
const StoryModule = createDIModule([{ provide: Mailer, useValue: new FakeMailer() }]);

<AppModule>
  <StoryModule>
    <OrderForm /> {/* resolves the fake Mailer */}
  </StoryModule>
</AppModule>;

Reference

API reference#

ts
// 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#

Static resolutionno re-render on register

useInject resolves during render and does not re-render on later registrations. Register everything via createDIModule at mount.

Inherits injectable's modelsync, no decorators

Synchronous only, no constructor-type injection, and a genuinely missing dependency resolves to undefined rather than throwing. See injectable's limitations.

Hoist your modulesmodule scope

Creating a Module inside another component's render gives it a new container every render — call createDIModule(...) once, outside.