{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreigeggiuatqkaom7nyneeoq3jcwpgbahh6y2bjlcnl74d4qamhhjv4",
"uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mpiqexxjcop2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreibq5q6dtoabu7vrqg7sxgkq4szekdsgw4l767gkz6hrucxtntouxe"
},
"mimeType": "image/webp",
"size": 71746
},
"path": "/childrentime/react-useintersectionobserver-hook-lazy-load-detect-visibility-2026-1ooe",
"publishedAt": "2026-06-30T09:21:01.000Z",
"site": "https://dev.to",
"tags": [
"react",
"javascript",
"webdev",
"tutorial",
"@reactuses/core",
"useIntersectionObserver",
"useInfiniteScroll",
"useElementVisibility",
"SSR-Safe React Hooks",
"useResizeObserver",
"useElementSize",
"useElementBounding",
"React Observer Hooks: 7 Ways to Watch the DOM",
"@reactuses"
],
"textContent": "# React useIntersectionObserver Hook: Lazy Load & Detect Visibility (2026)\n\nYou want to load an image only when it scrolls near the viewport. Or fire an analytics event the first time a card is actually _seen_. Or trigger \"load more\" when the user reaches the bottom of a list. Every one of these is the same question — _is this element on screen yet?_ — and for years the answer was a `scroll` listener that fired hundreds of times a second, re-read `getBoundingClientRect()` on each tick, and still managed to miss the edge cases.\n\n`IntersectionObserver` is the browser API that answers that question correctly, asynchronously, and off the main thread. `useIntersectionObserver` is the hook that wires it into React without the `useEffect`/`useRef`/cleanup boilerplate — and without the leak-on-unmount and stale-closure bugs the hand-rolled version always ships. This post covers the real @reactuses/core API, the three patterns you'll actually reach for, and how to tune `threshold`, `rootMargin`, and `root`. SSR-safe and typed.\n\n## Why Not Just Use a Scroll Listener?\n\nThe old way to know whether an element was visible looked like this: listen to `scroll`, and on every event measure the element against the viewport.\n\n\n\n useEffect(() => {\n function onScroll() {\n const rect = el.getBoundingClientRect();\n if (rect.top < window.innerHeight) {\n setVisible(true);\n }\n }\n window.addEventListener('scroll', onScroll);\n return () => window.removeEventListener('scroll', onScroll);\n }, []);\n\n\nThis has two problems baked in. First, `scroll` fires on the main thread, dozens of times per second, and `getBoundingClientRect()` forces a synchronous layout each time — that's exactly the recipe for janky scrolling. Second, it only catches elements crossing the _viewport_ ; the moment your scroll happens inside a container, you're re-deriving geometry by hand.\n\n`IntersectionObserver` flips the model. You hand the browser a target and a threshold, and it tells _you_ — asynchronously, batched, off the scroll path — when the element crosses that threshold. No measuring, no listener thrash. The only thing left to get wrong is the React lifecycle around it, and that's the part the hook owns.\n\nHere's the naive in-component version, which has the same three bugs every hand-rolled observer does:\n\n\n\n function LazySection({ children }: { children: React.ReactNode }) {\n const ref = useRef<HTMLDivElement>(null);\n const [seen, setSeen] = useState(false);\n\n useEffect(() => {\n const el = ref.current;\n if (!el) return;\n const io = new IntersectionObserver(([entry]) => {\n if (entry.isIntersecting) setSeen(true); // 🐛 see below\n }, { threshold: 0.1 });\n io.observe(el);\n return () => io.disconnect();\n }, []);\n\n return <div ref={ref}>{seen ? children : null}</div>;\n }\n\n\n 1. **It leaks if you forget the cleanup.** Drop the `return () => io.disconnect()` — and people do, especially when refactoring — and the observer outlives the component.\n 2. **It captures stale closures.** The moment the callback references a prop or a second piece of state, the observer created on mount freezes whatever those were at mount time, not when it fires.\n 3. **It spreads.** Every lazy section, every \"viewed\" tracker, every infinite-scroll sentinel re-implements the same `useRef` + `observe` + `disconnect` dance, and each copy is a fresh chance to ship one of the first two bugs.\n\n\n\nA hook fixes all three in one place.\n\n## The API\n\nuseIntersectionObserver takes three arguments and returns a `stop` function:\n\n\n\n const stop = useIntersectionObserver(target, callback, options?);\n\n\n * **`target`** — what to observe. A React ref, a raw element, or a getter `() => element`. (It accepts `null`/`undefined` too, so observing a conditionally-rendered element is safe — the hook simply waits.)\n * **`callback`** — the standard `IntersectionObserverCallback`, `(entries, observer) => void`. You get the raw `IntersectionObserverEntry[]`, so _you_ decide what visibility means for your case.\n * **`options`** — the native `IntersectionObserverInit`: `{ root, rootMargin, threshold }`. All optional.\n * **returns`stop()`** — call it to disconnect the observer early (more on this below). The hook also calls it for you automatically on unmount.\n\n\n\nThe deliberate design choice here is that the hook is **callback-based, not boolean-based**. It doesn't decide for you that \"intersecting\" means visible — because depending on the job, it might mean \"10% visible\", \"fully visible\", or \"within 200px of the viewport\". You read `entry.isIntersecting` (or `entry.intersectionRatio`) and act. If all you want is a plain boolean, there's a convenience sibling for that — see below.\n\nInternally the callback is kept in a ref (via `useLatest`), so it never goes stale — bug #2 is gone even when your callback closes over props. And because the observer is only ever constructed inside an effect, the hook is SSR-safe: nothing touches `IntersectionObserver` during render.\n\n## Pattern 1: Lazy-Load an Image\n\nThe canonical use. Render a placeholder, and only swap in the real `<img>` once the container is about to enter the viewport. Note the `stop()` call — once we've loaded, we never need the observer again, so we disconnect it immediately.\n\n\n\n import { useRef, useState } from 'react';\n import { useIntersectionObserver } from '@reactuses/core';\n\n function LazyImage({ src, alt }: { src: string; alt: string }) {\n const ref = useRef<HTMLDivElement>(null);\n const [loaded, setLoaded] = useState(false);\n\n const stop = useIntersectionObserver(\n ref,\n ([entry]) => {\n if (entry.isIntersecting) {\n setLoaded(true);\n stop(); // one-shot: stop observing once we've committed to loading\n }\n },\n { rootMargin: '200px' }, // start loading 200px before it scrolls in\n );\n\n return (\n <div ref={ref} style={{ minHeight: 200 }}>\n {loaded ? <img src={src} alt={alt} /> : <div className=\"skeleton\" />}\n </div>\n );\n }\n\n\nTwo things make this feel right. The `rootMargin: '200px'` grows the observer's \"viewport\" by 200px on every side, so the fetch kicks off _before_ the image is actually visible and the user rarely sees the skeleton. And `stop()` inside the callback means a list of 500 lazy images ends up with zero live observers once they've all loaded — no lingering work as you keep scrolling.\n\n## Pattern 2: Fire-Once \"Viewed\" Analytics\n\nTracking which sections a user actually scrolled to is the same shape — but here you genuinely want it to fire exactly once, so the `stop()` is doing real work.\n\n\n\n import { useRef } from 'react';\n import { useIntersectionObserver } from '@reactuses/core';\n\n function TrackedSection({ id, children }: { id: string; children: React.ReactNode }) {\n const ref = useRef<HTMLElement>(null);\n\n const stop = useIntersectionObserver(\n ref,\n ([entry]) => {\n if (entry.isIntersecting) {\n analytics.track('section_viewed', { id });\n stop(); // count each section once, not once per scroll-past\n }\n },\n { threshold: 0.5 }, // \"viewed\" = at least half on screen\n );\n\n return <section ref={ref}>{children}</section>;\n }\n\n\nHere `threshold: 0.5` encodes a product decision — a section only counts as \"viewed\" once 50% of it is on screen, so a fast scroll past the top edge doesn't inflate your numbers. The `stop()` guarantees one event per section per page load even if the user scrolls it in and out repeatedly.\n\n## Pattern 3: Infinite-Scroll Trigger\n\nPut an empty sentinel `<div>` at the bottom of a list and fetch the next page when it intersects. Note that here we _don't_ call `stop()` — we want the trigger to keep firing for every page.\n\n\n\n import { useRef } from 'react';\n import { useIntersectionObserver } from '@reactuses/core';\n\n function Feed({ items, loadMore, hasMore }: FeedProps) {\n const sentinel = useRef<HTMLDivElement>(null);\n\n useIntersectionObserver(sentinel, ([entry]) => {\n if (entry.isIntersecting && hasMore) {\n loadMore();\n }\n });\n\n return (\n <>\n {items.map((it) => <Row key={it.id} item={it} />)}\n {hasMore && <div ref={sentinel} style={{ height: 1 }} />}\n </>\n );\n }\n\n\nBecause the callback is always the latest one (no stale closure), `loadMore` and `hasMore` are read fresh every time the sentinel intersects — the bug that bites the hand-rolled `useEffect` version doesn't exist here. If you want this whole pattern packaged, useInfiniteScroll builds exactly this on top, including the scroll-container plumbing.\n\n## Tuning: threshold, rootMargin, and root\n\nThe third argument is the native `IntersectionObserverInit`, passed straight through. Three knobs, each answering a different question:\n\n\n\n useIntersectionObserver(ref, callback, {\n threshold: 0.5, // HOW MUCH must be visible to count?\n rootMargin: '200px', // grow/shrink the trigger boundary\n root: containerRef.current, // WHAT are we measuring against?\n });\n\n\n * **`threshold`** — a number (or array) from `0` to `1` for _how much_ of the target must be visible before the callback fires. `0` (the default) fires the instant a single pixel crosses; `1` waits until the element is fully on screen. Pass an array like `[0, 0.25, 0.5, 0.75, 1]` to get a callback at each step — useful for scroll-linked animations driven by `entry.intersectionRatio`.\n * **`rootMargin`** — a CSS-margin string that inflates or deflates the root's bounding box _before_ intersection is computed. Positive values (`'200px'`) fire early — the lazy-load-ahead trick from Pattern 1. Negative values (`'-100px 0px'`) fire late, e.g. \"only count this as viewed once it's 100px past the top edge.\"\n * **`root`** — the element you're measuring against. Defaults to the browser viewport; set it to a scroll container's element when your list scrolls inside a `<div>` rather than the page.\n\n\n\n## The stop() Return Value\n\nThe returned `stop()` disconnects the observer. You usually don't need it — the hook auto-disconnects on unmount — but it's the clean way to express _one-shot_ observation, as in Patterns 1 and 2: the first time the element intersects, do the work and stop watching. That's both a correctness win (the event fires exactly once) and a performance one (no live observer trailing behind a long, already-loaded list).\n\n## Just Want a Boolean?\n\nSometimes you don't care about entries or thresholds — you just want a reactive `isVisible` flag for the whole viewport. useElementVisibility wraps `useIntersectionObserver` and hands you exactly that, as a tuple with its own `stop`:\n\n\n\n import { useRef } from 'react';\n import { useElementVisibility } from '@reactuses/core';\n\n function FadeIn({ children }: { children: React.ReactNode }) {\n const ref = useRef<HTMLDivElement>(null);\n const [visible] = useElementVisibility(ref);\n\n return (\n <div ref={ref} className={visible ? 'fade fade-in' : 'fade'}>\n {children}\n </div>\n );\n }\n\n\nReach for `useElementVisibility` when a boolean is all you need, and drop down to `useIntersectionObserver` the moment you want a custom `root`, a non-default `threshold`, multiple thresholds, or the raw entry. Same engine, two ergonomics.\n\n## SSR Safety\n\n`useIntersectionObserver` is safe to render on the server. It constructs the `IntersectionObserver` only inside an effect — which React never runs on the server — and the underlying element lookup returns `undefined` outside the browser, so there's no `typeof window` guard to write and no hydration mismatch to chase. Drop it into a Next.js, Remix, or Astro component as-is. (If SSR-safety is a recurring theme in your codebase, SSR-Safe React Hooks goes deeper.)\n\n## The Visibility & Size Family\n\n`useIntersectionObserver` is the low-level primitive in a family of DOM-watching hooks. Pick by what you actually want back:\n\nHook | Gives you | Reach for it when…\n---|---|---\nuseIntersectionObserver | raw entries, a `stop()` | you want full control: custom root, thresholds, one-shot\nuseElementVisibility | `[isVisible, stop]` | a plain \"is it on screen?\" boolean is enough\nuseInfiniteScroll | a load-more callback wired up | you're building a paginated/infinite list\nuseResizeObserver | a callback on size change | the element's _size_ matters, not its visibility\nuseElementSize | `{ width, height }` as state | you just need live width/height\nuseElementBounding | the full bounding rect | you need viewport-relative position (changes on scroll)\n\nFor the full tour of how these compose, see React Observer Hooks: 7 Ways to Watch the DOM.\n\n## Takeaways\n\n * A `scroll` listener plus `getBoundingClientRect()` is the wrong tool for \"is this on screen\" — it thrashes the main thread and still misses scroll containers. `IntersectionObserver` answers it correctly, batched and off the scroll path.\n * **`useIntersectionObserver(target, callback, options?)`** wires it into React: hand it a ref, a callback that receives the raw entries, and the native options. It returns a `stop()` and auto-disconnects on unmount.\n * It's **callback-based on purpose** — you decide what \"visible\" means via `entry.isIntersecting` / `entry.intersectionRatio`. The callback is never stale, so it reads fresh props every time it fires.\n * Call **`stop()`** inside the callback for one-shot jobs (lazy-load, fire-once analytics); skip it for repeating triggers (infinite scroll).\n * Tune with **`threshold`** (how much must show), **`rootMargin`** (fire early/late), and **`root`** (measure against a container, not the viewport).\n * Want just a boolean? **`useElementVisibility`** returns `[isVisible, stop]`. Both are SSR-safe.\n\n\n\nGrab it from @reactuses/core and delete your scroll-listener boilerplate.",
"title": "React useIntersectionObserver Hook: Lazy Load & Detect Visibility (2026)"
}