Dependency injection in React with some Context

Implementing container using context API as Dependency Injection Tool

Dependency injection is a technique used to remove interdependencies between two components of an application, but for some reason, it’s not commonly used in React applications. In this article we’re going to discuss some convenient ways to do it. But before digging into the React-specific part, let’s understand what Dependency Injection is.

What is Dependency Injection?

As we told you before, Dependency injection (AKA DI) is a technique used to remove interdependencies between two units of an application. Typically, we pass the dependencies as arguments during the unit’s creation.

Let’s take an example of a dependency created and how to resolve it using DI

Here, we’re going to use Typescript to make the types more explicit. Learn Typescript

// createUser.ts

import { User, create, isValidUser } from "../User";
import { UserRepository } from "../repositories/UserRepository"; // coupling
import { MailService } from "../services/MailService"; // coupling

const createUser = async (userData: User) => {
  const userRepository = new UserRepository();
  const mailService = new MailService();

  if (!isValidUser(userData)) {
    throw new Error("Error");
  }

  const user = create(userData);

  await userRepository.save(user);
  await mailService.sendWelcomeMail(user.email);

  return user;
};

export { createUser };

The example above shows us a user creation flow in some applications, but in order to achieve this, the createUser file imports UserRepository and MailService to build its flow. This is what we mentioned before as “coupling”, createUser depends on those concrete implementations of UserRepository and MailService, which not only reduces the code flexibility, it also increases the difficulties when writing tests.

Although it is natural that they depend on each other, we can avoid coupling our code by doing inversion of control i.e., enforcing createUser to tell the callers what is expected to create a new user (his dependencies). Dependency injection can help us remove the coupling from our code, let’s see how.

To accomplish this technique, we will be using high-order functions, which is a way in JavaScript/Typescript to write functions that return other functions. You can read more about it here.

// @aplication/useCases/CreateUser.ts

import { User, create, isValidUser } from "../domain/user/User";

type Dependencies = {
  userRepository: { save: (user: User) => void };
  mailService: { sendWelcomeMail: (email: User.email) => void };
};

const makeCreateUser =
  ({ userRepository, mailService }: Dependencies) =>
  async (userData: User) => {
    if (!isValidUser(userData)) {
      throw new Error("Error");
    }

    const user = create(userData);

    await userRepository.save(user);
    await mailService.sendWelcomeMail(user.email);

    return user;
  };

export { makeCreateUser };

In this code, we implemented some changes that I would like to discuss.

  • We have created a Dependency type that declares the required modules to create a user;
  • Changed the original function to a HOF to be able to receive our dependencies, and the userData.

Now with these changes, createUser depends on an interface only, and it no longer needs to import anything but the user’s domain. Furthermore, you may have noticed that we named our HOF makeCreateUser, and thats on purpose, since this function knows only how to build createUser, that is, know its dependencies and how it works inside. But, Where do we inject the dependencies? Well, this location is also called a container or a composition root.

// src/container.ts

import { makeCreateUser } from "../application/useCases/CreateUser";

import { userRepository } from "../infra/repositories/UserRepository";
import { mailServices } from "../services/MailServices";

const container = {
  createUser: makeCreateUser({ userRepository, mailServices }),
};

export { container };

Great! Now, we can use the createUser function by passing just userData as an argument.

// ../controllers/userController.ts

import { app } from "../hhtp/server";
import { createUser } from "../container";

app.post("/users", async (req, res) => {
  const { name, email } = req.body;

  /*
     As we assembled the dependencies within the container file,
      we can pass just name and email to createUser
   */
  const user = await createUser({ name, email });

  res.status(201).json(user);
});

Context API

We have gained a better understanding of DI now, so let’s make things even more exciting and learn how the Context API can help us implement the same approach in front-end applications.

Context API, it’s a feature available on React that allows us to pass data through the DOM, that is, a source of data serving its content to their children. You can read more about context API in this link.

The problem

Just as in our last example, we’re going to show you the problem and how we can solve this by using the context API as a DI container (we’ll use the same use case too)

// HomePage.tsx
import { useState } from "React";

import api from "../apiClient";
import { User } from "../domain/User";

const HomePage = () => {
  const [user, setUser] = useState<User | null>(null);

  const createUser = () => (user: User) => {
    const newUser = api
      .post("/user", user)
      .then((data) => setUser(data))
      .catch((e) => console.log(e));
  };

  return (
    <div class="home-container">
      /// ...more UI hre
      <button class="btn" onClick={createUser({ name, email })}>
        Create user
      </button>
      // ... more UI here
    </div>
  );
};

You might have noticed that, as we saw in a previous example, there is a coupling between two components – HomePage and API – in such a way that any changes in this service (API), have an impact on the HomePage component.

