A lightweight, type-safe dependency injection container for React, built on top of Context. No decorators, no reflection, no runtime magic - just a factory function, a Context, and a couple of hooks/HOCs for wiring dependencies into components.
React Context is great for passing values down a tree, but using it directly
means every component that needs a dependency has to
know about the whole context shape, and there's no easy way to swap
implementations in tests. react-context-di wraps Context in a small, typed API
so you can:
- Declare a single container type describing everything your app can inject (services, repositories, use-cases, whatever you have).
- Pull out only the dependencies a given hook or component actually needs, fully typed, with no casting.
- Swap the container wholesale in tests - no per-dependency mocking.
npm install react-context-direact (>=18) is a peer dependency - install it if you haven't already.
// di/di.types.ts
import { UserService } from "../services/user.service";
export interface DiContainer {
userService: UserService;
}// di/di.ts
import { createDependencyInjectionContext } from "react-context-di";
import type { DiContainer } from "./di.types";
const { DependenciesContext, inject, withInjections } =
createDependencyInjectionContext<DiContainer>();
export const DI = {
Provider: DependenciesContext.Provider,
withInjections,
inject,
};// di/di.registry.ts
import { UserService } from "../services/user.service";
import { type DiContainer } from "./di.types";
export function registerDiContainer(): DiContainer {
return {
userService: new UserService(),
};
}import { DI } from "./di/di";
import { registerDiContainer } from "./di/di.registry";
import { ViewHome, ViewSimpleHome } from "./views/home.view";
const diContainer = registerDiContainer();
export function App() {
return (
<DI.Provider value={diContainer}>
<ViewHome name="Tony" />
<ViewSimpleHome />
</DI.Provider>
);
}inject lets you build a hook that receives dependencies from the container,
plus any of its own arguments, fully typed.
import type { UserService } from "../services/user.service";
import { DI } from "../di/di";
interface UseGreetInjections {
userService: UserService;
}
interface UseGreetProps {
name: string;
}
// In generic type we specify what we want to inject + UseGreetProps
// in order to inherit them for the return function.
// Inside inject arguments we specify what exactly we want to inject.
export const useGreet = DI.inject<"userService", UseGreetProps>("userService")(
({ userService, name }: UseGreetInjections & UseGreetProps) => {
return {
sayHello: () => userService.greet(name),
};
},
);
// You can skip generic type for inject function if your hook
// doesn't have any arguments.
export const useSimpleGreet = DI.inject("userService")(({ userService }) => {
return {
sayHello: () => userService.greet("John Doe"),
};
});import { DI } from "../di/di";
import { useSimpleGreet, useGreet } from "./use-greet";
type ViewHomeProps = {
name: string;
};
// In generic type we specify what we want to inject + ViewHomeProps
// in order to inherit them for the return component.
// Inside withInjections arguments we specify what exactly we want to inject.
export const ViewHome = DI.withInjections<"userService", ViewHomeProps>(
"userService",
)(({ userService, name }) => {
const greet = userService.greet(name);
const { sayHello } = useGreet({ name });
return (
<div>
<p>Greet: {greet}</p>
<button onClick={sayHello}>say hello</button>
</div>
);
});
// You can skip generic type for withInjections function if your
// component doesn't have any arguments.
export const ViewSimpleHome = DI.withInjections("userService")(({
userService,
}) => {
const greet = userService.greet("John Doe");
const { sayHello } = useSimpleGreet();
return (
<div>
<p>Greet: {greet}</p>
<button onClick={sayHello}>say hello</button>
</div>
);
});Note that userService is not passed by the caller - it's injected
automatically from the container, so the public prop type only exposes
name.
Since the container is provided via a single Provider, tests can swap in
fakes without mocking individual modules:
test("renders greet", async () => {
const fakeContainer = {
userService: {
greet: vi.fn(),
},
};
render(
<DependenciesContext.Provider value={fakeContainer}>
<ViewHome name="Tony" />
</DependenciesContext.Provider>,
);
expect(await screen.findByText("Hello, Tony")).toBeInTheDocument();
});Creates a new, isolated DI context typed to your container shape D.
Returns:
| Export | Description |
|---|---|
DependenciesContext |
A React Context. Use DependenciesContext.Provider to supply the container at your app's root. |
useDeps(keys) |
Hook. Returns an object containing only the requested keys from the container. |
inject(...keys) |
Higher-order function for hooks: injects dependencies plus any extra arguments into a hook. |
withInjections(...keys) |
HOC: injects dependencies into a component as props, alongside its own props. |
useDeps throws if called outside a DependenciesContext.Provider - always
wrap your app (or your tests) in the provider before rendering anything that
injects dependencies.
- No runtime overhead beyond Context. There's no service registry
lookup, no reflection, no metadata -
useDepsis auseContextcall plus areduceover the keys you asked for. - One container, one source of truth. All your app's injectable dependencies live in a single typed object. Swapping implementations (e.g. for tests, or for a mock backend) means swapping one object, not hunting down individual imports.
- Stable container values. Since the context value is stable and nothing mutates it, application doesn't have unnecessary re-renders.
MIT