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.