In addition, this component has many responsibilities. Ideally, a component in React should handle only UI. Here are two concepts that allow us to decouple components and make them more concise: Dependency Injection and Custom Hooks.

Solution with DI and Context API

First, we need to create a makeCreateUser function again, but this time with the API dependency injected, just like that

// @aplication/useCases/CreateUser.ts

import { User, create, isValidUser } from "../domain/user/User";

type Dependencies = {
  api: ApiClientType;
};

const makeCreateUser =
  ({ api }: Dependencies) =>
  async (userData: User) => {
    if (!isValidUser(user)) {
        throw new Error('Error');
    }

    const user = await api.post("/users", userData);

    if (!user) {
      throw new Error("Can't create user");
    }

    return user;
  };

export { makeCreateUser };

Now we can assemble the dependencies within the container file.

// container.ts
import { User } from "../domain/User";
import { api } from "../apiClient";
import { makeCreateUser } from "../useCases/CreateUser";

export const container = {
  createUser: makeCreateUser({ api }),
};

export const Container = typeof container;

Finally, we’re going to create a context to provide our functions through the application

// ContainerContext.tsx
import { createContext } from "React";
import { container, Container } from '../container'

export const ContainerContext = createContext<Container>(container)

const ContainerProvider = ({ children }: { children: React.ReactNode }) => {

  return (
    <ContainerContext.Provider value={container}>
      {children}
    </ContainerContext.Provider>
  );
};

So, let’s use it on the HomePage component

// HomePage.tsx
import { useContext } from "React";
import { ContainerContext } from "../container";

const HomePage = () => {
  // more stuff here ...
  const { createUser } = useContext(ContainerContext);
  // more stuff here ...

  return (
    <div class="home-container">
      <button class="btn" onClick={() => createUser({ name, email })}>
        Create user
      </button>
      // ... more UI here
    </div>
  );
};

Hold on, and what about custom hooks? (you might have asked) Well noticed! As we’ve said before, a component in React should handle only UI, the appropriate layer should handle any business rule.

Thanks to React Hooks, we can abstract logic and business rules without suffering. To create a new hook, you must obey some rules outlined in the React documentation. In our case, the custom hook will be created within the context API itself.

// ContainerContext.tsx
import { createContext, useContext } from "React";
import { container, Container } from '../container'

export const ContainerContext = createContext<Container>(container)

const ContainerProvider = ({ children }: { children React.ReactNode }) => {

  return (
    <ContainerContext.Provider value={container}>
      {children}
    </ContainerContext.Provider>
  );
};

export const useContainer = () => useContext(ContainerContext);

And now, on the HomePage component

// HomePage.tsx
import { useContainer } from "../container";

const HomePage = () => {
  // more stuff here ...
  const { createUser } = useContainer();
  // more stuff here ...

  return (
    <div class="home-container">
      <button class="btn" onClick={() => createUser({ name, email })}>
        Create user
      </button>
      // ... resto da UI
    </div>
  );
};

Tests with our solution

With this approach, we can write tests in as many scenarios as we want, without third-party libraries (which can bring more complexity and less legibility).

// HomePage.ts
import { act } from "@testing-library/react";
import userEvent from "@testing-library/user-event";

import { HomePage } from "./HomePage";
import { ApiClientType } from "../../apiClient";
import { ContainerContext } from "../ContainerContext";
import { makeCreateUser } from "../../useCases/CreateUser";

const api: ApiClientType = jest.fn().mockReturnValue({
  post: jest.fn(),
});

const createUser = makeCreateUser({ api });

const fakeContainer = { createUser };

describe("<HomePage />", () => {
  it("creates a user", async () => {
    const { findByRole, findByTestId } = render(
      <ContainerContext.Provider value={fakeContainer}>
        <HomePage />
      </ContainerContext.Provider>
    );

    const nameInput = findByTestId("sign-up-name-input");
    const emailInput = findByTestId("sign-up-email-input");
    const signUpButton = findByRole("button", { name: /Create Account/ });

    act(() => {
      userEvent.type(nameInput, "Moonshadow");
      userEvent.type(emailInput, "moonshadow@jmdemmateis.com");

      userEvent.click(signUpButton);
    });

    expect(await findByText("Welcome, Moonshadow")).toBeInTheDocument();
  });
});

Final considerations

We made it, folks! In such a simple way we have DI ready to use in our entire application and with this we have gained more flexibility, composability as well as better testing.

I really recommend you to read these two blog posts about Dependency Injection from another miner: Dependency Injection in JS/TS – Part 1 and Dependency Injection in JS/TS Part 2, as well as this one about using context api with DI approach instead of emulating a state management tool: React Context for dependency injection not state management.

We want to work with you. Check out our "What We Do" section!