The Case for Dependency Injection in React


The primary purpose of writing tests is to put yourself in a position where you can refactor your code later, and re-run the tests. By re-running the tests, you can be confident you didn’t break anything. If a test breaks, it should ideally be because you introduced a bug during the refactor — not because of anything else. This process — having tests, and re-running them after a refactor — saves a development team time, because it catches bugs early, before a developer even checks in code.

This might be controversial, but in my opinion, React.js makes this harder to accomplish than in other frameworks, like Vue or Angular, because React.js does not support dependency injection. I love React, but this does seem to be a real downside of using it.

An example

Say we have a component that fetches a user and renders their name. The API call itself lives in a separate module:

// api.js
export const fetchUser = async (id) => {
  const res = await fetch(`/api/users/${id}`);
  return res.json();
};
// UserProfile.jsx
import { fetchUser } from "./api";

const UserProfile = ({ userId }) => {
  const [user, setUser] = React.useState(null);

  React.useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);

  if (!user) return null;

  return <div>{user.name}</div>;
};

UserProfile doesn’t take fetchUser as a prop — it just imports it directly, the way most React components do. To test UserProfile without making a real network call, the typical approach is to mock the whole ./api module:

// UserProfile.test.jsx
import { render, screen } from "@testing-library/react";
import { fetchUser } from "./api";
import UserProfile from "./UserProfile";

jest.mock("./api");

test("renders the user's name", async () => {
  fetchUser.mockResolvedValue({ name: "Jane Doe" });

  render(<UserProfile userId="123" />);

  expect(await screen.findByText("Jane Doe")).toBeInTheDocument();
});

jest.mock("./api") replaces the entire module with an auto-mocked version, and fetchUser.mockResolvedValue(...) tells that mock what to return.

While this effectively mocks the data, and the test passes, this pattern isn’t ideal.

Then a dependency changes

Say a month goes by, and another developer decides to replace fetchUser with fetchUserV2 — a faster way to fetch a user, imported from a new apiV2 module:

// UserProfile.jsx
import { fetchUserV2 } from "./apiV2";

const UserProfile = ({ userId }) => {
  const [user, setUser] = React.useState(null);

  React.useEffect(() => {
    fetchUserV2(userId).then(setUser);
  }, [userId]);

  if (!user) return null;

  return <div>{user.name}</div>;
};

If that developer re-ran the test, it would fail — because fetchUserV2 isn’t mocked. jest.mock("./api") is still mocking a module UserProfile no longer even imports. And this is despite the fact that there’s nothing wrong with the code at all.

The real problem

Step back and think about this at a high level. Inside UserProfile.jsx, the fact that this component uses fetchUser was never supposed to be exposed to the outside world. The test file shouldn’t be making assumptions about which fetch function this component happens to use — all a test should know about is the component’s public interface: its props, and what it renders.

fetchUser is really part of UserProfile’s private interface. Which explains why, when a developer swaps that function out, they shouldn’t have to go back and modify any consuming code because of it — not even the test file. But they do. The whole concept of mocking modules in React seems to violate basic encapsulation principles.

This has really been my experience with React. Tests do break sometimes because a dependency was changed, and not because a bug was introduced. And it’s always a pain to go back into the test file and re-mock the correct dependency just to get a broken test passing again.

The good news is there’s a way around this problem, and other frameworks, like Vue and Angular, already support it: dependency injection. They make it straightforward and very easy to inject a dependency as an argument — in React, the equivalent would be passing it in as a prop, rather than importing it directly.

Wouldn’t that be a nice addition? One day, updating an API call or other dependency in your React component might not require modifying your test file at all — and when tests did break, they might break predominantly because of actual bugs.