Put a small registry in React context — one IntersectionObserver per root and option set, created by a provider — and let components subscribe their elements through a hook; the provider never re-renders consumers, scroll containers get their own provider with the right root, and subscriptions are released in ref cleanups.
Problem / Scenario Context
A React product grid renders 400 ProductCard components, each calling useInView(), which constructs its own IntersectionObserver. On mount, the page allocates 400 observers and 400 callbacks; profiling shows the mount taking 60 ms longer than the same grid without the hook. A module-level shared observer fixed the mount cost, but broke a second use: cards inside a horizontally scrolling "recently viewed" carousel need the carousel track as their root, and a single module-level observer can only have one root.
A shared observer needs a scope: the root and options come from where a component lives in the tree — which is exactly what React context is for. The React Observer Hooks topic covers hook basics; shared observer pooling explains the performance case.
Mechanics Explanation
IntersectionObserver's root, rootMargin and threshold are fixed at construction. A module-level pool keyed by options can serve many option sets, but the root is usually an element — a specific scroll container — which only exists inside the component tree. Context lets a provider component own that element and the observer for it, and lets any descendant subscribe to the nearest provider.
The provider's context value must be stable: if it changes identity, every consumer re-renders. So the value is a registry object created once (in useState initialiser or useRef), not an object literal rebuilt on each render. Consumers never read changing data from context; they only call registry.observe(el, cb).
The observer itself is created lazily, when the root element is known — for a scroll-container provider, after the container's ref has been attached.
Comparison Table: Sharing Strategies in React
| Strategy | Observers for 400 cards | Custom roots | Provider re-renders consumers? |
|---|---|---|---|
| One observer per component | 400 | yes | n/a |
| Module-level singleton | 1 | no | n/a |
| Module-level pool keyed by options | few | only document-level | n/a |
| Context registry per root/options | 1 per provider | yes | no, with a stable value |
| Context with changing value | 1 per provider | yes | yes — avoid |
Minimal Reproducible Example
function useInView<T extends Element>() {
const [inView, setInView] = useState(false);
const ref = useRef<T>(null);
useEffect(() => {
const io = new IntersectionObserver(([e]) => setInView(e.isIntersecting), { rootMargin: '200px' });
io.observe(ref.current!);
return () => io.disconnect();
}, []);
return [ref, inView] as const;
}
Production-Safe Solution
// observer-context.tsx
import { createContext, useCallback, useContext, useRef, useState, type ReactNode, type RefObject } from 'react';
type Cb = (e: IntersectionObserverEntry) => void;
class Registry {
#io: IntersectionObserver | null = null;
#cbs = new Map<Element, Set<Cb>>();
constructor(private init: () => IntersectionObserverInit) {}
#observer(): IntersectionObserver {
return this.#io ??= new IntersectionObserver((entries) => {
for (const e of entries) this.#cbs.get(e.target)?.forEach((cb) => cb(e));
}, this.init());
}
observe(el: Element, cb: Cb): () => void {
let set = this.#cbs.get(el);
if (!set) { set = new Set(); this.#cbs.set(el, set); this.#observer().observe(el); }
set.add(cb);
return () => {
set!.delete(cb);
if (set!.size === 0) { this.#cbs.delete(el); this.#io?.unobserve(el); }
if (this.#cbs.size === 0) { this.#io?.disconnect(); this.#io = null; }
};
}
}
const Ctx = createContext<Registry | null>(null);
interface ProviderProps { rootRef?: RefObject<Element>; rootMargin?: string; threshold?: number | number[]; children: ReactNode }
export function InViewProvider({ rootRef, rootMargin = '0px', threshold = 0, children }: ProviderProps) {
// Created once; reads the root lazily so a container ref attached later still works.
const [registry] = useState(() => new Registry(() => ({ root: rootRef?.current ?? null, rootMargin, threshold })));
return <Ctx.Provider value={registry}>{children}</Ctx.Provider>;
}
const fallback = new Registry(() => ({})); // no provider: document-level default
export function useInView<T extends Element>(opts: { once?: boolean } = {}) {
const registry = useContext(Ctx) ?? fallback;
const [inView, setInView] = useState(false);
const ref = useCallback((el: T | null) => {
if (!el) return;
const stop = registry.observe(el, (e) => {
setInView(e.isIntersecting);
if (e.isIntersecting && opts.once) stop();
});
return stop; // React 19 ref cleanup
}, [registry, opts.once]);
return [ref, inView] as const;
}
// Usage
function Page() {
const trackRef = useRef<HTMLDivElement>(null);
return (
<InViewProvider rootMargin="200px">
<Grid /> {/* cards share the page observer */}
<div ref={trackRef} className="carousel-track">
<InViewProvider rootRef={trackRef} rootMargin="0px 100% 0px 100%">
<Carousel /> {/* cards share the track observer */}
</InViewProvider>
</div>
</InViewProvider>
);
}
declare function Grid(): JSX.Element; declare function Carousel(): JSX.Element;
The registry's observer is created on the first subscription, after the carousel track's ref has been attached (child ref callbacks run after the parent's DOM exists in the same commit), so rootRef.current is available. The provider value never changes, so rendering the provider never re-renders consumers. Options are captured at the first subscription; changing them requires remounting the provider (for example with a key), mirroring the observer's own immutability.
Keeping Consumers From Re-Rendering
Context is often blamed for re-render storms, but only because context values change. The pattern above avoids that by construction:
- The value is an object created once via
useState(() => new Registry(...)). It never changes identity. - Changing data flows through subscriptions, not context: each consumer holds its own
inViewstate and re-renders only when its element's visibility changes. - Options are not props of the value. If a parent re-renders the provider with a different
rootMargin, the registry ignores it until remounted; that is deliberate, and akey={rootMargin}on the provider makes the remount explicit when options really must change.
For lists where each card's visibility drives expensive rendering, the per-card state keeps updates local. For global summaries — "how many cards are visible" — keep a separate external store rather than lifting every card's state into context.
Verification Steps
- Heap snapshot: one
IntersectionObserverper provider, not per card. - React DevTools Profiler: scrolling re-renders only cards whose visibility changed.
- Carousel cards report visibility relative to the track (scroll the track without scrolling the page).
- StrictMode: no duplicate subscriptions after mount settles.
- Unmount the grid and confirm the registry disconnects its observer.
Common Mistakes to Avoid
- Creating the context value inline (
value={{ observe }}) — it changes every render. - A single module-level observer for all roots. Scroll containers need their own.
- Reading
rootRef.currentduring render. It is null on the first render; read it lazily at first subscription. - Expecting option props to update a live observer. Remount the provider instead.
FAQ
Why context rather than a module-level pool?
A module-level pool works for document-level observers. When the root is a specific scroll container, the observer must be tied to that element, which exists only within part of the component tree; context scopes the observer to that subtree.
Will the provider cause all consumers to re-render?
Not if its value is stable. The registry is created once, so the context value never changes identity and consumers are never re-rendered by the provider.
How does a nested provider get its root element?
Pass a ref to the scroll container. The registry reads ref.current when the first card subscribes, by which time the container's DOM node exists.
What happens without a provider?
The hook falls back to a document-level registry, so components work anywhere, with the viewport as root.
Is this compatible with React Server Components?
The provider and hook are client components. Server components can render them as children; see the guide on observers in server components and client boundaries.
Does unsubscribing inside the callback cause problems?
No. Removing a callback from the set while the registry iterates entries is safe for other targets; for the same target, remaining entries in the current batch may still arrive, which the once option tolerates.
Related
- Building a TypeScript useIntersectionObserver Hook — the single-component hook
- Observers in React Server Components and Client Boundaries — where the provider must live
- Keying an Observer Pool by Threshold and rootMargin — pooling without context
↑ Back to React Observer Hooks