Skip to content
goodguydaniel.com

Guide to custom React Hooks

with MutationObserver

JavaScript, React4 min read

This article was originally published by LogRocket. You can checkout the original post here.


With the introduction of React Hooks, the amount of shareable code within React codebases has exploded. Because Hooks are thin APIs on top of React, developers can collaborate by attaching reusable behavior to components and segregating these behaviors into smaller modules.

While this is similar to how JavaScript developers abstract business logic away in vanilla JavaScript modules, Hooks provide more than pure JavaScript functions. Instead of taking data in and out, developers can stretch the spectrum of possibilities of what can happen inside a Hook.

For instance, developers can:

  • Mutate and manage a piece of state for a specific component or an entire application
  • Trigger side effects on a page, like changing the title of a browser tab
  • Rectify external APIs by tapping into React components’ lifecycle with Hooks

In this post we’ll explore the latter possibility. As a case study, we’ll abstract the MutationObserver API in a custom React Hook, demonstrating how we can build robust, shareable pieces of logic in a React codebase.

We’ll create a dynamic label that updates itself to indicate how many items we have in a list. Instead of using the provided React state array of elements, we’ll use the MutationObserver API to detect added elements and update the label accordingly.

source: https://blog.logrocket.com/guide-to-custom-react-hooks-with-mutationobserver/

Implementation overview

The following code is a simple component that renders our list. It also updates a counter value that represents the number of fruits currently in the list:

1export default function App() {
2 const listRef = useRef();
3 const [count, setCount] = useState(2);
4 const [fruits, setFruits] = useState(["apple", "peach"]);
5 const onListMutation = useCallback(
6 (mutationList) => {
7 setCount(mutationList[0].target.children.length);
8 },
9 [setCount],
10 );
11
12 useMutationObservable(listRef.current, onListMutation);
13
14 return (
15 <div>
16 <span>{`Added ${count} fruits`}</span>
17 <br />
18 <button onClick={() => setFruits([...fruits, `random fruit ${fruits.length}`])}>Add random fruit</button>
19 <ul ref={listRef}>
20 {fruits.map((f) => (
21 <li key={f}>{f}</li>
22 ))}
23 </ul>
24 </div>
25 );
26}

We want to trigger a callback function whenever our list element is mutated. Within the callback we refer to, the element’s children give us the number of elements in the list.

Implementing the useMutationObservable custom Hook

Let’s look at the integration point:

1useMutationObservable(listRef.current, onListMutation);

The above useMutationObservable custom Hook abstracts the necessary operations to observe changes on the element passed as the first parameter. It then runs the callback passed as the second parameter whenever the target element changes.

Now, let’s implement our useMutationObservable custom Hook.

In the Hook, there are a number of boilerplate operations to understand. First, we must provide a set of options that comply with the MutationObserver API.

Once a MutationObserver instance is created, we must call observe to listen for changes in the targeted DOM element.

When we no longer need to listen to the changes, we must call disconnect on the observer to clean up our subscription. This must happen when the App component unmounts:

1const DEFAULT_OPTIONS = {
2 config: { attributes: true, childList: true, subtree: true },
3};
4function useMutationObservable(targetEl, cb, options = DEFAULT_OPTIONS) {
5 const [observer, setObserver] = useState(null);
6
7 useEffect(() => {
8 const obs = new MutationObserver(cb);
9 setObserver(obs);
10 }, [cb, options, setObserver]);
11
12 useEffect(() => {
13 if (!observer) return;
14 const { config } = options;
15 observer.observe(targetEl, config);
16 return () => {
17 if (observer) {
18 observer.disconnect();
19 }
20 };
21 }, [observer, targetEl, options]);
22}

All the above work, including initializing the MutationObserver with the right parameters, observing changes with the call to observer.observe, and cleaning up with observer.disconnect, are abstracted away from the client.

Not only do we export functionality, but we also clean up by hooking into the React components’ lifecycle and by leveraging cleanup callbacks on effect Hooks to tear down the MutationObserver instance.

Now that we have a functional and basic version of our Hook, we can think about improving its quality by iterating on its API and enhancing the developer experience around this shareable piece of code.

Input validation and development

One important aspect when designing custom React Hooks is input validation. We must be able to communicate to developers when things are not running smoothly or a certain use case is hitting an edge case.

Usually, development logs help developers understand unfamiliar code to adjust their implementation. Likewise, we can enhance the above implementation by adding runtime checks and comprehensive warning logs to validate and communicate issues to other developers:

