The browser Observer APIs are framework-agnostic by design, but every framework has its own rules for when the DOM exists, when it is safe to touch it, and when to release native resources. Wrapping IntersectionObserver, ResizeObserver, and MutationObserver into idiomatic React hooks, Vue composables, and Angular directives turns those rules into reusable, leak-free building blocks — so visibility tracking, responsive components, and DOM-change detection behave predictably across mounts, re-renders, and server-side rendering.

This guide connects the raw mechanics covered in Core Observer Fundamentals & Browser APIs to the concrete adapter patterns each major framework expects. If you have been copying observer boilerplate between components, the goal here is to replace it with a small set of well-tested wrappers.

API Architecture Overview — Where Observers Fit in a Component Lifecycle

A raw observer has three moments that matter: construction, observe(), and disconnect(). A component framework wraps those moments inside its own lifecycle — mount, update, and unmount — and the entire art of a good adapter is mapping one onto the other without leaking native handles or racing the DOM.

The critical constraint is timing. During the render phase, a framework computes what the DOM should be; the actual element does not exist yet. Calling observe() on a ref during render observes null. Every framework therefore provides a post-mount hook — useEffect in React, onMounted in Vue, ngAfterViewInit or a directive's ngOnInit in Angular — that runs after the element is attached. That is the only safe place to instantiate an observer, and it is also the place where server-side rendering never executes, which is why post-mount hooks double as SSR and hydration safety boundaries.

The diagram below maps the observer lifecycle onto a component's mount and unmount phases across the three frameworks:

Observer lifecycle mapped onto framework component lifecycles A flow showing render (element not yet in the DOM, unsafe to observe), the post-mount hook where the observer is created and observe() is called, the running phase where entries map into framework state, and the unmount hook where disconnect() releases the observer. React useEffect, Vue onMounted, and Angular ngOnInit are shown as the post-mount hooks; useEffect cleanup, onUnmounted, and ngOnDestroy as the teardown hooks. Render element not in DOM do NOT observe Post-mount hook new Observer(); observe() useEffect · onMounted · ngOnInit Running entries → framework state setState / ref / emit Teardown hook disconnect() cleanup / onUnmounted re-render does not re-observe (guard on identity)

Get this mapping right once, wrap it in a hook, composable, or directive, and every feature built on top — lazy loading, infinite scroll, sticky headers, container queries — inherits correct lifecycle behavior for free.

Core Concept Reference Table

Each framework exposes the same three lifecycle moments under different names. The table below is the Rosetta Stone for the rest of this guide.

Concern React Vue 3 Angular
Element reference useRef / ref callback template ref() ElementRef / @ViewChild
Post-mount hook useEffect(fn, []) onMounted ngOnInit (directive) / ngAfterViewInit
Reactive output useState setter reactive ref @Output EventEmitter / signal
Teardown hook useEffect cleanup return onUnmounted / tryOnScopeDispose ngOnDestroy / DestroyRef
Re-render guard stable deps / ref identity reactive dependency tracking zone-aware, manual
Keep off change detection n/a (opt-in renders) n/a (fine-grained) NgZone.runOutsideAngular

The recurring pattern is identical everywhere: acquire the element in a post-mount hook, create the observer, translate entry objects into the framework's reactivity primitive, and disconnect in teardown. The per-framework wrappers are covered in depth in React Observer Hooks, Vue Observer Composables, and Angular Observer Directives.

Annotated Production Code Pattern

The most reusable abstraction is a framework-agnostic core that owns the observer, plus a thin per-framework binding. The core below manages instantiation, SSR safety, and teardown; each framework only needs to wire its lifecycle hooks to connect and disconnect.

TypeScript
// observer-core.ts — framework-agnostic, testable, no framework imports
export interface VisibilityState {
  isIntersecting: boolean;
  ratio: number;
  entry: IntersectionObserverEntry | null;
}

