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
destroyruns when the element is removed. A Solid ref callback runs once, andonCleanupruns 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
$statevalue 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
onMountdo not run during SSR in Svelte; in Solid, refs andonMountdo 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.
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
// 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
// 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
// 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
<!-- 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>
// Solid
const [visible, ref] = createInView({ rootMargin: '200px' });
return <section ref={ref} classList={ { visible: visible() } }>…</section>;
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 |
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.
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.
// 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.
Related
- Writing a Svelte IntersectionObserver Action — the action in depth
- Svelte 5 Runes and ResizeObserver State — size as reactive state
- SolidJS createIntersectionObserver Primitive — the Solid side
- Cleaning Up Observers in Svelte and Solid Lifecycles — teardown guarantees
- Shared Observer Pooling — the pool behind both bindings