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.

Action Lifecycle Around One ElementA timeline for one element. The component renders and the node is inserted. The action runs and registers the node with the shared observer. The params change twice; the first change is irrelevant and update returns early, the second changes rootMargin and re-registers. The element is removed and destroy unregisters it.One element's lifeelementinsertedremovedactionaction(node)update, sameupdate, rebuilddestroy()0time20time40time60time80time100time

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

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

TypeScript
// 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(); },
  };
}
HTML
<!-- 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.

Sixty Components, One ObserverFour boxes. Sixty product components each apply the use:inView action to their image. Each action registers its node in a shared handler map keyed by options. One IntersectionObserver instance watches all sixty nodes. Each entry is routed back to the right node's handler, which dispatches an enter event.60 componentseach uses use:inViewHandler mapnode → handler, peroption set1 observerwatches all 60 nodesRouted entryenter event on theright node

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.

Event or Callback From the ActionTwo columns. Dispatching a custom enter event works in Svelte 4 and 5, lets several listeners react, and needs an attribute type declaration for editors. Passing an onchange callback in the parameters is simpler, fully typed and Svelte 5 friendly, but supports a single consumer per element.Dispatch a CustomEventWorks with on:enter and onenterSeveral listeners can reactNeeds ActionReturn attribute typingonchange callback parameterPlain function; typed values, no event objectNatural in Svelte 5 props styleOne consumer per element

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-check to 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 update unconditionally. Inline params produce new objects on every render.
  • Relying on bind:this in 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.


↑ Back to Svelte Actions & Solid Primitives for Observers