export interface ObserverBinding {
  connect(element: Element): void;
  disconnect(): void;
}

/**
 * Create a self-contained visibility tracker. The `onChange` callback is the
 * single seam each framework adapts to its own reactivity primitive.
 */
export function createVisibilityBinding(
  onChange: (state: VisibilityState) => void,
  options: IntersectionObserverInit = { threshold: 0 }
): ObserverBinding {
  let observer: IntersectionObserver | null = null;

  return {
    connect(element: Element): void {
      // SSR / unsupported-browser guard — never touch the API on the server
      if (typeof IntersectionObserver === "undefined" || !element) return;
      // Idempotency: a re-render must not stack a second observation
      if (observer) return;

      observer = new IntersectionObserver((entries) => {
        const entry = entries[entries.length - 1];
        onChange({
          isIntersecting: entry.isIntersecting,
          ratio: entry.intersectionRatio,
          entry,
        });
      }, options);
      observer.observe(element);
    },

    disconnect(): void {
      observer?.disconnect();
      observer = null; // break the closure chain so GC can reclaim state
    },
  };
}
JavaScript
// Plain JS equivalent (no type annotations)
// export function createVisibilityBinding(onChange, options = { threshold: 0 }) { ... }

React binds this with useEffect, Vue with onMounted/onUnmounted, Angular with a directive's ngOnInit/ngOnDestroy. Because the core holds the only reference to the native observer and nulls it on disconnect(), the memory-leak vectors that plague ad-hoc component code are closed off in one place.

Memory & Lifecycle Management

Framework components mount and unmount constantly — routes, tabs, modals, and virtualized rows all churn the DOM. Every observer created on mount must be released on unmount, or the browser's internal registry keeps the target element and the callback's captured scope alive. Three disciplines carry over from Observer Lifecycle & Memory Management into framework code:

1. Cleanup is not optional. In React the useEffect cleanup return is the teardown; in Vue it is onUnmounted or tryOnScopeDispose; in Angular it is ngOnDestroy or a DestroyRef callback. A wrapper that does not register teardown is a leak generator, because components re-create it on every remount.

2. Guard against re-render churn. A React effect with unstable dependencies re-runs on every render, disconnecting and re-observing needlessly. Memoize options and callbacks (or store the observer in a ref) so the observe/disconnect pair runs exactly once per mount. This is the same idempotency guard shown in the core above.

3. Share observers for large collections. Rendering one observer per row in a thousand-row list creates a thousand native handles. A single shared observer keyed by a WeakMap registry dispatches to per-element handlers and lets the garbage collector reclaim detached rows automatically — the foundation of virtual list windowing.

Layout Thrashing Prevention

Wrapping an observer in a hook does not exempt its callback from the rules of the rendering pipeline. Reading layout properties (offsetHeight, getBoundingClientRect()) and then writing styles inside the same callback forces synchronous reflow. Prefer the pre-computed geometry already on the entry — entry.boundingClientRect, entry.contentRect, entry.intersectionRect — and defer any visual mutation to the next animation frame, exactly as covered in syncing observer callbacks with requestAnimationFrame.

Framework reactivity adds a second cost: a state update from a callback schedules a re-render. In React and Vue that is fine at human interaction rates but wasteful at scroll frequency, so update state only when a value the UI actually depends on changes. In Angular the cost is sharper — every callback that touches a bound property triggers change detection — which is why observer callbacks belong outside the Angular zone.

TypeScript
// Angular — keep high-frequency callbacks off change detection
constructor(private zone: NgZone, private el: ElementRef<HTMLElement>) {}

ngOnInit(): void {
  this.zone.runOutsideAngular(() => {
    this.observer = new ResizeObserver((entries) => {
      const { inlineSize } = entries[0].contentBoxSize[0];
      // Re-enter the zone ONLY when a render-worthy change happens
      if (Math.round(inlineSize) !== this.lastWidth) {
        this.lastWidth = Math.round(inlineSize);
        this.zone.run(() => (this.width = this.lastWidth));
      }
    });
    this.observer.observe(this.el.nativeElement);
  });
}

