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
Top comments (4)
This is amazing! Thanks for the post, I'll definitely have an use for it.
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.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 anICat
😄 there's also auseResolve
hook that lets you write more "traditional" hooks:I definitely should've made that clear in this post.
I forgot to mention there actually is a jpex plugin for Vite now 🎉
npmjs.com/package/@jpex-js/vite-pl...