Svelte and Solid both skip the virtual DOM and give you the real element directly — through a Svelte action or a Solid ref and onCleanup — which makes wrapping an observer simpler than in React or Vue. The traps are different: reactive options that silently rebuild observers, updates that re-render more than intended, and SSR paths that run code the server cannot execute.

Concept Framing

The Framework Integration & Observer Adapters section covers React hooks, Vue composables and Angular directives. Svelte and Solid belong to a different family: compiled, fine-grained reactive frameworks where components run once and reactivity is attached to individual values rather than to whole component re-renders.

That changes the shape of an observer binding in three ways:

  • Lifecycle is per node, not per render. A Svelte action runs when its element is created and its destroy runs when the element is removed. A Solid ref callback runs once, and onCleanup runs when the owning scope is disposed. There is no "effect runs twice" problem as in React's Strict Mode, and no dependency array to get wrong.
  • Updates are surgical. Setting a signal or a $state value from an observer callback updates only the DOM that reads it. That makes it cheap to drive UI from visibility — but it also means a signal read inside the callback's setup can make the whole setup reactive and re-run it.
  • SSR runs component code once on the server. Actions and onMount do not run during SSR in Svelte; in Solid, refs and onMount do not run on the server either. That makes observer code naturally SSR-safe as long as it lives in those places — and broken as soon as it moves to the component's top level.

Svelte Actions and Solid Primitives Side by SideTwo columns. A Svelte action receives the node and options, returns an object with update and destroy, runs only in the browser, and is applied with the use directive. A Solid primitive receives the element through a ref, registers teardown with onCleanup, exposes state as signals, and also runs only in the browser when attached through refs or onMount.Svelte actionfunction (node, options) returns update and destroyApplied with use:inView={options}Never runs during SSRCommunicates by dispatching events or callingcallbacksSolid primitiveReceives the element through a ref callbackTeardown with onCleanup in the owning scopeExposes state as a signal accessorRefs and onMount never run on the server

Spec / Signature Reference Table

The framework APIs that observer bindings rely on:

Framework API Role in an observer binding
Svelte (all) use:action={params} attach the observer to a node
Svelte (all) action return { update(params), destroy() } react to option changes; tear down
Svelte 5 $state, $derived, $effect hold and react to visibility or size
Svelte 5 attachments {@attach fn} newer alternative to actions, re-run on dependency change
Solid ref={el => …} get the element once
Solid createSignal, createMemo hold visibility or size
Solid onCleanup(fn) disconnect when the scope is disposed
Solid onMount(fn) run browser-only setup after the element exists
Solid untrack(fn) read signals without subscribing

Step-by-Step Implementation

This walk-through builds the same "in view" binding for both frameworks, backed by one shared observer module so neither framework creates an observer per element.

Step 1: A framework-agnostic shared observer

TypeScript
// observe-shared.ts
type Handler = (entry: IntersectionObserverEntry) => void;
const pools = new Map<string, { io: IntersectionObserver; handlers: WeakMap<Element, Handler>; count: number }>();

export function observeInView(el: Element, handler: Handler, init: IntersectionObserverInit = {}): () => void {
  const key = `${init.rootMargin ?? '0px'}|${String(init.threshold ?? 0)}`;
  let pool = pools.get(key);
  if (!pool) {
    const handlers = new WeakMap<Element, Handler>();
    const io = new IntersectionObserver((entries) => {
      for (const e of entries) handlers.get(e.target)?.(e);
    }, init);
    pool = { io, handlers, count: 0 };
    pools.set(key, pool);
  }
  pool.handlers.set(el, handler);
  pool.io.observe(el);
  pool.count++;
  return () => {
    pool!.io.unobserve(el);
    pool!.handlers.delete(el);
    if (--pool!.count === 0) { pool!.io.disconnect(); pools.delete(key); }
  };
}

Pools are keyed by options, as in keying an observer pool by threshold and rootMargin.

Step 2: The Svelte action

TypeScript
// in-view.ts
import type { Action } from 'svelte/action';
import { observeInView } from './observe-shared';

interface InViewParams { rootMargin?: string; once?: boolean; onchange: (visible: boolean) => void }

export const inView: Action<HTMLElement, InViewParams> = (node, params) => {
  let current = params;
  let stop = observeInView(node, (e) => {
    current.onchange(e.isIntersecting);
    if (e.isIntersecting && current.once) stop();
  }, { rootMargin: current.rootMargin });

  return {
    update(next) {
      if (next.rootMargin !== current.rootMargin) {      // only rebuild when options change
        stop();
        stop = observeInView(node, (e) => next.onchange(e.isIntersecting), { rootMargin: next.rootMargin });
      }
      current = next;
    },
    destroy() { stop(); },
  };
};

