Hone logo
Hone
Problems

React Custom Hook: useIntersectionObserver

This challenge asks you to create a reusable React custom hook, useIntersectionObserver, that leverages the browser's native IntersectionObserver API. This hook will enable components to efficiently detect when they enter or leave the viewport, a common requirement for features like lazy loading images, infinite scrolling, or triggering animations.

Problem Description

Your task is to implement a React hook named useIntersectionObserver in TypeScript. This hook should encapsulate the logic for setting up and managing an IntersectionObserver instance.

Key Requirements:

  1. Hook Signature: The hook should accept a React RefObject pointing to the DOM element to observe and an optional IntersectionObserverInit configuration object.
  2. Observer Instance: Inside the hook, create an IntersectionObserver instance.
  3. Observation: The hook should start observing the provided DOM element.
  4. State Management: The hook should return a boolean value indicating whether the observed element is currently intersecting the viewport.
  5. Cleanup: The hook must properly disconnect the IntersectionObserver when the component unmounts to prevent memory leaks.
  6. Reactivity: The hook should re-run its setup logic if the observed element or the observer options change.

Expected Behavior:

  • The hook should return false initially.
  • When the observed element becomes visible within the viewport (based on the observer's thresholds), the hook should return true.
  • When the observed element leaves the viewport, the hook should return false.
  • The hook should handle cases where the ref is not yet attached to a DOM element gracefully.

Edge Cases to Consider:

  • The ref might be null initially or when the component is first rendered.
  • The IntersectionObserverInit options (like root, rootMargin, threshold) might be provided or omitted.
  • The observed element might be removed from the DOM dynamically.

Examples

Example 1: Observing an image for lazy loading.

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

function LazyImage({ src, alt }: { src: string; alt: string }) {
  const imgRef = useRef<HTMLImageElement>(null);
  const isIntersecting = useIntersectionObserver(imgRef);

  return (
    <img
      ref={imgRef}
      src={isIntersecting ? src : '/placeholder.png'} // Load actual image when intersecting
      alt={alt}
      style={{ width: '200px', height: '200px', backgroundColor: '#ccc' }}
    />
  );
}

// Usage in another component:
function ImageGallery() {
  return (
    <div>
      {[...Array(10)].map((_, i) => (
        <LazyImage key={i} src={`/images/image-${i}.jpg`} alt={`Image ${i}`} />
      ))}
    </div>
  );
}

Explanation: The LazyImage component uses useIntersectionObserver to track its visibility. The src attribute is conditionally set: it uses a placeholder until isIntersecting becomes true, at which point it loads the actual image. This defers image loading until the image is likely to be seen by the user.

Example 2: Observing a div to trigger an animation.

import React, { useRef } from 'react';
import { useIntersectionObserver } from './useIntersectionObserver';

function AnimatedSection() {
  const sectionRef = useRef<HTMLDivElement>(null);
  const isVisible = useIntersectionObserver(sectionRef, { threshold: 0.5 }); // Trigger when 50% visible

  return (
    <div
      ref={sectionRef}
      style={{
        width: '300px',
        height: '300px',
        backgroundColor: isVisible ? 'lightblue' : 'lightgray',
        transition: 'background-color 0.5s ease',
        margin: '100vh auto', // Push it far down the page to test scrolling
      }}
    >
      This section animates when visible!
    </div>
  );
}

// Usage:
function App() {
  return (
    <div>
      <p>Scroll down...</p>
      <AnimatedSection />
      <p>More content below...</p>
    </div>
  );
}

Explanation: The AnimatedSection component animates its background color based on whether it's at least 50% visible in the viewport. The threshold: 0.5 option ensures the animation triggers only when half of the element is in view.

Example 3: Using root and rootMargin.

import React, { useRef, useState } from 'react';
import { useIntersectionObserver } from './useIntersectionObserver';

function ScrollContainer({ children }: { children: React.ReactNode }) {
  const containerRef = useRef<HTMLDivElement>(null);
  // Observe children, but the root is the scrollable container itself,
  // and we want to trigger when children are 50px from the top of the container.
  const isChildVisible = useIntersectionObserver(
    useRef<HTMLDivElement>(null), // Ref for the child element to be observed, this will be set inside the hook
    {
      root: containerRef.current,
      rootMargin: '50px 0px 0px 0px', // 50px from the top of the root
      threshold: 0, // Trigger as soon as it's visible within the rootMargin
    }
  );

  return (
    <div
      ref={containerRef}
      style={{
        height: '400px',
        overflowY: 'scroll',
        border: '1px solid black',
        padding: '10px',
      }}
    >
      {/* For this example to work, the hook needs to accept a ref to the element INSIDE the container */}
      {/* Let's simplify the hook for this example to accept a ref directly and return its intersection status */}
      {React.Children.map(children, (child, index) => (
        <div
          key={index}
          style={{ height: '150px', margin: '20px', border: '1px dashed blue' }}
        >
          {child}
        </div>
      ))}
    </div>
  );
}

// Simplified hook usage for demonstration:
function ScrollableItem({ id }: { id: number }) {
  const itemRef = useRef<HTMLDivElement>(null);
  // We need to pass the actual element ref to the hook for observation.
  // The provided hook signature is `useIntersectionObserver(elementRef, options)`.
  // For this example, let's assume we can observe the `itemRef` and use `containerRef` as the `root`.
  // This would require a more complex hook or a slight modification to how the hook is used.
  // For the sake of this example, let's assume the `useIntersectionObserver` hook can take the `root` ref directly.
  // A more robust implementation would involve passing a ref to the *child* element to the hook.

  // Actual implementation would look like this:
  // const isVisible = useIntersectionObserver(itemRef, { root: containerRef.current, ... });

  // For simplicity, let's imagine a direct scenario for now.
  return (
    <div ref={itemRef} style={{ height: '150px', margin: '20px', border: '1px dashed blue' }}>
      Item {id}
    </div>
  );
}


function AppWithScroll() {
  const containerRef = useRef<HTMLDivElement>(null);
  const [visibleItemId, setVisibleItemId] = useState<number | null>(null);

  // A more realistic hook implementation would allow passing the 'root' option.
  // Let's simulate the hook's behavior for this example's intent.
  // In a real scenario, `useIntersectionObserver` would manage the observer.

  // This part is conceptual to demonstrate the *intent* of using root/rootMargin.
  // The actual hook implementation would handle the observer setup.

  // We'd observe individual items, and they'd tell us if they intersect with 'containerRef'.
  const items = [1, 2, 3, 4, 5];

  return (
    <div
      ref={containerRef}
      style={{
        height: '400px',
        overflowY: 'scroll',
        border: '1px solid black',
        padding: '10px',
      }}
    >
      {items.map(id => (
        // In a real implementation, `ScrollableItem` would use `useIntersectionObserver`
        // and pass `containerRef.current` as the `root` option.
        <div key={id} style={{ height: '150px', margin: '20px', border: '1px dashed blue' }}>
          {/* For this example, imagine ScrollableItem uses the hook */}
          Item {id}
        </div>
      ))}
    </div>
  );
}

Explanation: This example (conceptually) demonstrates using a scrollable div as the root for the IntersectionObserver. This allows you to observe elements within a specific scrolling container, not just the main viewport. The rootMargin allows you to define a buffer zone around the root before an intersection is considered.

Constraints

  • The hook must be written in TypeScript.
  • The hook should be performant and avoid unnecessary re-renders.
  • The hook should adhere to React's hook rules.
  • The IntersectionObserver API must be used. Do not polyfill it.
  • The hook should accept a React.RefObject for the element to observe.
  • The hook should accept an optional IntersectionObserverInit object for configuration.

Notes

  • The IntersectionObserver API provides an entries array in its callback, which contains information about each observed element, including its isIntersecting status.
  • Consider how you will handle the case where the ref.current is null when the hook is first run or when the DOM element hasn't been mounted yet.
  • The disconnect() method of the IntersectionObserver is crucial for cleanup in useEffect.
  • Think about how to re-initialize the observer if the options or the target element change. The useEffect hook with dependencies will be your friend here.
  • A good implementation will return a stable boolean state that updates as the element's intersection status changes.
Loading editor...
typescript