Skip to content

zavvdev/react-context-di

Repository files navigation

react-context-di

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.

Why

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.

Install

npm install react-context-di

react (>=18) is a peer dependency - install it if you haven't already.

Quick start

1. Describe your container

// di/di.types.ts
import { UserService } from "../services/user.service";

export interface DiContainer {
  userService: UserService;
}

2. Create the DI context

// 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,
};

3. Create context value

// di/di.registry.ts
import { UserService } from "../services/user.service";
import { type DiContainer } from "./di.types";

export function registerDiContainer(): DiContainer {
  return {
    userService: new UserService(),
  };
}

4. Provide the container at the root of your app

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>
  );
}

5. Inject dependencies into a hook

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"),
  };
});

6. Inject dependencies straight into a component

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.

Testing

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();
});

API

createDependencyInjectionContext<D>()

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.

Design notes

  • No runtime overhead beyond Context. There's no service registry lookup, no reflection, no metadata - useDeps is a useContext call plus a reduce over 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.

License

MIT

About

A lightweight, type-safe dependency injection container for React, built on top of Context.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors