Custom React hooks go beyond reusability
5 min read —
Tags:
webdev
react
frontend
tutorial
React allows us to create functions that handle stateful logic called hooks, and that brings some interesting possibilities to write cleaner components.
Table of contents
- First of all: hooks vs. other functions
- Reusability
- Separation of concerns: components
- Separation of concerns: logic and data display
- Testing
- Conclusion
- References
First of all: hooks vs. other functions
Custom hooks are not simply functions with "use" in their names. They were designed to deal with stateful logic. If there is no need to deal with state, you can simply create a utility function. But we will see in a minute that this is debatable due other factors.
Reusability
There are plenty of examples on the Web of hooks that encapsulate reusable logic. Here is a very simple one to handle fetching data:
export default function useFetch(url: string) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [data, setData] = useState<unknown>(null);
useEffect(() => {
setLoading(true);
fetch(url)
.then((response) => response.json())
.then((parsed) => {
setData(parsed);
})
.catch((err) => {
setError(new Error("An error occurred while fetching the data"));
})
.finally(() => {
setLoading(false);
});
}, [url]);
return { loading, error, data };
}
And then we would be able to use that anywhere, like:
import useFetch from "./hooks/useFetch";
export default function App() {
const { error, loading, data } = useFetch(
"https://pokeapi.co/api/v2/pokemon/ditto"
);
if (loading) return <p>Loading...</p>;
if (error) return <p>{error.message}</p>;
return (
<div className="App">
<p>Pokemon name: {data?.species.name}</p>
</div>
);
}
But our focus today is to accomplish something different.
Separation of concerns: components
As in the docs:
"Instead of artificially separating technologies by putting markup and logic in separate files, React separates concerns with loosely coupled units called “components” that contain both."
So let's try another example. Here we have a Progress bar that tracks the user's position while scrolling through an article, for example. We'll be using just a little bit of TailwindCSS for styling.
import {
DetailedHTMLProps,
ProgressHTMLAttributes,
useEffect,
useState,
} from "react";
type Props = DetailedHTMLProps<
ProgressHTMLAttributes<HTMLProgressElement>,
HTMLProgressElement
>;
export default function ProgressBar(props: Props) {
const [pageHeight, setPageHeight] = useState(0);
const [progress, setProgress] = useState(0);
useEffect(() => {
function handleScroll() {
setProgress(window.scrollY);
}
const windowHeight = document.body.clientHeight - window.innerHeight;
setPageHeight(windowHeight);
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
return (
<progress
{...props}
className="w-full [&::-webkit-progress-value]:rounded-r-lg [&::-webkit-progress-bar]:bg-transparent [&::-webkit-progress-value]:bg-violet-400 [&::-moz-progress-bar]:transparent"
aria-label="Page scroll progress"
tabIndex={0}
aria-valuenow={progress}
aria-valuemax={pageHeight}
value={progress}
max={pageHeight}
/>
);
}
Breaking it down a little bit:
- We create states to store the page size and scroll position
- When the component renders for the first time, we add an event listener to get the user's vertical scroll position
- When the component unmounts, we use the useEffect's cleanup function to remove the event listener.
Note: There are other ways of doing it, but I'm using Next.js so it's easier for this example to just handle the whole logic inside a useEffect to tell Next.js we are on the client side and have access to the window
object.
And here is the result:
That's 100% valid and follows React's philosophy, but it's too verbose. Any component slightly more complex, or even this one with newer features, can easily become too long and hard to read.
Separation of concerns: logic and data display
The beauty of custom hooks is that we can keep concerns separated by components, but that doesn't mean we must stop there, and we also don't need to artificially separate technologies.
We can separate its logic from the data display! In other words: we can treat data in one place and have a presentational layer in another. Our component doesn't need to be a single file.
So let's create our useProgress hook:
import { useEffect, useState } from "react";
export default function useProgress() {
const [pageHeight, setPageHeight] = useState(0);
const [progress, setProgress] = useState(0);
useEffect(() => {
function handleScroll() {
setProgress(window.scrollY);
}
const windowHeight = document.body.clientHeight - window.innerHeight;
setPageHeight(windowHeight);
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, []);
return { pageHeight, progress };
}
And now our progress bar just imports the two values it needs, hiding the implementation.
import { DetailedHTMLProps, ProgressHTMLAttributes } from "react";
import useProgress from "./hooks/useProgress";
type Props = DetailedHTMLProps<
ProgressHTMLAttributes<HTMLProgressElement>,
HTMLProgressElement
>;
export default function ProgressBar(props: Props) {
const { progress, pageHeight } = useProgress();
return (
<progress
{...props}
className="w-full [&::-webkit-progress-value]:rounded-r-lg [&::-webkit-progress-bar]:bg-transparent [&::-webkit-progress-value]:bg-violet-400 [&::-moz-progress-bar]:transparent"
aria-label="Page scroll progress"
tabIndex={0}
aria-valuenow={progress}
aria-valuemax={pageHeight}
value={progress}
max={pageHeight}
/>
);
}
Note: Here we can argue if every function inside our custom hook would deal only with stateful logic or if it could also include "utils". Overall, I particularly would expect to see only stateful logic.
Testing
react-testing-library allows us to test our hooks with the renderHook function, which can be very helpful (and it doesn't forbid you from also mocking it in your component). In the example below I'm just testing the hook, but the Github repo at the end of this article is a little more complete.
import { renderHook, fireEvent } from "@testing-library/react";
import useProgress from "./index";
describe("useProgress custom hook", () => {
it("renders with correct initial values", () => {
const { result } = renderHook(() => useProgress());
expect(result["current"]["progress"]).toBe(0);
expect(result["current"]["pageHeight"]).toBe(-768);
});
it("updates progress value on vertical scroll", () => {
const { result } = renderHook(() => useProgress());
fireEvent.scroll(window, { target: { scrollY: 300 } });
expect(result["current"]["progress"]).toBe(300);
expect(result["current"]["pageHeight"]).toBe(-768);
});
});
Conclusion
This article aims to demonstrate that we can rely on powerful React features to keep our code cleaner and maintainable. If you disagree or would like to contribute with more details or possibilities, you're more than welcome to comment.
Also, don't forget to read the docs linked in the references section below. It will provide you with a deeper knowledge of how React works.