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.

Providers Scope the Shared ObserverThree stacked layers of a React tree. The page-level provider owns one observer with the viewport as root, used by grid cards. A carousel provider nested inside owns a second observer whose root is the carousel track, used by carousel cards. Each card's hook subscribes to the nearest provider's registry, so four hundred cards share two observers.Page providerroot: viewport, rootMargin 200px — used by 380 grid cardsCarousel providerroot: carousel track — used by 20 carousel cardsuseInView() in each cardSubscribes to the nearest provider; no observer construction.

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

TSX
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

TSX
// 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;
}
TSX
// 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.

Mount Cost for a 400-Card GridA bar chart of scripting time to mount a grid of four hundred cards. One observer per card cost about sixty-eight milliseconds more than no observers. A shared context registry cost about eight milliseconds more. Most of the saving is observer construction and closure allocation.Extra scripting time to mount 400 cards, mid-range laptopobserver per card+68 msshared context registry+8 ms

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 inView state 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 a key={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.

Why the Provider Does Not Re-Render ConsumersFour boxes. The provider creates its registry once with useState, so the context value never changes identity. Consumers read the registry from context only to subscribe. Each consumer's visibility lives in its own local state. When an entry arrives, only the affected card re-renders.useState(() =>registry)created onceStable contextvaluenever changes identitySubscribe via refper-card local stateEntry arrivesonly that cardre-renders

Verification Steps

  • Heap snapshot: one IntersectionObserver per 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.current during 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.


↑ Back to React Observer Hooks