A small IntersectionObserver store that keeps reveal state sane

Hey folks, I’m wiring up a docs page with sticky side nav and section reveals, and I’m trying to keep scroll/viewport state owned in one place instead of every component attaching observers. The failure mode I keep hitting is duplicate observers + stale refs when sections re-render, which turns into a sneaky memory leak.

// One observer, many targets, state owned centrally.
export function createRevealStore({ root = null, rootMargin = "0px 0px -15% 0px", threshold = 0.1 } = {}) {
  const state = new Map(); // el -> { visible, ratio }
  const listeners = new Set();

  const io = new IntersectionObserver((entries) => {
    let changed = false;
    for (const e of entries) {
      const next = { visible: e.isIntersecting, ratio: e.intersectionRatio };
      const prev = state.get(e.target);
      if (!prev || prev.visible !== next.visible || prev.ratio !== next.ratio) {
        state.set(e.target, next);
        changed = true;
      }
    }
    if (changed) listeners.forEach((fn) => fn(state));
  }, { root, rootMargin, threshold });

  return {
    observe(el) {
      if (!el) return () => {};
      state.set(el, state.get(el) ?? { visible: false, ratio: 0 });
      io.observe(el);
      return () => {
        io.unobserve(el);
        state.delete(el);
      };
    },
    subscribe(fn) {
      listeners.add(fn);
      fn(state);
      return () => listeners.delete(fn);
    },
    disconnect() {
      io.disconnect();
      state.clear();
      listeners.clear();
    }
  };
}

Neat part is it makes “revealed once” vs “currently in viewport” a product choice you can encode in one store, and it plays nicely with sticky + parallax sections without every component owning scroll listeners.

1 Like