A good SolidJS IntersectionObserver primitive returns a signal per element (or a keyed store for many), registers elements through a ref, shares one observer per option set, controls reactivity with on() or untrack so option changes are explicit, and disconnects in onCleanup of the owning scope.

Problem / Scenario Context

A SolidJS news reader marks articles as "read" once 60% of an article card has been visible, and lazily mounts embedded media. The first primitive created an observer inside a createEffect that read a threshold signal and a targets() accessor. It worked until a settings panel let users change the read threshold: every keystroke in the settings input tore down and rebuilt the observer, and every new article appended to the feed re-observed all existing cards, because the effect tracked the whole list.

Solid's fine-grained reactivity makes observer bindings very cheap to consume and surprisingly easy to over-subscribe. The Svelte Actions & Solid Primitives topic covers the shared model; this page is the Solid-specific build.

Mechanics Explanation

In Solid, components run once. Reactivity lives in computationscreateEffect, createMemo, JSX expressions — that automatically subscribe to every signal they read while running. When any of those signals changes, the computation re-runs, first disposing of anything created in its previous run (its child owners and onCleanup handlers).

For an observer this means:

  • An observer created in an effect is tied to that effect's dependencies. If the effect reads the options, changing an option re-creates the observer — which is correct. If it reads the list of targets, adding one target re-creates the observer and re-observes all of them — which is wasteful.
  • onCleanup inside the effect runs before each re-run and at disposal, so teardown is automatic if the observer is created there.
  • A ref callback runs once per element, outside any tracking scope, and is the natural place to register a single element.

The design that falls out: create or look up the observer in a computation that depends only on options, and register elements individually through refs.

Over-Subscribed Versus Targeted EffectsTwo columns. The over-subscribed effect reads options and the entire target list, so adding one article re-creates the observer and re-observes every card. The targeted design creates the observer in a computation that reads only the options, and registers each card individually through its ref, so adding an article registers one element.One effect reads everythingTracks threshold() and targets()New article: dispose and rebuild the observerRe-observes all 200 cardsSettings keystroke: rebuild againOptions-only computation + refsObserver depends on options onlyEach card registers itself via refNew article: one observe() callOption change: one deliberate rebuild

Comparison Table: Where to Put Each Piece in Solid

Piece Put it in Why
Observer creation createMemo / createEffect reading only options re-creates only when options change
Element registration ref callback runs once per element, untracked
Teardown onCleanup in the same scope as creation automatic on re-run and disposal
Visibility state signal per element, or a store keyed by id fine-grained updates
Reading state in callbacks untrack if inside a computation prevents accidental subscription
Batching many entries batch(() => …) one downstream update per callback

Minimal Reproducible Example

TSX
function Feed(props: { articles: Article[]; threshold: number }) {
  const [read, setRead] = createStore<Record<string, boolean>>({});
  let cards: HTMLElement[] = [];

  createEffect(() => {
    const io = new IntersectionObserver((es) => es.forEach((e) => {
      if (e.intersectionRatio >= props.threshold) setRead((e.target as HTMLElement).dataset.id!, true);
    }), { threshold: props.threshold });
    props.articles.forEach((_, i) => cards[i] && io.observe(cards[i]));   // tracks the list
    onCleanup(() => io.disconnect());
  });

  return <For each={props.articles}>{(a, i) => <article ref={(el) => (cards[i()] = el)} data-id={a.id}>…</article>}</For>;
}

interface Article { id: string }

Append one article and the effect re-runs: disconnect, new observer, observe everything again.

Production-Safe Solution

TypeScript
// createIntersectionObserver.ts
import { createMemo, createSignal, onCleanup, batch, untrack, type Accessor } from 'solid-js';

export interface IOOptions { rootMargin?: string; threshold?: number | number[] }

export function createIntersectionObserver(options: Accessor<IOOptions>) {
  const handlers = new Map<Element, (e: IntersectionObserverEntry) => void>();

  // Re-created only when options change; everything registered is re-observed then.
  const observer = createMemo(() => {
    const opts = options();
    const io = new IntersectionObserver((entries) => {
      batch(() => entries.forEach((e) => handlers.get(e.target)?.(e)));
    }, opts);
    untrack(() => handlers.forEach((_, el) => io.observe(el)));
    onCleanup(() => io.disconnect());
    return io;
  });

  function observe(el: Element, onEntry: (e: IntersectionObserverEntry) => void): void {
    handlers.set(el, onEntry);
    untrack(observer).observe(el);
    onCleanup(() => {                           // disposes with the element's owner (e.g. a <For> row)
      handlers.delete(el);
      untrack(observer).unobserve(el);
    });
  }

  // Convenience: a visibility signal for one element.
  function createVisible(): [Accessor<boolean>, (el: Element) => void] {
    const [visible, setVisible] = createSignal(false);
    return [visible, (el) => observe(el, (e) => setVisible(e.isIntersecting))];
  }

  return { observe, createVisible };
}
TSX
// Usage
function Feed(props: { articles: Article[] }) {
  const [threshold] = useSettings();                        // Accessor<number>
  const io = createIntersectionObserver(() => ({ threshold: threshold() }));
  const [read, setRead] = createStore<Record<string, boolean>>({});

  return (
    <For each={props.articles}>
      {(a) => (
        <article
          data-id={a.id}
          classList={ { read: !!read[a.id] } }
          ref={(el) => io.observe(el, (e) => {
            if (e.intersectionRatio >= threshold()) setRead(a.id, true);
          })}
        >…</article>
      )}
    </For>
  );
}

