I have sometimes heard people say that with the introduction of React's functional components, it's natural to expect functions to become larger and more complex. After all, the components are written as "one function", and therefore you have to accept a bit of swelling. It is also stated in React's documentation that:
"Now that function components can do more, it’s likely that the average function component in your codebase will become longer."
It also says that we should:
"Try to resist adding abstraction too early."
If you use CodeScene, you are probably aware that it warns you if functions become too long or complex. In light of the above, it might be tempting to think that CodeScene should be configured to be more lenient in this case. While this is entirely possible to do, I will argue that we don't have to, and we shouldn't resist adding these abstractions too much, as there are many benefits to be had, and it's not always that tricky to do. We can still keep the code health at a perfect 10!
Tackling complexity
We should realize that although functional components are indeed written as "one function", they can - like any other function - be composed of other functions. Every useState
, useEffect
, other hooks or sub-components are themselves just functions. Therefore we should be able to tackle complexity in the same way we are already familiar with: by extracting common patterns into new functions that encapsulate more complex behavior.
The obvious way of attacking complex components is to decompose them into sub-components. But this can feel unnatural or it can be difficult to find the right delineation. Sometimes we can instead find new abstractions simply by looking at the hook logic of the component.
Whenever we see a long list of useState
or useEffect
or other built-in primitive hooks within a component, there might be an opportunity to extract them into a custom hook. A custom hook is just another function that uses other hooks, and they are really simple to build.
The following component represents a small dashboard that lists a user's repositories (you could imagine something like GitHub). It is not a complex component, but it will serve as a good example for using hooks in this way.
function Dashboard() {
const [repos, setRepos] = useState<Repo[]>([]);
const [isLoadingRepos, setIsLoadingRepos] = useState(true);
const [repoError, setRepoError] = useState<string | null>(null);
useEffect(() => {
fetchRepos()
.then((p) => setRepos(p))
.catch((err) => setRepoError(err))
.finally(() => setIsLoadingRepos(false));
}, []);
return (
<div className="flex gap-2 mb-8">
{isLoadingRepos && <Spinner />}
{repoError && <span>{repoError}</span>}
{repos.map((r) => (
<RepoCard key={i.name} item={r} />
))}
</div>
);
}
To extract the hook logic into a custom hook, we can just copy the code into a function whose name starts with use
(in this case useRepos
):
/**
* Hook for fetching the list of all repos for the current user.
*/
export function useRepos() {
const [repos, setRepos] = useState<Repo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchRepos()
.then((p) => setRepos(p))
.catch((err) => setError(err))
.finally(() => setIsLoading(false));
}, []);
return [repos, isLoading, error] as const;
}
The reason it has to start with use
is so that the linter can detect that you are calling a hook rather than a "normal" function, which allows it to check that you are following the rules of calling a hook.
The only new thing here is the return statement and the use of as const
. The typing hint simply ensures that the inferred type is exactly the same as that of the array expression: an array of 3 elements with the types Repo[]
, boolean
and string | null
, respectively. There is no rule that says it has to be an array, though. You can return anything you want from a hook.
Putting the hook into practice, our component now becomes:
function Dashboard() {
const [repos, isLoadingRepos, repoError] = useRepos();
return (
<div className="flex gap-2 mb-8">
{isLoadingRepos && <Spinner />}
{repoError && <span>{repoError}</span>}
{repos.map((i) => (
<RepoCard key={i.name} item={i} />
))}
</div>
);
}
Notice that our component no longer has access to any of the setter functions. It doesn't need them as it's all encapsulated in the useRepos
hook. If you do need them, you are of course free to return some, or all of them.
What are the advantages of doing this? React's documentation mentions that:
"Building your own Hooks lets you extract component logic into reusable functions."
We can easily imagine other components within the application with a need to display a list of repositories, and all they have to do now is to import the useRepos
hook. If the hook is updated - perhaps with some form of caching, or a continuous update via polling or by a more sophisticated method - then all users of the hook will reap the benefits.
useState
and useEffect
came together to achieve one thing - the fetching of repos - it's quite common to see many sets of these primitives within a single component all achieving different things, and if we package the ones that "belong together" into their own hooks, it makes it easier see which of the states that have to be kept in sync when updating the code. It also gives us:
- Smaller functions that are easier to read
- The ability to name the concept
(useRepos)
- A natural place to document the operation (TSDoc, etc.)
Conclusion
useState
or useEffect
hooks. An example is Dan Abramov's useInterval hook. Perhaps you have a use case similar to useRepos
above, except it has to continuously poll for updates? You can then put useInterval
into use in your custom hook.