Step 3: The Solid primitive

TypeScript
// createInView.ts
import { createSignal, onCleanup, type Accessor } from 'solid-js';
import { observeInView } from './observe-shared';

export function createInView(opts: { rootMargin?: string } = {}): [Accessor<boolean>, (el: Element) => void] {
  const [visible, setVisible] = createSignal(false);
  let stop: (() => void) | undefined;
  const ref = (el: Element) => {
    stop = observeInView(el, (e) => setVisible(e.isIntersecting), { rootMargin: opts.rootMargin });
  };
  onCleanup(() => stop?.());
  return [visible, ref];
}

Step 4: Use them

HTML
<!-- Svelte 5 -->
<script lang="ts">
  import { inView } from './in-view';
  let visible = $state(false);
</script>
<section use:inView={ { rootMargin: '200px', onchange: (v) => (visible = v) } } class:visible>…</section>
TSX
// Solid
const [visible, ref] = createInView({ rootMargin: '200px' });
return <section ref={ref} classList={ { visible: visible() } }>…</section>;

One Observer Module, Two Framework BindingsFour steps. Write a framework-agnostic shared observer keyed by options. Wrap it in a Svelte action with update and destroy. Wrap it in a Solid primitive that returns a signal and a ref and cleans up with onCleanup. Use each in markup; both end up registering targets with the same shared instance.1Shared moduleOne observer per option set; WeakMap of handlers; refcounted.2Svelte actionRegisters the node; update() rebuilds only if options change; destroy() unregisters.3Solid primitiveSignal for visibility, ref to register, onCleanup to unregister.4Markupuse:inView or ref={ref}; both share the same observer.

Threshold / Configuration Variants

Need Svelte Solid Notes
Fire once, then stop once: true param stop inside handler one-shot reveals and lazy loads
Continuous visibility callback sets $state signal setter cheap: only readers update
Visibility ratio pass threshold array, expose ratio ratio signal prefer few thresholds
Element size resize action with ResizeObserver createElementSize primitive read contentBoxSize
Reactive options update() compares and rebuilds wrap in createEffect with explicit deps avoid rebuilding every change
Custom root pass root element param accessor for root element root must exist before observing

Where Each Framework Runs Observer CodeA grid showing where observer setup is safe in each framework. In Svelte, the component script top level runs on the server and is unsafe, actions and onMount run only in the browser and are safe. In Solid, the component body runs on the server and is unsafe, refs and onMount run only in the browser and are safe, and createEffect runs on the client after hydration.Runs on server?Safe for observers?Svelte script top levelyesnoSvelte action / onMountnoyesSvelte 5 $effectnoyesSolid component bodyyesnoSolid ref / onMountnoyes

Edge Cases & Gotchas

Reactive options rebuild observers. In Svelte, passing an inline object literal to an action creates a new object each time the component updates, and update() is called with it. Comparing the fields that matter, as in step 2, avoids tearing down and recreating the observation on every unrelated change. In Solid, reading an options signal inside createEffect makes the effect re-run — and re-observe — whenever it changes; use on() with explicit dependencies or untrack to control that.

Solid refs run before insertion. A Solid ref callback receives the element as soon as it is created, which may be before it is attached to the document. IntersectionObserver handles that — it reports the element as not intersecting and updates once it is inserted — but code that measures in the ref callback reads zeros. Measure in onMount instead.

