Hone logo
Hone
Problems

React Custom Hook: useMutationObserver

Create a reusable React custom hook useMutationObserver that abstracts the functionality of the MutationObserver API. This hook should allow React components to easily observe changes to DOM elements and react to those changes. Mastering this hook will enable more dynamic and responsive UIs that can adapt to DOM manipulations outside of direct React control.

Problem Description

Your task is to implement a custom React hook named useMutationObserver that encapsulates the MutationObserver Web API. This hook should accept a React RefObject pointing to the DOM element to observe, a callback function to execute when mutations occur, and an optional configuration object for the observer.

Key Requirements:

  1. Hook Signature: The hook should have the following signature:

    function useMutationObserver(
      ref: React.RefObject<Element>,
      callback: MutationCallback,
      options?: MutationObserverInit
    ): void;
    
    • ref: A React.RefObject pointing to the DOM element to observe.
    • callback: A MutationCallback function that will be invoked when mutations are detected. This function receives an array of MutationRecord objects and the MutationObserver instance itself.
    • options: An optional MutationObserverInit object to configure which mutations to observe (e.g., childList, attributes, subtree, characterData).
  2. Observer Initialization: The hook should create a new MutationObserver instance internally.

  3. Observation Start/Stop:

    • The observer should start observing the target element when the component mounts or when the ref or callback changes.
    • The observer should stop observing when the component unmounts or when the ref or callback changes (to ensure proper cleanup and prevent memory leaks).
  4. Cleanup: Implement proper cleanup by calling observer.disconnect() when the hook is no longer needed.

  5. Type Safety: The hook and its parameters should be strongly typed using TypeScript.

Expected Behavior:

When a MutationObserver is active for a given element, and the specified mutations occur (based on the options), the provided callback function should be executed with the relevant mutation data.

Examples

Example 1: Observing Child List Changes

Consider a component that displays a list of items and you want to know when items are added or removed from a specific container.

Input:

import React, { useRef, useState, useEffect } from 'react';
import { useMutationObserver } from './useMutationObserver'; // Assuming hook is in this file

const ChildListObserver: React.FC = () => {
  const containerRef = useRef<HTMLDivElement>(null);
  const [changes, setChanges] = useState<string[]>([]);

  const mutationCallback: MutationCallback = (mutations) => {
    mutations.forEach(mutation => {
      if (mutation.type === 'childList') {
        mutation.addedNodes.forEach(node => {
          if (node instanceof Element) {
            setChanges(prev => [...prev, `Added: ${node.textContent}`]);
          }
        });
        mutation.removedNodes.forEach(node => {
          if (node instanceof Element) {
            setChanges(prev => [...prev, `Removed: ${node.textContent}`]);
          }
        });
      }
    });
  };

  useMutationObserver(containerRef, mutationCallback, {
    childList: true,
    subtree: true, // Observe changes in descendants too
  });

  const addItem = () => {
    const newItem = document.createElement('div');
    newItem.textContent = `Item ${changes.length + 1}`;
    containerRef.current?.appendChild(newItem);
  };

  const removeItem = () => {
    if (containerRef.current && containerRef.current.lastChild) {
      containerRef.current.removeChild(containerRef.current.lastChild);
    }
  };

  return (
    <div>
      <button onClick={addItem}>Add Item</button>
      <button onClick={removeItem}>Remove Item</button>
      <div ref={containerRef} style={{ border: '1px solid black', padding: '10px', minHeight: '50px' }}>
        {/* Initial content can go here, or it can be dynamically added */}
      </div>
      <h3>Detected Changes:</h3>
      <ul>
        {changes.map((change, index) => (
          <li key={index}>{change}</li>
        ))}
      </ul>
    </div>
  );
};

export default ChildListObserver;

Output (after clicking "Add Item" twice, then "Remove Item" once):

Detected Changes:
Added: Item 1
Added: Item 2
Removed: Item 2

Explanation: The useMutationObserver hook is used to monitor the containerRef for childList changes. When "Add Item" is clicked, new div elements are appended, triggering the mutationCallback. When "Remove Item" is clicked, the last div is removed, also triggering the callback. The state changes is updated to reflect these additions and removals.

Example 2: Observing Attribute Changes

Imagine you want to react when a specific attribute of an element changes, for example, a data-status attribute on a task item.

Input:

import React, { useRef, useState } from 'react';
import { useMutationObserver } from './useMutationObserver'; // Assuming hook is in this file

const AttributeObserver: React.FC = () => {
  const taskRef = useRef<HTMLDivElement>(null);
  const [status, setStatus] = useState('pending');

  const mutationCallback: MutationCallback = (mutations) => {
    mutations.forEach(mutation => {
      if (mutation.type === 'attributes' && mutation.attributeName === 'data-status') {
        const newStatus = (mutation.target as Element).getAttribute('data-status');
        setStatus(newStatus || 'unknown');
      }
    });
  };

  useMutationObserver(taskRef, mutationCallback, {
    attributes: true, // Observe attribute changes
    attributeOldValue: false, // We only care about the new value
    // attributeFilter: ['data-status'] // More specific if needed
  });

  const updateStatus = () => {
    const nextStatus = status === 'pending' ? 'completed' : 'pending';
    taskRef.current?.setAttribute('data-status', nextStatus);
  };

  return (
    <div>
      <div
        ref={taskRef}
        data-status={status}
        style={{ border: '1px solid blue', padding: '10px', margin: '10px' }}
      >
        Task Item
      </div>
      <button onClick={updateStatus}>
        Change Status (Current: {status})
      </button>
      <h3>Observed Status: {status}</h3>
    </div>
  );
};

export default AttributeObserver;

Output (after clicking "Change Status" once):

Observed Status: completed

Explanation: The useMutationObserver hook monitors the taskRef for attributes changes. When the "Change Status" button is clicked, the data-status attribute is updated. The mutationCallback detects this change and updates the component's state, reflecting the new status.

Constraints

  • The MutationObserver API must be used. Direct manipulation of React state based on user events is not the primary focus of this challenge, but rather reacting to DOM changes that might occur from other sources.
  • The hook must handle the lifecycle of the MutationObserver correctly, ensuring it's connected when needed and disconnected on unmount or when dependencies change.
  • The hook should be implemented in TypeScript.

Notes

  • Consider how the useMutationObserver hook will behave if the ref object's .current property is null initially. The observer should not be initialized until the ref points to a valid DOM element.
  • The callback function passed to the MutationObserver has a specific signature. Ensure your implementation adheres to this.
  • Think about potential performance implications. If subtree: true and attributes: true are used on a very complex DOM, it could lead to performance issues. While not a strict constraint for this challenge, it's a good practice to be aware of.
  • The options object for MutationObserverInit can be complex. The hook should correctly pass this object through.
Loading editor...
typescript