DEV Community

loading...

Dependency Injection in React with Jpex

Jack
Typescript and React nerd. I have a soft spot for architecture and unit testing
・4 min read

Dealing with side effects in React is a tricky subject. I'm sure we've all started by writing something like this:

const Users = () => {
  const [ users, setUsers ] = useState();

  useEffect(() => {
    window.fetch('/api/users').then(res => res.json()).then(data => setUsers(data));
  }, []);

  if (users == null) {
    return null;
  }

  return (
    <ul>
      {users.map(user => (
        <li>{user.name}</li>
      ))}
    </ul>
  );
};
Enter fullscreen mode Exit fullscreen mode

But this is pretty dirty. You're fetching from an api inline, managing app state inilne, etc.

Just use hooks!

When we're talking about dealing with side effects and state in components, the common solution is simply use hooks to abstract:

const useUsers = () => {
  const [ users, setUsers ] = useState();

  useEffect(() => {
    window.fetch('/api/users').then(res => res.json()).then(data => setUsers(data));
  }, []);

  return users;
};

const Users = () => {
  const users = useUsers();

  if (users == null) {
    return null;
  }

  return (
    <ul>
      {users.map(user => (
        <li>{user.name}</li>
      ))}
    </ul>
  );
};
Enter fullscreen mode Exit fullscreen mode

That's better right? Now the component is much simpler. However, both the beauty and the problem with hooks is that they're just regular functions, this is doing literally the exact same thing as the first example. You're still fetching data from an api.

How would you write a unit test for this hook, or the component? You'd probably mock the fetch function by overriding window.fetch right?

spyOn(window, 'fetch').mockImplementation(fakeFetchFn);

renderHook(useUsers);

expect(window.fetch).calledWith(...);

window.fetch.mockRestore();
Enter fullscreen mode Exit fullscreen mode

