Vue 3's Composition API turns ResizeObserver and IntersectionObserver into small, typed composables that any component can call — no mixins, no manual wiring of lifecycle hooks in every file that needs dimension or visibility tracking. This guide builds production-grade useResizeObserver and useIntersectionObserver composables from scratch, wires them to <script setup> template refs, and covers the Nuxt SSR guards and shared-observer patterns that production codebases need, building on the general lifecycle discipline from Core Observer Fundamentals & Browser APIs.
Concept Framing
A Vue composable is a plain function that calls other Composition API functions (ref, onMounted, onUnmounted, watch) and returns reactive state. For observer wrapping, the composable's job is to bridge two different reactivity models: the browser's imperative, callback-driven Observer APIs on one side, and Vue's reactive ref/reactive proxies on the other. Every callback entry the browser delivers gets copied into a ref, and Vue's dependency tracking takes care of re-rendering any template or computed value that reads it.
This bridging pattern is the same one used for React observer hooks and Angular directives, but Vue's explicit lifecycle hooks (onMounted, onUnmounted) make the mount/unmount boundary easier to reason about than React's effect dependency array. There is no Strict Mode double-invocation to guard against — a Vue component's setup() runs exactly once per instance, and onMounted fires exactly once after the first render.
The diagram below traces the full lifecycle: template ref population, observer instantiation, the reactive update path, and teardown.
Spec / Signature Reference Table
The table below lists the Composition API primitives a composable needs and how each maps onto the observer lifecycle.
| Composition API primitive | Role in the composable | Runs on server (Nuxt SSR)? |
|---|---|---|
ref<HTMLElement | null>(null) |
Template ref bound with ref="target" in the template |
Yes (stays null) |
ref<number>(0) / ref<boolean>(false) |
Reactive output state (width, height, isVisible) | Yes |
onMounted(fn) |
Runs once, client-side only, after the DOM is attached | No |
onUnmounted(fn) |
Runs once on component teardown | No |
tryOnScopeDispose(fn) (VueUse) |
Runs on effectScope disposal even outside a component |
No |
watchEffect(fn) |
Re-runs when a reactive dependency (e.g. a swapped target ref) changes | Depends |
getCurrentScope() (VueUse) |
Detects whether a composable runs inside an active effect scope | Yes |
Step-by-Step Implementation
Step 1 — Declare reactive state and the template ref
import { ref, onMounted, onUnmounted, type Ref } from 'vue';
interface ResizeObserverResult {
target: Ref<HTMLElement | null>;
width: Ref<number>;
height: Ref<number>;
}
The target ref is bound in the template with <div ref="target">; Vue populates it automatically once the element mounts. width and height start at 0 and only change once the browser delivers its first entry.
Step 2 — Create the observer inside onMounted
export function useResizeObserver(
box: ResizeObserverBoxOptions = 'content-box'
): ResizeObserverResult {
const target = ref<HTMLElement | null>(null);
const width = ref(0);
const height = ref(0);
let observer: ResizeObserver | null = null;
onMounted(() => {
// SSR guard — ResizeObserver does not exist during Nuxt's server render
if (typeof ResizeObserver === 'undefined' || !target.value) return;
observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
const entry = entries[0];
const size = box === 'border-box'
? entry.borderBoxSize?.[0]
: entry.contentBoxSize?.[0];
if (size) {
width.value = size.inlineSize;
height.value = size.blockSize;
} else {
// Safari < 15.4 fallback — no boxSize arrays
width.value = entry.contentRect.width;
height.value = entry.contentRect.height;
}
});
observer.observe(target.value, { box });
});
onUnmounted(() => observer?.disconnect());
return { target, width, height };
}
Because onMounted never executes during server-side rendering, wrapping the constructor call inside it is often sufficient SSR protection on its own — but the explicit typeof ResizeObserver === 'undefined' check also guards test environments (Vitest with jsdom) that stub lifecycle hooks without providing the native constructor.
Step 3 — <script setup> consumption
<script setup lang="ts">
import { useResizeObserver } from './composables/useResizeObserver';
const { target, width, height } = useResizeObserver();
</script>
<template>
<div ref="target" class="chart-container">
Width: {{ width }}px · Height: {{ height }}px
</div>
</template>
<script setup> automatically exposes target to the template's ref binding — no explicit return statement is needed inside a single-file component, unlike composables consumed from a plain setup() function.
Step 4 — Building useIntersectionObserver alongside it
import { ref, onMounted, onUnmounted, type Ref } from 'vue';
interface IntersectionResult {
target: Ref<Element | null>;
isVisible: Ref<boolean>;
ratio: Ref<number>;
}
export function useIntersectionObserver(
options: IntersectionObserverInit = { threshold: 0.1 }
): IntersectionResult {
const target = ref<Element | null>(null);
const isVisible = ref(false);
const ratio = ref(0);
let observer: IntersectionObserver | null = null;
onMounted(() => {
if (typeof IntersectionObserver === 'undefined' || !target.value) return;
observer = new IntersectionObserver(([entry]) => {
isVisible.value = entry.isIntersecting;
ratio.value = entry.intersectionRatio;
}, options);
observer.observe(target.value);
});
onUnmounted(() => observer?.disconnect());
return { target, isVisible, ratio };
}
This composable follows the same IntersectionObserverEntry shape documented in the IntersectionObserver API Deep Dive, so any threshold-array or rootMargin strategy from that reference applies here unchanged.
Configuration Variants
| Variant | When to use | Trade-off |
|---|---|---|
| Per-component observer (shown above) | Small numbers of tracked elements (a handful of charts, one hero section) | Simple, but one native observer per component instance |
Shared module-level observer with a Map<Element, callback> |
Long lists — table rows, card grids, virtualized feeds | One native observer total, but requires a registry and slightly more composable code |
box: 'device-pixel-content-box' |
Canvas/WebGL surfaces needing physical pixel accuracy | Not supported in older Safari; fall back to content-box and multiply by devicePixelRatio |
threshold: [0, 0.25, 0.5, 0.75, 1] for useIntersectionObserver |
Progress-style visibility tracking (reading progress, ad viewability) | More callback invocations per scroll — acceptable since Vue batches DOM updates via its own scheduler |
once: true option (unobserve after first match) |
Lazy image loading, one-shot entry animations | Composable must expose an internal unobserve path, shown in the shared-observer pattern below |
Shared observer pattern for lists
// shared-resize-observer.ts — one native observer, many composable callers
const callbacks = new Map<Element, (entry: ResizeObserverEntry) => void>();
let sharedObserver: ResizeObserver | null = null;
function getSharedObserver(): ResizeObserver | null {
if (typeof ResizeObserver === 'undefined') return null;
if (!sharedObserver) {
sharedObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
callbacks.get(entry.target)?.(entry);
}
});
}
return sharedObserver;
}
export function useSharedResizeObserver(): { target: Ref<HTMLElement | null>; width: Ref<number> } {
const target = ref<HTMLElement | null>(null);
const width = ref(0);
onMounted(() => {
const observer = getSharedObserver();
if (!observer || !target.value) return;
callbacks.set(target.value, (entry) => {
width.value = entry.contentRect.width;
});
observer.observe(target.value);
});
onUnmounted(() => {
if (target.value) {
sharedObserver?.unobserve(target.value);
callbacks.delete(target.value);
}
});
return { target, width };
}
Each row in a virtualized list calls useSharedResizeObserver() independently, but only one native ResizeObserver instance ever exists — the browser batches all entries from every row into a single callback invocation per frame, matching the batching guidance in detecting container queries with ResizeObserver.
Edge Cases & Gotchas
Template ref is null inside setup()'s top level
Reading target.value synchronously at the top of setup() (outside onMounted) always returns null — Vue has not yet rendered the template and attached the ref. Any composable that tries to call observer.observe(target.value) before onMounted will silently no-op or throw. Always defer observer creation to onMounted or a watchEffect that explicitly reads target.value.
Swapping the observed element with v-if
If the observed element is conditionally rendered (v-if) and later toggled, the template ref becomes null again when the element unmounts, and a new DOM node is attached when it reappears — the old observer is still watching a detached node. Use a watch(target, (newEl, oldEl) => { ... }) to unobserve the old element and observe the new one:
import { watch } from 'vue';
watch(target, (newEl, oldEl) => {
if (oldEl) observer?.unobserve(oldEl);
if (newEl) observer?.observe(newEl);
});
effectScope composables outside components
Composables called inside a Pinia store, a plain effectScope(), or another composable chain (not directly inside a component's setup()) will not have onUnmounted available — Vue emits a runtime warning because there is no active component instance to attach the hook to. Use VueUse's tryOnScopeDispose, which detects the current effect scope and falls back gracefully:
import { tryOnScopeDispose } from '@vueuse/core';
tryOnScopeDispose(() => observer?.disconnect());
Reactive refs update, but children built with v-memo don't re-render
If a child component wraps its render in v-memo with a dependency array that does not include the composable's width or height ref, Vue skips the re-render even though the ref changed. Always include every observer-derived ref your template reads inside the v-memo dependency array.
Nuxt hydration mismatch from conditional rendering
If a component's template branches on isVisible.value (e.g., <div v-if="isVisible">...</div>) and that branch differs between the server-rendered false default and the client's first paint, Vue logs a hydration mismatch warning. Keep the server-rendered markup identical to the client's pre-observer state, and only toggle classes or styles based on isVisible rather than the DOM structure itself, mirroring the SSR & Hydration Observer Safety guidance.
Framework Integration Patterns
Nuxt 3 — <ClientOnly> and auto-imported composables
Nuxt auto-imports files under composables/, so useResizeObserver.ts becomes globally available without an explicit import. For components that render meaningfully different markup pre- and post-hydration, wrap them in <ClientOnly>:
<template>
<ClientOnly>
<div ref="target">{{ width }}px wide</div>
<template #fallback>
<div class="skeleton" />
</template>
</ClientOnly>
</template>
Pinia store integration
import { defineStore } from 'pinia';
import { useResizeObserver } from '~/composables/useResizeObserver';
export const useViewportStore = defineStore('viewport', () => {
const { target, width, height } = useResizeObserver();
return { target, width, height };
});
Because Pinia setup stores run inside an effectScope, prefer tryOnScopeDispose over onUnmounted inside the composable if the store might be instantiated outside a component tree (e.g., in a plugin).
Vitest unit test mock
// vitest.setup.ts
class MockResizeObserver {
observe = vi.fn();
unobserve = vi.fn();
disconnect = vi.fn();
}
vi.stubGlobal('ResizeObserver', MockResizeObserver);
Debugging Checklist
width/heightstay0forever — confirm the target ref is actually populated (console.log(target.value)insideonMounted), and confirm the element has non-zero rendered dimensions beforeobserve()is called.- Observer never disconnects — verify
onUnmounted(ortryOnScopeDispose) is registered synchronously inside the composable's top-level body, not nested inside anasyncfunction or a conditional — Vue requires lifecycle hooks to be registered during the synchronoussetup()execution. - Duplicate callbacks after route change — check whether
keep-aliveis caching the component; cached components do not unmount, soonUnmountednever fires. UseonDeactivated/onActivatedinstead when<KeepAlive>wraps the route. ReferenceError: ResizeObserver is not definedduring Nuxt build — the composable is being called or evaluated outsideonMountedat module scope. Move any top-levelnew ResizeObserver(...)call into a lifecycle hook.- Reactivity not triggering template updates — ensure you are mutating
.valueon aref, not reassigning a plain destructured variable fromreactive(), which loses reactivity when destructured.
FAQ
Why does my Vue composable's observer never attach to the element?
Template refs are null until after the component's first render, so creating the observer inside <script setup> at the top level runs before the ref is populated. Move observer creation into onMounted (or a watchEffect that reads the ref), where the DOM node is guaranteed to exist.
Should I use onUnmounted or tryOnScopeDispose for teardown?
Use onUnmounted when the composable is always called directly inside a component's setup() function. Use tryOnScopeDispose when the composable might also run inside a plain effectScope (outside a component, such as a Pinia store or a standalone composable chain) because onUnmounted throws a warning if there is no active component instance.
How do I make a Vue observer composable safe for Nuxt SSR?
Guard the observer constructor call, not just the ref access. Nuxt executes setup() on the server, where ResizeObserver and IntersectionObserver do not exist. Wrap instantiation in a typeof window !== 'undefined' check inside onMounted, since onMounted itself never runs during server-side rendering but the extra guard also protects against test environments that stub onMounted.
Can one composable instance share an observer across many components?
Yes, and it is recommended for lists. Create a single ResizeObserver or IntersectionObserver at a module or provide/inject level, then have each component's composable call observe(el) and register its own reactive callback in a Map keyed by element. This avoids creating one native observer instance per list row.
Why does the width ref stay at zero right after mounting?
ResizeObserver delivers its first entry asynchronously in a microtask after observe() is called, so the reactive ref is still at its initial value during the same synchronous tick as onMounted. This is expected — read the ref inside a watch or template binding, which will re-render automatically once the first entry arrives, rather than expecting a value immediately after calling observe().