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.

Vue 3 Observer Composable Lifecycle A five-stage flow diagram: setup() creates a ref and reactive state; onMounted reads the populated template ref and creates the native Observer; the Observer callback fires and writes into reactive refs; Vue's reactivity system schedules a re-render reading those refs in the template; onUnmounted calls disconnect() to tear down the native observer. setup() ref(null) target onMounted ref populated new ResizeObserver .observe(el.value) callback fires width.value = w template re-render reads width.value onUnmounted disconnect() Reactivity bridges the imperative Observer callback and Vue's dependency-tracked refs. Teardown is explicit — Vue never calls disconnect() for you.

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

TypeScript
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

TypeScript
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

HTML
<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

TypeScript
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

TypeScript
// 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:

TypeScript
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:

TypeScript
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>:

HTML
<template>
  <ClientOnly>
    <div ref="target">{{ width }}px wide</div>
    <template #fallback>
      <div class="skeleton" />
    </template>
  </ClientOnly>
</template>

Pinia store integration

TypeScript
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

TypeScript
// vitest.setup.ts
class MockResizeObserver {
  observe = vi.fn();
  unobserve = vi.fn();
  disconnect = vi.fn();
}
vi.stubGlobal('ResizeObserver', MockResizeObserver);

Debugging Checklist

  • width/height stay 0 forever — confirm the target ref is actually populated (console.log(target.value) inside onMounted), and confirm the element has non-zero rendered dimensions before observe() is called.
  • Observer never disconnects — verify onUnmounted (or tryOnScopeDispose) is registered synchronously inside the composable's top-level body, not nested inside an async function or a conditional — Vue requires lifecycle hooks to be registered during the synchronous setup() execution.
  • Duplicate callbacks after route change — check whether keep-alive is caching the component; cached components do not unmount, so onUnmounted never fires. Use onDeactivated/onActivated instead when <KeepAlive> wraps the route.
  • ReferenceError: ResizeObserver is not defined during Nuxt build — the composable is being called or evaluated outside onMounted at module scope. Move any top-level new ResizeObserver(...) call into a lifecycle hook.
  • Reactivity not triggering template updates — ensure you are mutating .value on a ref, not reassigning a plain destructured variable from reactive(), 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().


↑ Back to Framework Integration & Observer Adapters