Writing truly reusable React hooks, one test at a time
Learn how to decouple your custom React hooks from any external dependency they might need.
Example: a coupled hook
More often than not, custom React hooks need to access data through other hooks.
For example, consider the following custom hook, connected to React Router via useLocation()
:
import { useState, useEffect } from "react";
import { useLocation } from "react-router-dom";
const useThing = () => {
const { pathname } = useLocation();
const [thing, setThing] = useState("");
useEffect(() => {
if (pathname === "/") return;
// call an API or do something else
setThing("I did my thing");
}, [pathname]);
return thing;
};
export { useThing };
The code is straightforward. Depending on the browser location we do something in a useEffect()
, be it an API call or something else.
What about a unit test for this hook? A basic test would look like the following:
import { renderHook } from "@testing-library/react-hooks";
import { useThing } from "../useThing";
describe("useThing", () => {
test("it does nothing on /", () => {
const {
result: { current }
} = renderHook(() => useThing());
expect(current).toEqual("");
});
});
However, if we launch this test, it will fail miserably with the following error:
TypeError: Cannot read property 'location' of undefined
Makes sense. In order for our hook to work, we need to surround any of its usages with the right context provider, which in this case only BrowserRouter
from react-router-dom
can provide.
To fix this test we have two options:
- mocking
react-router-dom
- wrapping the hook with
BrowserRouter
To start with, here's the version of the test with a mocked react-router-dom
. This time we test with a proper pathname:
import { renderHook } from "@testing-library/react-hooks";
import { useThing } from "../useThing";
import "react-router-dom";
jest.mock("react-router-dom", () => {
return {
useLocation: () => {
return {
pathname: "/user/"
};
}
};
});
describe("useThing", () => {
test("it does its thing", () => {
const {
result: { current }
} = renderHook(() => useThing());
expect(current).toEqual("I did my thing");
});
});
The test will pass. At what cost? Mocking is tempting, however, it exposes our test to a series of pitfalls:
- the hook is tested, but not so reusable outside a React Router context
- mocks are noisy, and can quickly go out of sync if the external dependency changes its API
Can we do better? Most guides suggest wrapping the hook under test with the correct context. Again, here only BrowserRouter
from react-router-dom
can give our hooks the expected context.
Here's how we need to wrap our hook in a unit test:
import { renderHook } from "@testing-library/react-hooks";
import { useThing } from "../useThing";
import { BrowserRouter } from "react-router-dom";
describe("useThing", () => {
test("it does its thing", () => {
const wrapper = ({ children }) => <BrowserRouter>{children}</BrowserRouter>;
const {
result: { current }
} = renderHook(() => useThing(), { wrapper });
expect(current).toEqual("");
});
});
However, this test starts to look more like an integration test to me, plus, we haven't solved our problem: we need a truly reusable hook which can work everywhere. Can we do better?
A better approach: treating custom hooks like functions
In the end, custom React hooks are simply functions. If we treat them like functions, we can think in terms of parameters, which lead us to write something like this:
import { useState, useEffect } from "react";
type UseThing = (pathname: string) => string;
const useThing: UseThing = pathname => {
const [thing, setThing] = useState("");
useEffect(() => {
if (pathname === "/") return;
// call an API or do something else
setThing("I did my thing");
}, [pathname]);
return thing;
};
export { useThing };
Now our hook is truly reusable. It does not care whether the path name comes from useLocation()
in React Router, or useRouter()
from Next.js. It only needs to know that pathname is a string, and should do "its thing" depending on the value of the string.
In fact, we could use this hook in a Next.js page, and it will work flawlessly:
import { NextPage } from "next";
import { useThing } from "./useThing";
const Page: NextPage = () => {
const { pathname } = useRouter();
const doStuff = useThing(pathname);
return <p>Hello world!</p>
};
export default Page;
As for our test, we can now again write a real unit test for the hook:
import { renderHook } from "@testing-library/react-hooks";
import { useThing } from "../useThing";
describe("useThing", () => {
test("it does nothing on /", () => {
const {
result: { current }
} = renderHook(() => useThing("/"));
expect(current).toEqual("");
});
test("it does its thing", () => {
const {
result: { current }
} = renderHook(() => useThing("/user/"));
expect(current).toEqual("I did my thing");
});
});
In my opinion this is much better than any mock/wrapper.
Key takeaways
In this post we learned how to decoupled our custom React hooks from an external dependency.
In the end, we should be interested in avoiding tight coupling in our code, whenever possible. A React hook which is directly tied to some specific routing system won't be reusable.
Thanks for reading!