Dependency Injection in React with Jpex

jackmellis

Jack

Posted on December 17, 2020

Dependency Injection in React with Jpex

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

💖 💪 🙅 🚩
jackmellis
Jack

Posted on December 17, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related