Use VueUse's useIntersectionObserver (or the simpler useElementVisibility) by default — it handles reactive targets, SSR guards and cleanup correctly — and write your own composable only when you need one observer shared across many components, custom batching of entries, or behaviour VueUse's per-call model makes awkward.

Problem / Scenario Context

A team debates whether to adopt VueUse or keep a hand-written useVisible composable. The hand-written one has subtle bugs: it observes only the element present at mount (so v-if swaps are missed), it does not stop when the component is inside a <KeepAlive> that deactivates, and it throws during SSR in one code path. VueUse fixes all three. But the product grid, with 500 tiles each calling useElementVisibility, creates 500 observers, and a performance review flags it.

Both positions are right for different components. The Vue Observer Composables topic covers composable fundamentals; this page compares the library with custom code.

Mechanics Explanation

useIntersectionObserver(target, callback, options) from VueUse:

  • Accepts a reactive target — a template ref, a getter, or an array of them — and watches it, so when the element changes (conditional rendering, v-for updates), it unobserves the old element and observes the new one.
  • Creates one IntersectionObserver per call, rebuilt when reactive options such as root or rootMargin change.
  • Guards SSR with an isSupported check and does nothing when IntersectionObserver is unavailable.
  • Cleans up automatically when the owning effect scope (usually the component) is disposed, and returns { isActive, pause, resume, stop } for manual control.

useElementVisibility(target, options) wraps it and returns a boolean ref.

The per-call model is the right default: it is simple, isolated and correct. Its cost scales with the number of calls. For a handful of components that cost is invisible; for hundreds of list items it adds construction time, closures and per-observer delivery — the costs quantified in one observer vs many.

VueUse Composable Versus Shared Hand-Rolled ComposableTwo columns. VueUse useIntersectionObserver watches reactive targets, guards SSR, cleans up with the effect scope, offers pause and resume, and creates one observer per call. A shared hand-rolled composable uses one observer per option set for the whole app, can batch entries across components, and requires you to implement reactive targets, SSR guards and cleanup correctly yourself.VueUse useIntersectionObserverReactive targets, arrays of targetsSSR guard, scope-based cleanuppause / resume / stop controlsOne observer per callShared hand-rolled composableOne observer per option set, app-wideCan batch entries across componentsYou own reactivity, SSR and cleanup

Comparison Table: Choosing per Use Case

Use case Instances Recommendation
A few sections, reveal animations < 20 VueUse useElementVisibility
Infinite scroll sentinel 1 VueUse useIntersectionObserver or useInfiniteScroll
Carousel with a custom root a few VueUse with root option
Product grid, hundreds of tiles 100s shared composable or directive
Tiles in a v-for without per-component state 100s directive (v-intersect)
Analytics with batching and dwell time 100s shared composable with custom batching

Minimal Reproducible Example

VUE
<!-- ProductTile.vue, rendered 500 times -->
<script setup lang="ts">
import { ref } from 'vue';
import { useElementVisibility } from '@vueuse/core';
const el = ref<HTMLElement | null>(null);
const visible = useElementVisibility(el);           // one observer per tile
</script>

<template><div ref="el" :class="{ visible }"><slot /></div></template>

Correct, clean — and 500 IntersectionObserver instances on the grid page.

Production-Safe Solution

Keep VueUse where instance counts are low, and use a shared composable with the same ergonomics for high-count components:

TypeScript
// composables/useSharedVisibility.ts
import { onScopeDispose, ref, shallowRef, watch, type MaybeRefOrGetter, toValue } from 'vue';

type Cb = (e: IntersectionObserverEntry) => void;
const pools = new Map<string, { io: IntersectionObserver; cbs: Map<Element, Set<Cb>> }>();

function getPool(rootMargin: string, threshold: number) {
  const key = `${rootMargin}|${threshold}`;
  let p = pools.get(key);
  if (!p) {
    const cbs = new Map<Element, Set<Cb>>();
    const io = new IntersectionObserver((entries) => {
      for (const e of entries) cbs.get(e.target)?.forEach((cb) => cb(e));
    }, { rootMargin, threshold });
    p = { io, cbs };
    pools.set(key, p);
  }
  return p;
}

export function useSharedVisibility(
  target: MaybeRefOrGetter<Element | null | undefined>,
  { rootMargin = '0px', threshold = 0 }: { rootMargin?: string; threshold?: number } = {},
) {
  const visible = ref(false);
  const entry = shallowRef<IntersectionObserverEntry>();
  if (typeof IntersectionObserver === 'undefined') return { visible, entry };   // SSR / unsupported

  const pool = getPool(rootMargin, threshold);
  const cb: Cb = (e) => { entry.value = e; visible.value = e.isIntersecting; };
  let current: Element | null = null;

  const detach = () => {
    if (!current) return;
    const set = pool.cbs.get(current);
    set?.delete(cb);
    if (set && set.size === 0) { pool.cbs.delete(current); pool.io.unobserve(current); }
    current = null;
  };

  // Reactive target, like VueUse: follow element swaps.
  watch(() => toValue(target), (el) => {
    detach();
    if (!el) return;
    current = el;
    let set = pool.cbs.get(el);
    if (!set) { set = new Set(); pool.cbs.set(el, set); pool.io.observe(el); }
    set.add(cb);
  }, { immediate: true, flush: 'post' });

  onScopeDispose(detach);
  return { visible, entry };
}