1function useMutationObservable(targetEl, cb, options = DEFAULT_OPTIONS) {
2 const [observer, setObserver] = useState(null);
3
4 useEffect(() => {
5 // A)
6 if (!cb || typeof cb !== "function") {
7 console.warn(`You must provide a valid callback function, instead you've provided ${cb}`);
8 return;
9 }
10 const { debounceTime } = options;
11 const obs = new MutationObserver(cb);
12 setObserver(obs);
13 }, [cb, options, setObserver]);
14 useEffect(() => {
15 if (!observer) return;
16 if (!targetEl) {
17 // B)
18 console.warn(`You must provide a valid DOM element to observe, instead you've provided ${targetEl}`);
19 }
20 const { config } = options;
21 try {
22 observer.observe(targetEl, config);
23 } catch (e) {
24 // C)
25 console.error(e);
26 }
27 return () => {
28 if (observer) {
29 observer.disconnect();
30 }
31 };
32 }, [observer, targetEl, options]);
33}

In this example, we’re checking that a callback is passed as a second argument. This API check at runtime can easily alert the developer that something is wrong on the caller side.

We can also see whether the provided DOM element is invalid with an erroneous value provided to the Hook at runtime or not. These are logged together to inform us to quickly resolve the issue.

And, if observe throws an error, we can catch and report it. We must avoid breaking the JavaScript runtime flow as much as possible, so by catching the error, we can choose to either log it or report it depending on the environment.

Extensibility via configuration

If we want to add more capabilities to our Hook, we should do this in a retro-compatible fashion, such as an opt-in capability that has little or no friction towards its adoption.

Let’s look at how we can optionally debounce the provided callback function so callers can specify an interval of time when no other changes in the target element trigger. This runs the callback once rather than running the same amount of times the element or its children mutated:

1import debounce from "lodash.debounce";
2
3const DEFAULT_OPTIONS = {
4 config: { attributes: true, childList: true, subtree: true },
5 debounceTime: 0
6};
7function useMutationObservable(targetEl, cb, options = DEFAULT_OPTIONS) {
8 const [observer, setObserver] = useState(null);
9 useEffect(() => {
10 if (!cb || typeof cb !== "function") {
11 console.warn(
12 `You must provide a valida callback function, instead you've provided ${cb}`
13 );
14 return;
15 }
16 const { debounceTime } = options;
17 const obs = new MutationObserver(
18 debounceTime > 0 ? debounce(cb, debounceTime) : cb
19 );
20 setObserver(obs);
21 }, [cb, options, setObserver]);
22 // ...

This is handy if we must run a heavy operation, such as triggering a web request, ensuring it runs the minimum number of times possible.

Our debounceTime option can now pass into our custom Hook. If a value bigger than 0 passes to MutationObservable, the callback delays accordingly.

With a simple configuration exposed in our Hook API, we allow other developers to debounce their callbacks which can result in a more performant implementation given that we might drastically reduce the number of times the callback code gets executed.

Of course, we can always debounce the callback on the client side, but this way we enrich our API and make the caller-side implementation smaller and declarative.

Testing

Testing is an essential part of developing any kind of shared capability. It helps us ensure a certain level of quality for generic APIs when they are heavily contributed to and shared.

The guide to testing React Hooks has expansive detail around testing that can be implemented into this tutorial.

Documentation

Documentation can level up the quality of custom Hooks and make it developer-friendly.

But even when writing plain JavaScript, JSDoc documentation can be written for custom hook APIs to ensure the Hook passes the right message to developers.

Let’s focus on the useMutationObservable function declaration and how to add formatted JSDoc documentation to it:

1/**
2 * This custom hooks abstracts the usage of the Mutation Observer with React components.
3 * Watch for changes being made to the DOM tree and trigger a custom callback.
4 * @param {Element} targetEl DOM element to be observed
5 * @param {Function} cb callback that will run when there's a change in targetEl or any
6 * child element (depending on the provided options)
7 * @param {Object} options
8 * @param {Object} options.config check \[options\](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe)
9 * @param {number} [options.debounceTime=0] a number that represents the amount of time in ms
10 * that you which to debounce the call to the provided callback function
11 */
12function useMutationObservable(targetEl, cb, options = DEFAULT_OPTIONS) {

Writing this is not only useful for documentation, but it also leverages IntelliSense capabilities that autocomplete Hook usage and provide spot information for the Hook’s parameters. This saves developers a few seconds per usage, potentially adding up to hours wasted on reading through the code and trying to understand it.

Conclusion

With different kinds of custom Hooks we can implement, we see how they integrate extrinsic APIs into the React world. It’s easy to integrate state management within Hooks and run effects based on inputs from components using the Hook.

Remember that to build quality Hooks, it’s important to:

  • Design easy-to-use, declarative APIs
  • Enhance the development experience by checking for proper usage and logging warnings and errors
  • Expose features through configurations, such as the debounceTime example
  • Ease Hook usage by writing JSDoc documentation

You can check the full implementation of the custom React hook here.

If you liked this article, consider sharing (tweeting) it to your followers.