Custom React Hooks for Shared APIs
Talking to engineers at Grafana and other teams I’ve worked on, there’s a very common issue around how React apps feel like they have low constraints around how code is organized and structured. Certainly we’ve moved on from the Backbone and jQuery spaghetti from ~10 years ago, but to act as though there is no such thing as React spaghetti is disingenuous. Well, I have a pattern I follow that I believe solves a large number of problems around how I write React apps and specifically manage state. This is absolutely not my invention, but it’s my attempt to distill it into something actionable based on my own research of effective APIs in React libraries from the broader community.
Defining custom hooks
React hooks are functional APIs within the React library that allow a component to “hook” into the behavior of React’s internal logic and processes. They are now the default mechanism to do so, and if you’ve written React code recently, you’re familiar with the common hooks like useState()
, useEffect()
, and more.
Hooks are specifically a React paradigm in terms of how an end user (you as the user of React) interact with React’s APIs, but the design practice behind them affords us a way to build boundaries between aspects of our own code as well. These would be what I would define as a “custom hook” in React - any function that matches the design practice of React hooks that allows you as the developer to hook into some shared logic of your app.
Let’s look at examples of custom hooks to see in practice what they can do.
Minimal wrapper around useContext
React Context is always my first choice for tree-local state management. If you know that state is shared between several components that will always belong to the same tree, the Context
API is the way to go. Here’s an example of a custom hook to get our feet wet:
import { createContext, useContext } from "react";
type Post = {
title: string;
content: string;
};
interface Client {
GetPosts(): Promise<Post[]>;
}
export const ClientContext = createContext<Client | null>(null);
export function useClient(): Client {
const v = useContext(ClientContext);
if (v === null) {
throw new Error("uninitialized client");
}
return v;
}
Here we define a context called ClientContext
that holds a nullable value of type Client
. Any consumer of the context can import the context itself and call useContext(ClientContext)
, but then it’s up to that consume to check the value of the Client
before using it. The custom hook useClient()
affords us an API where the localized domain knowledge of how a client works is interacted with (checking if it’s null before using it).
Now, consumers of the client have a custom hook they can use and have to be less concerened with whether the client is initialized.
Managing a domain model through a custom hook
In the last example, we saw that you can create a function that implements some logic around another hook, and it’s important to note that any function that calls a hook should now be considered a hook itself. This is indiciated through the convention of the use
keyword as the name of any method that is a hook. The last example also only used a single hook. Let’s look at another example that further demonstrates how you can use custom hooks as a means of encapsulation of a given domain model.
Consider a CMS admin panel for which a React app lists all posts in the CMS. Interacting with the list of posts is not trivial when you consider the amount of UX that can go into a simple table of records:
- We need to manage the fetching of the list which will require
useEffect()
to make the request when the component is mounted. And additionally, we track the state of the response from the fetch. - We need to paginate the list and will track state around the current page and any filtering applied to the table (such as components that apply a filter such as
draft: true
or a search term). - We want to make sure the URL is consistent with what state is affecting the list of pages (paging, filtering, etc).
- We need to do this all while keep the
loading
state correct.
Once you implement all of the above inside your component for rendering the list of pages, you’ve now bound a component with the domain model which makes it either hard or impossible to share with another part of the app that needs to do the exact same thing. Conceptually, loading posts is an extremely pervasive operation for a CMS admin panel.
Here’s a complete solution to the domain model part. I place this in its own file (and you even get to use the plain .ts
extension if you really want to, which is how you know it’s truly decoupled from React rendering).
import { useEffect, useState } from "react";
import { apiClient } from "@app/http"; // consider this an app-specific wrapper around fetch
const PageSize = 50; // a default, and this app doesn't let the user set it
type Post = {
title: string;
content: string;
};
/**
* PostListAPI is a shared interface for using a paginated and filtered list of posts.
*/
export interface PostListAPI {
posts: Post[];
loading: boolean;
initialized: boolean;
total: number;
page: number;
setPage: (p: number) => void;
filters: PostFilters;
setQueryFilter: (v: string) => void;
refresh: (reset: boolean) => Promise<void>;
}
type PostFilters = {
draft: boolean;
query: string;
};
export const DefaultFilters: PostFilters = {
draft: false,
query: "",
};
type GetPostsResponse = {
posts: Post[];
total: number;
};
export function fetchPosts(
page: number,
perPage: number,
filters: PostFilters,
): Promise<GetPostsResponse> {
return apiClient.get("/api/posts", {
...filters,
page,
perPage,
});
}
export function usePostList(
initPage: number,
initFilters: PostFilters = DefaultFilters,
): PostListAPI {
const [posts, setPosts] = useState<Post[]>([]);
const [loading, setLoading] = useState(true);
const [initialized, setInitialized] = useState(false);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(initPage);
const [filters, setFilters] = useState(initFilters);
useEffect(() => {
setLoading(true);
fetchPosts(page, PageSize, filters).then((resp) => {
setPosts(resp.posts);
setTotal(resp.total);
setLoading(false);
if (!initialized) {
setInitialized(true);
}
});
}, [page, filters]);
function setQueryFilter(newValue: string) {
setFilters({ ...filters, query: newValue });
}
async function refresh(reset: boolean) {
let currentPage = page;
if (reset) {
setPage(1);
currentPage = 1;
}
setLoading(true);
const resp = await fetchPosts(currentPage, PageSize, filters);
setPosts(resp.posts);
setTotal(resp.total);
setLoading(false);
}
return {
posts,
loading,
initialized,
total,
page,
setPage,
filters,
setQueryFilter,
refresh,
};
}
All this shared logic has no bearing on how the posts are rendered - it only is concerned with how to fetch posts in a correct way that also grants the rendering exactly the data and actions it needs to render the posts. This makes it easier to both share with other several components as well as to test. You could write a plain old unit test against this if you wanted to that never has to render a React component.
Using our posts API
Any component that needs to render a list of posts can simply call the custom hook and use any parts of the API that are suitable for the component’s UX.
import { usePostList } from "./api";
function PostsPage() {
const postList = usePostList(1);
return (
<table>
{postList.posts.map((post) => {
{
/* ...render the post */
}
})}
</table>
);
}
If you need a component in PostsPage()
tree to have access to postList
, you can either pass the value as a prop to any direct children, or in the case of a deeply nested dependency on postList
, define a React context in api.ts
that contains a PostListAPI
as its value. Then follow the React API for rendering the context provider as the first child of PostsPage()
.
Conclusion
When you start to use custom hooks, you’re afforded an optional domain model encapsulation that is simple to create, easier to test, and reduces maintenance overhead. If you’ve seen cusotm hooks before, perhaps you’ve never though about defining them in your app’s own domain, so please - give it a shot!