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:
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.
// 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
},
};
}
// 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.
// 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
nullref 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.
Attaching to an Element That Can Be Replaced
Every framework's standard recipe attaches an observer once, on mount, and cleans up on unmount. That is correct for a component whose root element never changes, and quietly wrong for one whose element does — and elements are replaced far more often than people expect.
A React component that renders <article> normally and <section> when featured replaces its DOM node without unmounting the component. A list item whose key changes is a new element in the same component position. A conditional render that toggles a wrapper off and on produces a fresh node each time. In every one of those cases a mount effect with an empty dependency array does not re-run, so the observer carries on watching a node that is no longer in the document, and the replacement is never registered. Nothing throws; the feature simply stops working on one code path.
The general fix is to attach to the element lifecycle rather than to the component lifecycle. React's callback refs express this directly: React invokes the function with the element when it attaches and with null when it detaches, which is exactly the observe/unobserve pair an observer needs, with no dependency array to reason about. Vue's equivalent is a watch on the template ref rather than an onMounted hook, since the ref moves to null when a v-if removes the element while the component stays alive. Angular's is a directive, which is destroyed with its host element by construction — which is why the directive form is the recommended shape there rather than a component-level observer.
The pattern generalises beyond observers. Any resource keyed to a specific DOM node — a chart instance, a media player, a third-party widget — has the same failure mode, and the same remedy.
Keeping Initial State Server-Safe
Server-rendered applications add a second constraint that has nothing to do with element identity: the first client render must produce the same markup the server produced, or hydration fails and the subtree is thrown away and re-rendered.
Observer-driven state is a common source of that failure, because the temptation is to seed it from a measurement. A reveal-on-scroll component that initialises its visible flag by comparing the element's bounding rectangle against the viewport height will render one class on the server, where no viewport exists, and a different class on the client — and React reports a mismatch on exactly the elements that were above the fold. The seeding was added to avoid a flash of unrevealed content and instead costs a re-render of the subtree it was protecting.
The rule that resolves it is short: initial state must be derivable without a browser. Anything from getBoundingClientRect, window.innerWidth, matchMedia, localStorage or the clock is unavailable during server rendering and therefore cannot seed a value. Start from the server-safe default, let the first observer callback move it, and remove the resulting flash in CSS rather than in state — a class added to the document element by a tiny inline script before first paint is outside every hydrated subtree and cannot cause a mismatch. The full treatment, including the useSyncExternalStore escape hatch for values that genuinely must differ, is in hydration mismatch from observer-driven class toggles.
Between them, these two rules cover the large majority of framework-specific observer bugs: attach to the element rather than the component, and never let the first render depend on something only a browser knows.
Testing Observer Code Inside a Framework
The framework layer adds two testing problems on top of the ones the APIs already have, and both are worth planning for rather than discovering.
The first is that the test runner has no layout engine, so neither IntersectionObserver nor ResizeObserver exists. A component that constructs one at module scope throws on import, before any test body runs, which is why the stub belongs in a setup file rather than in a beforeEach. The stub has to reproduce the parts of the contract the component actually depends on: inert construction, asynchronous delivery, per-instance target tracking, and a normalised options object. A double that fires synchronously from observe() will pass tests for code that reads state on the following line — code that fails in every real browser.
The second is that framework lifecycles interact with the stub in ways that look like bugs. React's Strict Mode runs effects twice in development, so two observer instances appear for a single mount; that is the runtime deliberately checking your cleanup, and a test asserting a single instance fails for the wrong reason. Vue components that create their observer inside a watch on a template ref produce no instance at all until the ref is populated, so a test that renders and immediately reaches for "the observer" finds none. Angular directives running outside the zone need an explicit tick() before change detection reflects anything the callback did.
The practical shape that survives all three is to assert on behaviour, never on configuration. That the image received a src, that the class was added, that the target was unobserved after a one-shot trigger, that disconnect ran on unmount. Those assertions are identical across frameworks, they do not break when someone reorders an options object, and they fail precisely when the feature is actually broken rather than when its wiring merely changed shape. The full setup, including the reusable double and the flush helpers, is in testing observers in JSDOM and real browsers.
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.