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:
- Hook Signature: The hook should accept a React
RefObjectpointing to the DOM element to observe and an optionalIntersectionObserverInitconfiguration object. - Observer Instance: Inside the hook, create an
IntersectionObserverinstance. - Observation: The hook should start observing the provided DOM element.
- State Management: The hook should return a boolean value indicating whether the observed element is currently intersecting the viewport.
- Cleanup: The hook must properly disconnect the
IntersectionObserverwhen the component unmounts to prevent memory leaks. - Reactivity: The hook should re-run its setup logic if the observed element or the observer options change.
Expected Behavior:
- The hook should return
falseinitially. - 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
refis not yet attached to a DOM element gracefully.
Edge Cases to Consider:
- The
refmight be null initially or when the component is first rendered. - The
IntersectionObserverInitoptions (likeroot,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
IntersectionObserverAPI must be used. Do not polyfill it. - The hook should accept a
React.RefObjectfor the element to observe. - The hook should accept an optional
IntersectionObserverInitobject for configuration.
Notes
- The
IntersectionObserverAPI provides anentriesarray in its callback, which contains information about each observed element, including itsisIntersectingstatus. - Consider how you will handle the case where the
ref.currentisnullwhen the hook is first run or when the DOM element hasn't been mounted yet. - The
disconnect()method of theIntersectionObserveris crucial for cleanup inuseEffect. - Think about how to re-initialize the observer if the options or the target element change. The
useEffecthook 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.