Dependency Injection in React with Jpex
Jack
Posted on December 17, 2020
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>
);
};
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>
);
};
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();
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;
};
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>
);
};
So now we can actually safely test our hook:
renderHook(() => useUsers(fakeWindow, fakeCookies));
expect(fakeWindow.fetch).calledWith(...);
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);
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;
});
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>
);
};
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(...);
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');
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
Posted on December 17, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.