declare function useSettings(): [() => number];

Adding an article runs one ref callback and one observe(). Removing one disposes its row's owner, whose onCleanup unobserves it. Changing the threshold re-runs only the memo: one new observer, the existing handlers re-observed. batch makes a callback with twenty entries produce one update pass for everything that reads the store.

To avoid rebuilding on every keystroke in a settings input, debounce the settings signal itself rather than the observer — the observer simply follows whatever threshold() settles on.

What Each Change CostsThree boxes. Adding an article runs its ref callback and a single observe call. Removing an article disposes its row owner, whose onCleanup unobserves one element. Changing the threshold re-runs only the options memo, disconnecting the old observer and re-observing the registered elements on a new one.Article addedref → one observe()Article removedowner disposed → one unobserve()Threshold changedmemo re-runs → one rebuild

Integrating with Suspense and Lazy Components

Solid's <Suspense> and lazy() render content in stages, and refs run as elements are created — potentially while the content is still inside a suspended boundary that is not yet attached to the document. IntersectionObserver copes: an element observed before insertion is reported as not intersecting, then reported again once it is inserted and laid out. Two consequences:

  • Do not treat the first entry as authoritative. A "not visible" initial report may simply mean "not inserted yet".
  • Lazy-mount embeds with a sentinel element that is always rendered, not with the lazy component itself. Observe a lightweight placeholder div, and render the heavy component when the placeholder's signal turns true.
TSX
function LazyEmbed(props: { src: string }) {
  const [visible, ref] = io.createVisible();
  const [shown, setShown] = createSignal(false);
  createEffect(() => { if (visible()) setShown(true); });   // latch: never unmount once shown
  return <div ref={ref} class="embed-slot">
    <Show when={shown()} fallback={<div class="embed-placeholder" />}>
      <Embed src={props.src} />
    </Show>
  </div>;
}

declare const io: ReturnType<typeof createIntersectionObserver>;
declare function Embed(p: { src: string }): unknown;

Lazy-Mounting an Embed With a LatchFour steps. A lightweight slot element is always rendered and registered with the observer through its ref. The visible signal turns true when the slot nears the viewport. An effect latches a shown signal to true. The heavy embed component mounts inside the slot and stays mounted even if it scrolls away.1Render a slotCheap placeholder div, always present, registered via ref.2Visible signalTurns true when the slot enters the observer's root.3LatchAn effect sets shown = true once and never resets it.4Mount the embedShow renders the heavy component inside the slot.

Verification Steps

  • Count observer constructions with a temporary log in the memo: once on load, once per option change.
  • Append 50 articles and confirm 50 observe() calls, not a rebuild.
  • Remove articles and confirm the handler map shrinks.
  • Change the threshold and confirm exactly one rebuild after the settings signal settles.
  • Check SSR: refs do not run on the server, so rendered HTML contains no observer-driven state.

Common Mistakes to Avoid

  • Reading the target list inside the observer's computation. It subscribes the observer to list changes.
  • Registering elements in createEffect instead of refs. Effects run later and re-run on dependencies.
  • Forgetting onCleanup for per-element registration. Removed rows stay observed and retained.
  • Updating many signals per entry without batch. Downstream memos recompute once per entry instead of once per callback.

FAQ

Should I use @solid-primitives/intersection-observer instead?

It is a solid choice for most apps and follows similar principles. Building your own is worthwhile when you need a shared observer across micro-frontends, custom batching, or integration with an existing pooling layer.

Why use createMemo for the observer rather than createEffect?

A memo returns a value — the observer — that other code can read, and it re-runs only when its dependencies change. createEffect would work but gives no convenient handle to the current observer.

Does onCleanup inside a ref callback work?

Yes. The ref callback runs within the owner of the component or For row that created the element, so onCleanup registers with that owner and runs when the element's row is disposed.

How do I read isIntersecting in JSX without re-rendering the list?

Store visibility per id in a store or per element in a signal, and read only that item's value in its own JSX. Solid updates only the expressions that read the changed property.

Is it safe to call setters inside the observer callback?

Yes. Setters outside a tracking scope simply schedule updates. Wrapping multiple setter calls in batch combines their effects into one update pass.


↑ Back to Svelte Actions & Solid Primitives for Observers