The composable mirrors VueUse's reactive-target behaviour (watch with flush: 'post' so template refs are populated) and scope-based cleanup, but all callers with the same options share one observer. Swapping useElementVisibility(el) for useSharedVisibility(el) in the tile is a one-line change.

Reactive Target With a Shared PoolFour boxes. The composable watches the target ref with flush post so the element is available after render. When the element changes, the callback is detached from the old element and attached to the new one in the shared pool. The pool's single observer delivers entries to each element's callbacks. When the component's effect scope is disposed, the callback is detached and the element unobserved if no one else uses it.watch(target, post)element after renderDetach / attachmove cb betweenelementsShared observerone per option setonScopeDisposedetach, maybeunobserve

Behaviour Differences to Know

Moving between VueUse and a custom composable is mostly mechanical, but a few behaviours differ:

  • Pause/resume: VueUse's pause() disconnects that call's observer. In a shared pool, "pause" means detaching the element's callback, not disconnecting the shared observer.
  • Reactive options: VueUse rebuilds its observer when a reactive rootMargin changes. A shared pool keys by options, so a component whose options change must move to a different pool — detach and re-attach with the new key.
  • KeepAlive: both clean up only when the scope is disposed. Deactivated-but-cached components remain observed; call pause/detach in onDeactivated and re-attach in onActivated if hidden components should not receive entries.
  • Arrays of targets: VueUse accepts arrays; a shared composable can too, but for many elements a directive is usually cleaner.

Behaviour Differences Between the TwoA grid of behaviours for VueUse and a shared composable. Both follow reactive targets and clean up on scope disposal. VueUse pause disconnects its own observer, while the shared version detaches the element. VueUse rebuilds on reactive option changes, while the shared version must move the element to another pool. Neither pauses automatically for KeepAlive deactivation.VueUseShared composableReactive targetfollows swapsfollows swapsScope cleanupautomaticautomaticPausedisconnects its observerdetaches the elementReactive optionsrebuilds observermove to another poolKeepAlive deactivationmanualmanual

Verification Steps

  • Count observers on the heaviest page with each approach (heap snapshot filtered by IntersectionObserver).
  • Toggle v-if on an observed element and confirm the new element is observed.
  • Navigate away and confirm pools unobserve elements and remain empty.
  • Run SSR and confirm neither approach touches the API on the server.
  • Compare mount times of the product grid before and after switching tiles to the shared composable.

Common Mistakes to Avoid

  • Rewriting VueUse for low-count components. The library is correct and simpler.
  • Using a per-call observer in a 500-item grid. Share by options.
  • Watching the target without flush: 'post'. Template refs are not set yet.
  • Assuming KeepAlive pauses observation. Handle onDeactivated explicitly.

FAQ

Does VueUse share observers between components?

No. Each useIntersectionObserver call creates its own observer. That keeps calls independent, at the cost of more instances when used in long lists.

Is useElementVisibility just a wrapper?

Yes. It calls useIntersectionObserver and exposes the latest isIntersecting value as a ref, with options for root, margin and threshold.

Why flush: 'post' in the watcher?

Template refs are assigned when the DOM is patched. A post-flush watcher runs after the patch, so the element is available on first run and after conditional swaps.

How many observers is too many?

There is no hard limit. Costs grow roughly linearly with count; below a few dozen they are negligible, while hundreds show up in mount time and memory on mid-range phones.

Can I mix both approaches in one app?

Yes, and it is common: VueUse for one-off sections and sentinels, a shared composable or directive for high-volume list items.

What does useInfiniteScroll add over useIntersectionObserver?

VueUse's useInfiniteScroll works from scroll events and distance thresholds rather than a sentinel observer, and handles loading state and direction for you. A sentinel with useIntersectionObserver gives you more control over margins and roots; either is fine for one list.

How do I migrate a component from VueUse to the shared composable?

Replace the import and the call, keeping the same template ref as the target. The returned visible ref behaves the same. Check any use of pause, resume or reactive options, which behave differently in a shared pool.

Does the shared composable work with Nuxt?

Yes. The typeof guard keeps it inert on the server, and watchers with flush post run only on the client after hydration.


↑ Back to Vue Observer Composables