Skip to content

Providers & Resolution

A provider is a recipe: a token to register under, and a way to produce the value. A module takes a list of them and turns it into live instances; resolution is how you read those instances back.

This page covers every form a provider can take, when each one is constructed, and every way to read one - from a service, from a component, from a module hook.

Provider forms

The providers array accepts the following forms:

FormSpellingProduced
ClassOrdersStoreconstructed at module init
Class with options{ useClass: OrdersStore, ... }constructed at module init
Class, explicit token{ provide, useClass }constructed at module init
Value{ provide, useValue }never - you hand the value over
Factory{ provide, useFactory }the factory runs at module init
Alias{ provide, useExisting }never - points at another token
tsx
const OrdersModule = createModuleComponent({
    providers: [
        // The shorthand: the class is both the token and the implementation
        OrdersStore,

        // The same registration, in the spelling that takes options
        { useClass: OrdersCache, scope: "transient" },

        // Explicit token, separate implementation
        { provide: OrdersApi, useClass: RestOrdersApi },

        // A value you already have: config, a host object
        { provide: API_URL, useValue: "https://acme.example" },

        // Construction that needs logic
        { provide: OrdersSocket, useFactory: () => connectSocket() },

        // A second name for something already registered
        { provide: OrdersReader, useExisting: OrdersStore },
    ],
})

The class shorthand covers most of an app. The object forms exist for the cases it cannot spell: a token that is not a class, a value produced outside, construction logic, an alias.

Tokens

Every provider registers under a token. A class is its own token - that is why the shorthand works. For everything else - a value, a factory, an interface - you mint one with makeTokenizer:

tsx
import { makeTokenizer } from "@remodulo/container"

const token = makeTokenizer("@acme/orders")

export const API_URL = token<string>("api-url")
export const OrdersReader = token<Reader>("orders-reader")

makeTokenizer(namespace) takes a required namespace - use your package name. Every token it mints is interned as Symbol.for("<namespace>:<name>"), so the same name through the same namespace is always the same token: twice in one file, across hot reloads, or in two copies of a package sharing a process.

A plain string or symbol also works as a token. makeTokenizer is the recommended way because it namespaces for you - two packages both minting "api-url" never collide.

Scopes

A scope answers one question: how many instances does one declaration produce.

ScopeInstances
singleton (default)one per module that declares it
transienta fresh one per resolve
requestone per resolution graph
tsx
const OrdersModule = createModuleComponent({
    providers: [
        OrdersStore, // singleton

        // A fresh instance for every resolve
        { useClass: OrdersMapper, scope: "transient" },

        // One instance shared by everything reached from a single resolve, fresh for the next one
        { useClass: RequestTrace, scope: "request" },
    ],
})

scope exists on the class and factory forms. A value is handed over, an alias points elsewhere - there is nothing to scope.

Only singletons carry lifecycle. One instance, one death point. A transient or request instance never sees the module hooks - see Lifecycle.

Lazy

Singletons are constructed at module init, in registration order. lazy: true skips that eager pass - the instance is constructed on first resolve instead:

tsx
{ useClass: ReportExporter, lazy: true }

A lazy instance catches up on construction: onModuleInit fires immediately, and onModuleMount follows if the module is already mounted. From there it is an ordinary participant - unmounted and destroyed with the module.

Never resolved means never constructed: no instance, no hooks, nothing to bury.

Good for something heavy that most sessions never touch.

Collections

multi: true contributes to a collection under the token instead of claiming it. Several providers, one token, and the reader gets them all:

tsx
const token = makeTokenizer("@acme/orders")

export const OrderCheck = token<Check>("order-check")

const CheckoutModule = createModuleComponent({
    providers: [
        { provide: OrderCheck, useClass: StockCheck, multi: true },
        { provide: OrderCheck, useClass: FraudCheck, multi: true },
    ],
})

class Checkout {
    // [StockCheck, FraudCheck] - in registration order
    private readonly checks = injectAll(OrderCheck)
}

injectAll reads the whole chain by default: contributions from the module and every module above it, nearest first. An unregistered token is an empty collection - [], not an error.

A token is either single or multi, across the whole chain. A single registration claims the token - a second claim throws. multi contributes - mixing the two throws at registration, even when the other side lives in an ancestor module. The reads enforce the same line: inject on a multi token throws, injectAll on a single one throws.

A multi class provider needs an explicit provide. The shorthand registers under the class itself, and a class cannot be a collection of itself.

A collection agrees on lazy. All contributions eager, or all lazy - a mix throws at registration.

Injection & Resolution

There are two ways to read a provider, split by time: injection happens while an instance is being constructed, resolution works any time after.

Injection

Inside a provider, dependencies are read with the injectors:

tsx
class Checkout {
    private readonly api = inject(OrdersApi)
    private readonly flags = injectOptional(FEATURE_FLAGS)
    private readonly checks = injectAll(OrderCheck)
}
  • inject(token) - required: throws when nothing is registered
  • injectOptional(token) - undefined when nothing is registered
  • injectAll(token) - the collection; [] when nothing contributes

Injection works only during construction. The injectors read the container that is building right now, so they work synchronously inside a constructor body, a field initializer, or a useFactory body - and nowhere else:

tsx
class Checkout {
    // ✅ Field initializer
    private readonly api = inject(OrdersApi)

    constructor() {
        // ✅ Constructor body
        this.currency = inject(CURRENCY)
    }

    submit(): void {
        // ❌ Throws: "inject(OrdersApi) was called outside a construction frame."
        const api = inject(OrdersApi)
    }
}

const CheckoutModule = createModuleComponent({
    providers: [
        Checkout,
        {
            provide: OrdersReport,
            // ✅ A factory body is a construction frame too
            useFactory: () => {
                const api = inject(OrdersApi)
                return buildReport(api)
            }
        },
    ],
})

Circular Dependencies

Two providers injecting each other can never finish constructing. The read throws, with the full chain in the error:

tsx
class A {
    private readonly b = inject(B)
}

class B {
    // ❌ Throws: "Circular dependency found: A -> B -> A"
    private readonly a = inject(A)
}

The fix is delayed - one side moves its read out of construction time, and the cycle is gone:

tsx
class B {
    // ✅ Resolved on call, when A already exists
    private readonly getA = inject(A, { delayed: true })
}

Delayed

{ delayed: true } takes the read out of construction time. The injector returns a function instead of a value, and the resolve happens on call:

tsx
class Checkout {
    private readonly exporter = inject(ReportExporter, { delayed: true })

    export(): void {
        this.exporter().open()
    }
}

Every call is a live resolve: a singleton comes back as the same instance, a transient is fresh per call. Nothing is cached by the function itself.

Mode

{ mode } controls how far the read looks. The default is "nearest" - the first declaration at or above the declaring module. "self" reads the declaring module's own providers only:

tsx
private readonly local = injectOptional(OrdersApi, { mode: "self" })

Resolution

Resolution reads a module that is already built. Every door hands you the same object - the module's resolver.

In a component:

tsx
function OrdersView() {
    const store = useResolve(OrdersStore)
    const flags = useResolveOptional(FEATURE_FLAGS)
    const checks = useResolveAll(OrderCheck)

    return <ul>{/* ... */}</ul>
}

The hooks mirror the injectors one for one. And useResolve is a read, not a subscription - it never re-renders anything. Updates are the job of your reactivity layer: Connecting Reactivity.

Resolver

For several reads, or a read inside a handler, take the resolver itself. A component gets it from useResolver, a module hook is called with it, a service grabs it at construction with injectResolver:

tsx
const OrdersModule = createModuleComponent({
    providers: [OrdersStore],
    onModuleMount: (resolver) => {
        resolver.resolve(OrdersStore).refresh()
    },
})

function RefreshButton() {
    const resolver = useResolver()

    return <button onClick={() => resolver.resolve(OrdersStore).refresh()}>Refresh</button>
}
tsx
class Devtools {
    // Taken at construction, used any time after
    private readonly resolver = injectResolver()
}

The resolver's surface:

  • resolve(token) - required: throws when nothing is registered
  • resolveOptional(token) - undefined when nothing is registered
  • resolveAll(token) - the collection; [] when nothing contributes
  • resolveOr(token, fallback) - the fallback when nothing is registered
  • isRegistered(token) - true when a read would land
  • registrations() - every registration the module itself declares, in order
  • entry(token) - the snapshot of a single token's registration; undefined when the module declares none
  • entries(token) - the snapshots of a multi token's contributions
  • on(event, listener) - observation; returns the unsubscribe callback

Every read takes the same modes as the injectors, spelled as a plain second argument: resolve(OrdersApi, "self"). The hooks follow the resolver: useResolve(OrdersApi, "self").

There is no register here. Providers are declared when the module is constructed and never after - if a different situation needs different bindings, that is a different module.

Prefer injection

A field declared with inject is a visible dependency; a resolver read inside a method is not. Reach for the resolver when the read genuinely happens later - a handler, a module hook, a decision made at runtime.

Features

A feature is a named bag of providers you can drop into any module's providers array:

tsx
import { createFeature } from "@remodulo/react"

export const presenceFeature = createFeature({
    name: "presence",
    providers: [PresenceSocket, PresenceStore],
})

const WorkspaceModule = createModuleComponent({
    providers: [presenceFeature, OpenAreas],
})

Features nest and are flattened where the module is constructed. The container is handed the contents, never the bag - a feature costs nothing at resolution and never becomes a scope of its own.

The same feature twice flattens to one copy. Include it directly and through another feature - still one set of providers, no duplicate-registration error.

A feature owns nothing. The module its providers land in is the owner: the same feature dropped into two modules gives two independent sets of instances, each disposed by its own module.

Metadata

Every object-form provider can carry a metadata bag - arbitrary readonly data attached to the registration:

tsx
{ useClass: OrdersStore, metadata: { owner: "team-orders" } }

The container never reads it. It is frozen at registration and carried to the entry's snapshot, where diagnostics can:

tsx
resolver.entry(OrdersStore)?.metadata // { owner: "team-orders" }

Good for devtools, tracing, and conventions of your own.

"lazy" is reserved

lazy: true on a provider is stored through this same bag under the key "lazy". Don't claim that key for yourself.

Where to go next

Guides

Reference

MIT licensed.