MutationObserver
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:
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.
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:
export default function App() { const listRef = useRef(); const [count, setCount] = useState(2); const [fruits, setFruits] = useState(["apple", "peach"]); const onListMutation = useCallback( (mutationList) => { setCount(mutationList[0].target.children.length); }, [setCount] ); useMutationObservable(listRef.current, onListMutation); return ( <div> <span>{`Added ${count} fruits`}</span> <br /> <button onClick={() => setFruits([...fruits, `random fruit ${fruits.length}`])} > Add random fruit </button> <ul ref={listRef}> {fruits.map((f) => ( <li key={f}>{f}</li> ))} </ul> </div> ); }
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.
useMutationObservable
custom HookLet’s look at the integration point:
useMutationObservable(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:
const DEFAULT_OPTIONS = { config: { attributes: true, childList: true, subtree: true }, }; function useMutationObservable(targetEl, cb, options = DEFAULT_OPTIONS) { const [observer, setObserver] = useState(null); useEffect(() => { const obs = new MutationObserver(cb); setObserver(obs); }, [cb, options, setObserver]); useEffect(() => { if (!observer) return; const { config } = options; observer.observe(targetEl, config); return () => { if (observer) { observer.disconnect(); } }; }, [observer, targetEl, options]); }
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.
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:
function useMutationObservable(targetEl, cb, options = DEFAULT_OPTIONS) { const [observer, setObserver] = useState(null); useEffect(() => { // A) if (!cb || typeof cb !== "function") { console.warn( `You must provide a valid callback function, instead you've provided ${cb}` ); return; } const { debounceTime } = options; const obs = new MutationObserver(cb); setObserver(obs); }, [cb, options, setObserver]); useEffect(() => { if (!observer) return; if (!targetEl) { // B) console.warn( `You must provide a valid DOM element to observe, instead you've provided ${targetEl}` ); } const { config } = options; try { observer.observe(targetEl, config); } catch (e) { // C) console.error(e); } return () => { if (observer) { observer.disconnect(); } }; }, [observer, targetEl, options]); }
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.
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:
import debounce from "lodash.debounce"; const DEFAULT_OPTIONS = { config: { attributes: true, childList: true, subtree: true }, debounceTime: 0 }; function useMutationObservable(targetEl, cb, options = DEFAULT_OPTIONS) { const [observer, setObserver] = useState(null); useEffect(() => { if (!cb || typeof cb !== "function") { console.warn( `You must provide a valida callback function, instead you've provided ${cb}` ); return; } const { debounceTime } = options; const obs = new MutationObserver( debounceTime > 0 ? debounce(cb, debounceTime) : cb ); setObserver(obs); }, [cb, options, setObserver]); // ...
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 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 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:
/** * This custom hooks abstracts the usage of the Mutation Observer with React components. * Watch for changes being made to the DOM tree and trigger a custom callback. * @param {Element} targetEl DOM element to be observed * @param {Function} cb callback that will run when there's a change in targetEl or any * child element (depending on the provided options) * @param {Object} options * @param {Object} options.config check \[options\](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe) * @param {number} [options.debounceTime=0] a number that represents the amount of time in ms * that you which to debounce the call to the provided callback function */ function 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.
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:
debounceTime
exampleYou can check the full implementation of the custom React Hook here.
Install LogRocket via npm or script tag. LogRocket.init()
must be called client-side, not
server-side
$ npm i --save logrocket // Code: import LogRocket from 'logrocket'; LogRocket.init('app/id');
// Add to your HTML: <script src="https://cdn.lr-ingest.com/LogRocket.min.js"></script> <script>window.LogRocket && window.LogRocket.init('app/id');</script>
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowLearn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
Handle frontend data discrepancies with eventual consistency using WebSockets, Docker Compose, and practical code examples.
Efficient initializing is crucial to smooth-running websites. One way to optimize that process is through lazy initialization in Rust 1.80.
One Reply to "Guide to custom React Hooks with <code>MutationObserver</code>"
tutorial is provided by separate packs of data in rather tough and hard to get manner.. it should start from the start, not from the middle…