A Svelte IntersectionObserver action is a function that receives the element and options, registers the element with a shared observer, reports changes through a callback or custom event, and returns update (to react to option changes) and destroy (to unobserve) — Svelte calls both at exactly the right times.
Problem / Scenario Context
A Svelte storefront lazy-loads product images and fades in category banners. The first implementation put a new IntersectionObserver() inside each component's onMount, observed a bind:this element, and disconnected in onDestroy. It works, but the product grid mounts 60 components per page — 60 observers — and components inside {#each} blocks sometimes observe the wrong element after the list re-sorts, because bind:this pointed at a node reused for a different product.
Svelte's action mechanism was designed for exactly this: behaviour attached to one DOM node, created and destroyed with it. The Svelte Actions & Solid Primitives topic explains the model; this page builds the production version.
Mechanics Explanation
An action is applied with use:name={params}. Svelte calls the action function once, after the element is created and inserted, with the node and the current params. The function may return an object with two optional methods:
update(params)— called whenever the params expression produces a new value. Svelte does not deep-compare; any re-evaluation that yields a new object triggers it.destroy()— called when the element is removed from the DOM, including when an{#if}turns false, an{#each}item is removed, or the component unmounts.
Because actions run only in the browser, the action body can use IntersectionObserver freely; nothing runs during server-side rendering. And because the action is bound to the node rather than to a component variable, list reordering with keyed each blocks moves the node with its action intact.
Comparison Table: onMount Versus an Action
| Concern | Observer in onMount + bind:this |
use:inView action |
|---|---|---|
| Bound to | component instance | the specific element |
Elements inside {#if} inside the component |
must handle manually | automatic create/destroy |
Reused nodes in unkeyed {#each} |
may observe the wrong item | node and action move together when keyed |
| Instances per 60 components | 60 unless pooled | 1 shared |
| SSR safety | onMount is browser-only |
actions are browser-only |
| Reusability | copy per component | one import |
Minimal Reproducible Example
<script>
import { onMount, onDestroy } from 'svelte';
export let product;
let img;
let io;
onMount(() => {
io = new IntersectionObserver(([e]) => { if (e.isIntersecting) img.src = product.image; });
io.observe(img); // one observer per component
});
onDestroy(() => io?.disconnect());
</script>
<img bind:this={img} alt={product.name} width="400" height="400">
Sixty products create sixty observers, and in an unkeyed list the img bound at mount can later display a different product than the closure's product.
Production-Safe Solution
// actions/in-view.ts
import type { ActionReturn } from 'svelte/action';
export interface InViewParams {
rootMargin?: string;
threshold?: number;
once?: boolean;
onchange?: (visible: boolean, entry: IntersectionObserverEntry) => void;
}
interface InViewAttributes {
'on:enter'?: (e: CustomEvent<IntersectionObserverEntry>) => void; // Svelte 4 style
onenter?: (e: CustomEvent<IntersectionObserverEntry>) => void; // Svelte 5 style
}
// Shared observers, one per option set.
type Handler = (e: IntersectionObserverEntry) => void;
const shared = new Map<string, { io: IntersectionObserver; handlers: Map<Element, Handler> }>();
function register(node: Element, handler: Handler, rootMargin: string, threshold: number): () => void {
const key = `${rootMargin}|${threshold}`;
let s = shared.get(key);
if (!s) {
const handlers = new Map<Element, Handler>();
s = { handlers, io: new IntersectionObserver((es) => es.forEach((e) => handlers.get(e.target)?.(e)),
{ rootMargin, threshold }) };
shared.set(key, s);
}
s.handlers.set(node, handler);
s.io.observe(node);
return () => {
s!.io.unobserve(node);
s!.handlers.delete(node);
if (s!.handlers.size === 0) { s!.io.disconnect(); shared.delete(key); }
};
}
export function inView(
node: HTMLElement,
params: InViewParams = {},
): ActionReturn<InViewParams, InViewAttributes> {
let p = params;
let unregister = () => {};
const start = () => {
unregister = register(node, (entry) => {
p.onchange?.(entry.isIntersecting, entry);
if (entry.isIntersecting) {
node.dispatchEvent(new CustomEvent('enter', { detail: entry }));
if (p.once) unregister();
}
}, p.rootMargin ?? '0px', p.threshold ?? 0);
};
start();
return {
update(next) {
const rebuild = next.rootMargin !== p.rootMargin || next.threshold !== p.threshold;
p = next; // always take the latest callbacks
if (rebuild) { unregister(); start(); } // but only re-observe when geometry options change
},
destroy() { unregister(); },
};
}
<!-- Svelte 5 usage -->
<script lang="ts">
import { inView } from '$lib/actions/in-view';
let { product } = $props();
let src = $state('');
</script>
<img use:inView={ { rootMargin: '400px', once: true } }
onenter={() => (src = product.image)}
{src} alt={product.name} width="400" height="400">
The once option unregisters after the first entry, so a loaded image drops out of the observer entirely. Because update always stores the latest params but only re-observes when rootMargin or threshold change, passing a fresh inline object on every render costs nothing. The ActionReturn generic types the onenter / on:enter attribute so editors autocomplete it.
Typing Events for Svelte 4 and 5
Svelte 4 listens for custom events with on:enter; Svelte 5 prefers event attributes like onenter. The action above dispatches a DOM CustomEvent, which works in both, and the InViewAttributes interface teaches the type checker about both spellings. In Svelte 5 projects that do not need Svelte 4 compatibility, the onchange callback parameter is simpler still, because it avoids the event object and passes typed values directly.
Keep the event name distinctive. A generic name like change or visible risks colliding with native events or other actions on the same element.
Edge Cases
Unkeyed each blocks. Without a key, Svelte reuses DOM nodes by position when the list changes, and the action is not destroyed — only update is called with new params. If the params include the item (for example, { id: product.id }), handle the change in update; better, key the block by id so nodes follow their items.
Outro transitions. An element leaving with transition:fade stays in the DOM until the transition ends, and destroy runs afterwards. A once observer has usually already unregistered; a continuous one may deliver a final "not visible" entry during the outro, which is harmless if the callback only touches the element.
Hidden elements. An element inside a collapsed <details> or with display: none never intersects. If a lazy image inside a closed accordion must load when opened, the observer will handle it at open time — as long as the element has a non-zero size once visible.
SvelteKit navigation. Page components are destroyed and created on navigation, so actions tear down correctly. Shared observers survive between pages only while they still have targets, so no global cleanup is needed.
Verification Steps
- Count observer instances in a heap snapshot on the product grid: one per option set.
- Reorder the list (sort by price) and confirm each image still loads its own product.
- Toggle a filter that removes items and check the shared handler map shrinks.
- Run
svelte-checkto confirm the action's parameters and events are typed. - Navigate away and back several times and confirm the shared map returns to empty between pages that do not use the action.
Common Mistakes to Avoid
- Creating a new observer in every action call. The action runs per element; share the instance.
- Rebuilding in
updateunconditionally. Inline params produce new objects on every render. - Relying on
bind:thisin lists. Bindings are per component, not per item; use the node the action receives. - Forgetting
destroy. Without it, removed elements stay observed and retained.
FAQ
When does Svelte call an action's update function?
Whenever the expression passed as the action's parameter is re-evaluated to a new value, which for an inline object literal is on every component update that re-runs that part of the template. Compare the fields you care about before doing work.
Should the action dispatch events or call a callback?
Either works. Events match Svelte 4 conventions and let several listeners react; a callback parameter is simpler and better typed in Svelte 5. Supporting both, as above, costs little.
Do actions work on components?
No. Actions apply to DOM elements only. To observe a child component, apply the action to an element inside it or to a wrapper element around it.
How do I use a scroll container as the root?
Add a root parameter that takes an element and include it in the shared observer's key. The root element must exist when the action runs, so bind it in the parent and pass it down after mount.
Should I migrate this action to a Svelte 5 attachment?
Attachments are a good fit for new code because they compose and re-run on reactive changes. Existing actions keep working in Svelte 5, so there is no urgency; migrate when you are touching the code anyway.
Related
- Svelte 5 Runes and ResizeObserver State — the size-tracking counterpart
- Cleaning Up Observers in Svelte and Solid Lifecycles — teardown in detail
- Building a Lazy Image Loader with IntersectionObserver — the loader logic itself