Svelte transitions and display: none. Elements inside a Svelte {#if} block that is transitioning out are still in the DOM until the transition ends; the action's destroy runs after that. An observer on such an element may deliver a final "not intersecting" entry during the outro — handle it without updating state that belongs to an already-dismissed component.

Keyed each blocks. In {#each items as item (item.id)}, Svelte moves existing nodes when items reorder, and actions are not destroyed and recreated. Without a key, nodes are reused by position, so an action's node may represent a different item after an update; pass the item id as a parameter and handle it in update().

Hydration. Server-rendered markup should reflect the default state — usually "not visible" — so that hydration does not produce a mismatch. The hydration mismatch guide shows the React version of the same problem.

Porting a React Hook to Svelte and Solid

Teams moving code between frameworks often start from an existing React hook such as the one in building a TypeScript useIntersectionObserver hook. Most of the hook exists to work around React-specific behaviour, and a straight translation carries those workarounds into frameworks that do not need them.

Drop the stable-callback machinery. React hooks keep the latest callback in a ref so that re-renders do not recreate the observer. Svelte components and Solid components run their setup once, so a callback captured at setup is already stable. In Svelte, the action's update receives the newest callback; in Solid, reading props inside the callback reads the current value.

Drop the Strict Mode defences. React's development double-invocation of effects forces hooks to tolerate observe–disconnect–observe sequences. Neither Svelte nor Solid double-invokes lifecycle functions, so that code is dead weight — though idempotent teardown is still good practice.

Replace dependency arrays with explicit option tracking. A hook's [rootMargin, threshold] dependency array becomes Svelte's field comparison in update, or Solid's options accessor read inside a memo. The intent is the same — rebuild only when geometry options change — but the mechanism is explicit rather than inferred.

Keep the shared core. The part worth porting unchanged is the framework-agnostic pool: one observer per option set and a map from elements to handlers. That is the piece that makes long lists cheap in every framework, and keeping it identical across codebases means one set of tests covers all of them.

What Survives a Port From ReactTwo columns. Parts to drop when porting a React observer hook to Svelte or Solid include the latest-callback ref, Strict Mode double-invocation defences and dependency arrays. Parts to keep include the framework-agnostic shared observer, the WeakMap or Map of handlers, idempotent teardown and SSR guards.Drop when portingLatest-callback ref patternStrict Mode double-invoke defencesDependency arrays for optionsKeep unchangedShared observer per option setElement-to-handler mapIdempotent release functionsBrowser-only setup for SSR

Framework Integration Patterns

Two framework-specific refinements are worth knowing.

Svelte 5 attachments. Svelte 5.29 introduced attachments ({@attach fn}), functions that run when an element mounts and re-run when reactive values they read change, returning a cleanup. They can replace actions for observers, with the caveat that any $state read inside the attachment function makes it re-run — keep option reads explicit.

TypeScript
// Svelte 5 attachment version
import type { Attachment } from 'svelte/attachments';
export function inViewAttach(onchange: (v: boolean) => void, rootMargin = '0px'): Attachment {
  return (node) => observeInView(node, (e) => onchange(e.isIntersecting), { rootMargin });
}

Solid stores for collections. When many elements report visibility — a grid of cards — keep a single createStore keyed by id instead of one signal per card, and update it with produce in a batched callback. Solid's batch() ensures a callback with twenty entries triggers one downstream update rather than twenty.

Debugging Checklist

  • Log in the action's update()
  • Navigate between routes repeatedly and confirm destroy / onCleanup

FAQ

Should I use an action or onMount for observers in Svelte?

An action. It is tied to a specific element, runs only in the browser, and its destroy function is called exactly when that element is removed — including elements inside each blocks and conditionals, which onMount cannot target individually.

Do Svelte actions run during server-side rendering?

No. Actions only run in the browser when the element is created, so code inside them can safely reference IntersectionObserver and other browser APIs.

Why does my Solid observer get recreated when unrelated state changes?

Because the effect that creates it read a signal, which subscribed it. Create the observer in a ref or onMount, which run once, or wrap signal reads in untrack so only explicit dependencies trigger re-creation.

Is there a ready-made library for these?

Solid has @solid-primitives/intersection-observer and @solid-primitives/resize-observer, and several Svelte action libraries exist. They are reasonable choices; check whether they share one observer across elements and how they handle option changes before using them in long lists.

How do I observe size instead of visibility?

Use the same structure with ResizeObserver: an action or primitive that registers the element with a shared resize observer and publishes contentBoxSize into state. See the Svelte 5 runes and ResizeObserver guide for the details.

How do I test Svelte actions and Solid primitives?

Unit-test the shared core with a mocked IntersectionObserver that records observe and unobserve calls and lets the test deliver entries. For the bindings, mount a component with the testing library for each framework, deliver a fake entry, and assert on the DOM. Real intersection behaviour belongs in a browser test with Playwright.

Do fine-grained signals make observer callbacks cheaper?

They make the consequences cheaper. The callback itself costs the same, but setting a signal updates only the DOM that reads it, instead of re-rendering a component tree. That makes it practical to drive per-card visibility state from a single shared observer even in very long lists.

Can I use the same shared module across Svelte, Solid and plain scripts on one page?

Yes. Because the module only deals with elements and callbacks, micro-frontends built with different frameworks can share one observer per option set, which is exactly what makes a framework-agnostic core worth writing.


↑ Back to Framework Integration & Observer Adapters