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-forupdates), it unobserves the old element and observes the new one. - Creates one
IntersectionObserverper call, rebuilt when reactive options such asrootorrootMarginchange. - Guards SSR with an
isSupportedcheck and does nothing whenIntersectionObserveris 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.
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
<!-- 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:
// 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.
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
rootMarginchanges. 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; callpause/detachinonDeactivatedand re-attach inonActivatedif 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.
Verification Steps
- Count observers on the heaviest page with each approach (heap snapshot filtered by
IntersectionObserver). - Toggle
v-ifon 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
KeepAlivepauses observation. HandleonDeactivatedexplicitly.
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.
Related
- Vue IntersectionObserver Directive: v-intersect — declarative per-element behaviour
- Writing a useResizeObserver Composable for Vue 3 — the resize counterpart
- Shared Observer Pooling — why sharing pays off at scale
↑ Back to Vue Observer Composables