Cross-Framework Compatibility & SSR Strategy

The single most common production error in this space is ReferenceError: IntersectionObserver is not defined, thrown when component code touches the API during server rendering. The fix is structural, not a patch: keep every observer instantiation inside a client-only lifecycle hook, feature-detect before constructing, and render a deterministic default so the hydrated client markup matches the server output.

Framework Server-safe boundary SSR default to render
React (Next.js) useEffect / dynamic(..., { ssr: false }) element visible, then enhance
Vue (Nuxt) onMounted / <ClientOnly> element visible, then enhance
Angular (Universal) afterNextRender / isPlatformBrowser guard element visible, then enhance

The SSR & Hydration Observer Safety guide details each boundary, and the polyfill and feature-detection matrix lives in Browser Compatibility & Polyfills.

Debugging & Profiling Workflow

  • Confirm the ref is attached. Log the element inside the post-mount hook. A null ref means you are observing during render or the ref was never bound to a DOM node.
  • Count observations. Add a temporary counter in the callback. More invocations than mounted components points to a missing teardown or a StrictMode double-mount — see fixing doubled observer callbacks in React Strict Mode.
  • Watch the heap across route changes. Navigate away and back 10–20 times, force garbage collection in the DevTools Memory panel, and confirm detached observer instances do not accumulate.
  • Profile change detection (Angular). If the Performance panel shows change-detection frames firing on scroll, the observer callback is running inside the zone; move it out with runOutsideAngular.
  • Verify SSR output. View source (not the hydrated DOM) and confirm the server rendered the default state without throwing.

Accessibility & Progressive Enhancement

Framework adapters must preserve the accessibility contract of the underlying markup. Content revealed by an IntersectionObserver-driven hook should be present in document order and, when injected, announced with aria-live="polite". Observer-triggered animations must respect prefers-reduced-motion — apply the final state immediately when reduced motion is requested. Because SSR renders the visible-by-default state, the base experience works with JavaScript disabled or before hydration, and the observer only enhances it. Never gate essential content behind an observer callback that may never fire in an unsupported environment.

Frequently Asked Questions

Where should I instantiate an observer in a component?

Instantiate observers inside the lifecycle hook that runs after the DOM node exists on the client: useEffect in React, onMounted in Vue, ngAfterViewInit or a directive's ngOnInit in Angular. Never instantiate at module scope or during render — the ref is not attached yet and, under server-side rendering, the browser API does not exist.

Do I need one observer per component instance?

Not necessarily. One shared observer can watch many elements, and the browser batches all their entries into a single callback. For large lists a shared observer keyed by a WeakMap is more memory-efficient than one observer per row. For a single tracked element per component, a per-instance observer is simplest and disconnects cleanly on unmount.

Why does my observer callback fire twice in React development?

React 18 StrictMode intentionally mounts, unmounts, and remounts every component once in development to surface missing cleanup. If your effect observes without a matching disconnect() in the cleanup return, the remount stacks a second observation. Return a cleanup function that calls disconnect(), and the double-invocation becomes harmless.

How do I stop observer callbacks from triggering Angular change detection on every frame?

Create the observer inside NgZone.runOutsideAngular so its callbacks do not schedule change detection. When you have a state change worth rendering, re-enter the zone with ngZone.run() only for that update. This keeps high-frequency intersection and resize notifications off Angular's change-detection cycle.

Are observer wrappers safe under server-side rendering?

Only if you guard them. IntersectionObserver, ResizeObserver, and window do not exist in Node.js, so any code path that runs on the server must avoid touching them. Keep instantiation inside client-only lifecycle hooks, feature-detect with typeof window !== 'undefined', and render a sensible default (usually visible) so hydration matches the server markup.