Skip to main content
Version: 0.1.2

Dependency injection in React

React doesn't include a real dependency injection system. Context comes close, but provider trees get unwieldy fast. Every shared service becomes its own context, every consumer needs a useContext call, and shared lifecycle (start, stop, cleanup) has nowhere natural to live.

react-logic adds a small DI layer on top of React's tree:

  • Injectables — classes (or values, or factories) that the container can produce when asked. See Injectables.
  • Injectors — React components that scope which injectables are available to a subtree. See Injectors.
  • Providers — declarations that bind a token to an implementation. See Providers.

The mental model

Think of it as a tree of scopes. There's always a root scope (provided by the framework, no setup needed). Every <Injector> you add is a child scope wrapping its children.

When something calls inject(X), the framework looks for X in the current scope first, then the parent, then the parent's parent, all the way up to the root. The first scope that knows how to make X wins, and that's where the instance lives.

<Injector provide={[Theme, Logger]}>          ← scope A: knows Theme, Logger
<Injector provide={[ApiClient]}> ← scope B: knows ApiClient
<Component /> ← lives in scope B
</Injector>
</Injector>

A logic class inside <Component /> calls inject(Logger):

  1. Look in scope B → not registered there.
  2. Look in scope A → registered. Create the Logger instance.
  3. Cache that instance on scope A.

From now on, anyone in scope A or anywhere below it that asks for Logger gets the same instance. When scope A's <Injector> unmounts, the instance is disposed.

The same rule applies to the root scope: anything not registered in any <Injector> is created and cached there, so it's shared across the whole app and only goes away when the app does.

Why it scopes correctly

Components mount and unmount on a different schedule than their data. Hide a panel and the panel's logic class is destroyed, but the data behind it (the service it injected) lives on the surrounding <Injector>'s scope and survives.

You can do this with Context too — a Provider holds its value until it unmounts, no matter how many consumers come and go. The differences are mechanical:

  • One Provider per service. Context gives you one slot per createContext call. Sharing N services means N nested Providers (or one mega-context that re-renders everything when anything inside it changes).
  • Lifecycle is manual. With Context you usually useState/useMemo to construct and useEffect to clean up. With <Injector>, construction and disposal are tied to its mount/unmount automatically, and a service can register its own teardown via onDestroy().
  • Lazy by default. A service isn't created until something injects it. A Context Provider eagerly holds whatever you pass to value.
  • Constructor-time access. inject() runs as a field initializer on the logic class — before any method runs, and without a hook. Services can inject other services the same way, so you don't need a hook chain in a component to wire dependencies together.
Under the hood

<Injector> is a React Context Provider. The DI layer uses one Context to thread the current scope down the tree, and inject() reads it during construction. So this isn't replacing Context — it's wrapping it with a single, ergonomic API instead of one Context per service plus manual lifecycle glue.

How it integrates with logic classes

Call inject() inside the constructor or as a field initializer of a logic class or service:

class CartLogic {
store = inject(CartStore); // resolves through the active scope
api = inject(ApiClient);
}

The framework sets the active scope before useLogic constructs the logic instance, so plain inject() works without passing injectors through props.

Calling inject() outside a constructor (for example, in a method body) throws. The active scope is only set during construction.

What about React components themselves?

Components usually don't call inject() directly. They call useLogic(MyLogic) and let the logic class do the injecting. If a component really needs a service without a logic class around it, use the useInjector hook.