This is really dirty if you ask me. You're having to stub a global property, attempt to revert it after the test, hope that nothing bleeds between tests. You could also use something like msw to intercept the actual api requests? This has the same problem. If you've ever tried to use a concurrent test runner (like ava or jest's concurrent mode), you'll quickly encounter issues with this sort of thing.

It's important to make the distinction of unit testing vs integration testing. Integration tests encourage you test how your code works with other code. You'd want to stub as little as possible in order to get a more realistic setup. But for unit testing, you should be soley focused on a single unit of code without the distraction of external side effects.

To complicate our example further let's say we also need to use a cookie in our request:

const useUsers = () => {
  const [ users, setUsers ] = useState();
  const jwt = cookies.get('jwt');

  useEffect(() => {
    window.fetch('/api/users', {
      headers: {
        authorization: jwt,
      }
    }).then(res => res.json()).then(data => setUsers(data));
  }, []);

  return users;
};
Enter fullscreen mode Exit fullscreen mode

Invert control

The ideal solution would be to invert the control of your code. Imagine if we had complete control of what the hook thinks are its dependencies?

const useUsers = (window: Window, cookies: Cookies) => {
  const [ users, setUsers ] = useState();
  const jwt = cookies.get('jwt');

  useEffect(() => {
    window.fetch('/api/users', {
      headers: {
        authorization: jwt,
      }
    }).then(res => res.json()).then(data => setUsers(data));
  }, []);

  return users;
};

const Users = () => {
  const users = useUsers(window, cookies);

  if (users == null) {
    return null;
  }

  return (
    <ul>
      {users.map(user => (
        <li>{user.name}</li>
      ))}
    </ul>
  );
};
Enter fullscreen mode Exit fullscreen mode

So now we can actually safely test our hook:

renderHook(() => useUsers(fakeWindow, fakeCookies));

expect(fakeWindow.fetch).calledWith(...);
Enter fullscreen mode Exit fullscreen mode

Great! Now we have completely isolated that component's dependencies. But do you really want to be passing these things in every time? And how would you write a unit test for your component? Pass window/cookies in as props? Gross. We still don't have a large scale solution to this problem.

After this extremely long introduction, here's my solution:

Jpex

Jpex is a lightweight dependency injection container powered by typescript. It works with "vanilla" typescript but really shines when used with react. Unlike something like inversify it's not limited to OOP classes with experimental decorators, you can inject anything, anywhere!

So let's rewrite the example using jpex. First we want to register our cookies dependency:

import jpex from 'jpex';
import cookies, { Cookies } from 'my-cookies-library';

jpex.constant<Cookies>(cookies);
Enter fullscreen mode Exit fullscreen mode

This tells jpex that whenever it sees the Cookies type it is talking about the cookies variable.

We don't need to register the Window as jpex understands that it's a global object and can inject it automatically.

Now we can rewrite our react hook:

import { encase } from 'react-jpex';

const useUsers = encase((window: Window, cookies: Cookies) => () => {
  const [ users, setUsers ] = useState();
  const jwt = cookies.get('jwt');

  useEffect(() => {
    window.fetch('/api/users', {
      headers: {
        authorization: jwt,
      }
    }).then(res => res.json()).then(data => setUsers(data));
  }, []);

  return users;
});
Enter fullscreen mode Exit fullscreen mode

Well that's almost the same right? encase tells jpex "when somebody calls this function, resolve and inject its parameters, and return the inner function". The awesome thing about jpex is it is able to infer the dependencies purely based on their types. You could call window fuzzything and as long as it has a type of Window jpex understands.

Let's see our component:

const Users = () => {
  const users = useUsers();

  if (users == null) {
    return null;
  }

  return (
    <ul>
      {users.map(user => (
        <li>{user.name}</li>
      ))}
    </ul>
  );
};
Enter fullscreen mode Exit fullscreen mode

No change there! The component can just call the hook like a regular function. It doesn't need to understand or provide the hook's dependencies, yet we now have control of them.

Let's write a test for the hook now:

import { Provider } from 'react-jpex';

const wrapper = ({ children }) => (
  <Provider onMount={jpex => {
    jpex.constant<Cookies>(fakeCookies);
    jpex.constant<Window>(fakewindow);
  }}>
    {children}
  </Provider>
);

renderHook(useUsers, { wrapper });

expect(fakeWindow.fetch).calledWith(...);
Enter fullscreen mode Exit fullscreen mode

So what is happening here? The Provider component creates a new instance of jpex completely sandboxed for this test. We then pass an onMount prop that registers our stubbed dependencies. When our hook is called, it receives the stubbed dependencies.

Now lets consider how you could test a component that uses our hook:

import { Provider } from 'react-jpex';

const wrapper = ({ children }) => (
  <Provider onMount={jpex => {
    jpex.constant<Cookies>(fakeCookies);
    jpex.constant<Window>(fakewindow);
  }}>
    {children}
  </Provider>
);

render(<Users/>, { wrapper });

await screen.findByText('Bob Smith');
Enter fullscreen mode Exit fullscreen mode

Yup, it's the same! We have completely inverted control of our application so we can inject dependencies in from any level!

This is only the tip of the jpex iceberg. It's proven invaluable for things like storybook, hot-swapping dependencies based on environment, and abstracting our infrastructure layer. And although I've mostly focused on React usage, jpex is agnostic. You can use it with anything, and it works on the browser and in node!

Check it out! https://www.npmjs.com/package/jpex

Discussion (2)

Collapse
huytaquoc profile image
Huy Ta Quoc • Edited

Nice library! I used to use React Context for dependency injection, but its only limited to React components and React hooks.

However, it might be a little bit harder to do code splitting & lazy loading when we do dependency injection like this because there must be a "parent" that knows of all dependencies.

Another downside is according to the jpex's Documentation, you will also need a Babel/Webpack/Rollup plugin. This way you have coupled your application to the build tool. Now let's say you want to use Vite, you can't unless you write another Vite plugin.

And also, it would be much nicer if we could inject the dependencies without using encase. That way we can write the hooks/services just the way they are supposed to be written. In the backend world, there's awilix that helps to achieve that.

Collapse
jackmellis profile image
Jack Author

Thanks for the feedback!

Funnily enough I am using it in a Vite application right now, it took a little bit of fiddling but wasn't too difficult (and will be made into a plugin soon).

As the library is coupled with typescript there must be some build tool involved to turn it into javascript. The real problem I have is a severe lack of reflection tools for both javascript and typescript. It was what forced me down the babel path in the first place!

Saying that, you can use it with no build tools at all, you just lose the "magic" type inference powers.

encase is only one way to skin an ICat 😄 there's also a useResolve hook that lets you write more "traditional" hooks:

const useSomething = () => {
  const someFn = useResolve<SomeFn>();
  // ...
}
Enter fullscreen mode Exit fullscreen mode

I definitely should've made that clear in this post.