Observer Patterns
Observer Patterns
The observer pattern is everywhere in frontend development—from browser APIs that watch DOM changes to state management systems. Understanding these patterns is essential for building reactive, performant applications.
The Observer Design Pattern
At its core, the observer pattern defines a one-to-many relationship where one object (subject) notifies multiple objects (observers) about state changes.
Basic Implementation
class Observable {
constructor() {
this.observers = new Set();
}
subscribe(observer) {
this.observers.add(observer);
// Return unsubscribe function
return () => {
this.observers.delete(observer);
};
}
notify(data) {
this.observers.forEach((observer) => {
try {
observer(data);
} catch (error) {
console.error("Observer error:", error);
}
});
}
}
// Usage
const userState = new Observable();
const unsubscribe = userState.subscribe((user) => {
console.log("User changed:", user);
updateUI(user);
});
userState.notify({ name: "John", role: "admin" });
// Later
unsubscribe();Enhanced Observable with TypeScript
type Observer<T> = (value: T) => void;
type Unsubscribe = () => void;
class Observable<T> {
private observers = new Set<Observer<T>>();
private lastValue: T | undefined;
constructor(private options: { replayLast?: boolean } = {}) {}
subscribe(observer: Observer<T>): Unsubscribe {
this.observers.add(observer);
// Replay last value if available
if (this.options.replayLast && this.lastValue !== undefined) {
observer(this.lastValue);
}
return () => {
this.observers.delete(observer);
};
}
notify(value: T): void {
this.lastValue = value;
this.observers.forEach((observer) => observer(value));
}
get size(): number {
return this.observers.size;
}
}
// BehaviorSubject-like (always has current value)
class BehaviorObservable<T> {
private observers = new Set<Observer<T>>();
constructor(private currentValue: T) {}
get value(): T {
return this.currentValue;
}
subscribe(observer: Observer<T>): Unsubscribe {
this.observers.add(observer);
observer(this.currentValue); // Immediately emit current value
return () => this.observers.delete(observer);
}
next(value: T): void {
this.currentValue = value;
this.observers.forEach((observer) => observer(value));
}
}Intersection Observer
Watches when elements enter or exit the viewport (or another element). Essential for lazy loading, infinite scroll, and scroll-triggered animations.
Basic Usage
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
console.log({
element: entry.target,
isIntersecting: entry.isIntersecting,
intersectionRatio: entry.intersectionRatio,
boundingClientRect: entry.boundingClientRect,
});
});
});
// Observe elements
document.querySelectorAll(".observable").forEach((el) => {
observer.observe(el);
});
// Stop observing
observer.unobserve(element);
// Stop all observations
observer.disconnect();Configuration Options
const observer = new IntersectionObserver(callback, {
// Element to use as viewport (default: browser viewport)
root: document.querySelector(".scroll-container"),
// Margin around root (like CSS margin)
rootMargin: "100px 0px", // Start loading 100px before visible
// At what visibility percentage to trigger
threshold: [0, 0.25, 0.5, 0.75, 1], // Multiple thresholds
});Lazy Loading Images
class LazyImageLoader {
constructor(options = {}) {
this.loadedClass = options.loadedClass || "loaded";
this.errorClass = options.errorClass || "error";
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{
rootMargin: "50px 0px", // Start loading 50px before visible
threshold: 0,
},
);
this.init();
}
init() {
document.querySelectorAll("img[data-src]").forEach((img) => {
this.observer.observe(img);
});
}
handleIntersection(entries) {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.loadImage(entry.target);
this.observer.unobserve(entry.target);
}
});
}
loadImage(img) {
const src = img.dataset.src;
const srcset = img.dataset.srcset;
// Create temporary image to preload
const tempImg = new Image();
tempImg.onload = () => {
img.src = src;
if (srcset) img.srcset = srcset;
img.classList.add(this.loadedClass);
img.removeAttribute("data-src");
img.removeAttribute("data-srcset");
};
tempImg.onerror = () => {
img.classList.add(this.errorClass);
};
tempImg.src = src;
}
// For dynamically added images
observe(img) {
if (img.dataset.src) {
this.observer.observe(img);
}
}
destroy() {
this.observer.disconnect();
}
}
// Usage
const lazyLoader = new LazyImageLoader();
// For dynamic content
document.body.addEventListener("htmx:afterSettle", () => {
document.querySelectorAll("img[data-src]").forEach((img) => {
lazyLoader.observe(img);
});
});Infinite Scroll
class InfiniteScroll {
constructor(options) {
this.container = options.container;
this.loadMore = options.loadMore;
this.sentinel = this.createSentinel();
this.isLoading = false;
this.hasMore = true;
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{
root: options.root || null,
rootMargin: "200px",
threshold: 0,
},
);
this.observer.observe(this.sentinel);
}
createSentinel() {
const sentinel = document.createElement("div");
sentinel.className = "infinite-scroll-sentinel";
sentinel.setAttribute("aria-hidden", "true");
this.container.appendChild(sentinel);
return sentinel;
}
async handleIntersection(entries) {
const entry = entries[0];
if (!entry.isIntersecting || this.isLoading || !this.hasMore) {
return;
}
this.isLoading = true;
this.showLoader();
try {
const { items, hasMore } = await this.loadMore();
this.renderItems(items);
this.hasMore = hasMore;
if (!hasMore) {
this.observer.disconnect();
this.sentinel.remove();
}
} catch (error) {
console.error("Failed to load more:", error);
this.showError();
} finally {
this.isLoading = false;
this.hideLoader();
}
}
renderItems(items) {
const fragment = document.createDocumentFragment();
items.forEach((item) => {
const element = this.createItemElement(item);
fragment.appendChild(element);
});
// Insert before sentinel
this.container.insertBefore(fragment, this.sentinel);
}
createItemElement(item) {
const div = document.createElement("div");
div.className = "item";
div.innerHTML = `<h3>${item.title}</h3><p>${item.description}</p>`;
return div;
}
showLoader() {
this.container.classList.add("loading");
}
hideLoader() {
this.container.classList.remove("loading");
}
showError() {
// Show retry button
}
}
// Usage
const scroll = new InfiniteScroll({
container: document.getElementById("feed"),
loadMore: async () => {
const response = await fetch(`/api/posts?page=${currentPage++}`);
const data = await response.json();
return { items: data.posts, hasMore: data.hasNextPage };
},
});Scroll-Triggered Animations
class ScrollAnimations {
constructor() {
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{
threshold: [0, 0.1, 0.2, 0.3, 0.4, 0.5],
rootMargin: "-50px",
},
);
document.querySelectorAll("[data-animate]").forEach((el) => {
this.observer.observe(el);
});
}
handleIntersection(entries) {
entries.forEach((entry) => {
const el = entry.target;
const animation = el.dataset.animate;
if (entry.isIntersecting) {
// Trigger animation based on ratio
if (entry.intersectionRatio > 0.2) {
el.classList.add("animate", `animate-${animation}`);
// Optional: Only animate once
if (el.dataset.animateOnce !== undefined) {
this.observer.unobserve(el);
}
}
} else {
// Remove animation when out of view (for re-triggering)
if (el.dataset.animateOnce === undefined) {
el.classList.remove("animate", `animate-${animation}`);
}
}
});
}
}Progress Indicator (Reading Progress)
class ReadingProgress {
constructor(articleSelector, progressElement) {
this.article = document.querySelector(articleSelector);
this.progress = progressElement;
this.sections = this.article.querySelectorAll("h2, h3");
// Track visibility of sections
this.sectionVisibility = new Map();
this.observer = new IntersectionObserver(
this.handleIntersection.bind(this),
{
threshold: Array.from({ length: 101 }, (_, i) => i / 100),
rootMargin: "-10% 0px -10% 0px",
},
);
this.observer.observe(this.article);
this.sections.forEach((section) => {
this.sectionVisibility.set(section, 0);
this.observer.observe(section);
});
}
handleIntersection(entries) {
entries.forEach((entry) => {
if (entry.target === this.article) {
this.updateProgressBar(entry.intersectionRatio);
} else {
this.sectionVisibility.set(entry.target, entry.intersectionRatio);
this.updateActiveSection();
}
});
}
updateProgressBar(ratio) {
// More sophisticated calculation based on scroll position
const rect = this.article.getBoundingClientRect();
const articleHeight = this.article.offsetHeight;
const viewportHeight = window.innerHeight;
let progress;
if (rect.top >= 0) {
progress = 0;
} else if (rect.bottom <= viewportHeight) {
progress = 100;
} else {
const scrolled = -rect.top;
const scrollable = articleHeight - viewportHeight;
progress = (scrolled / scrollable) * 100;
}
this.progress.style.width = `${progress}%`;
}
updateActiveSection() {
let mostVisible = null;
let maxRatio = 0;
this.sectionVisibility.forEach((ratio, section) => {
if (ratio > maxRatio) {
maxRatio = ratio;
mostVisible = section;
}
});
if (mostVisible) {
document.querySelectorAll(".toc-link").forEach((link) => {
link.classList.toggle(
"active",
link.getAttribute("href") === `#${mostVisible.id}`,
);
});
}
}
}Mutation Observer
Watches for changes to the DOM tree—added/removed nodes, attribute changes, text content changes.
Basic Usage
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
console.log({
type: mutation.type, // 'childList', 'attributes', 'characterData'
target: mutation.target,
addedNodes: mutation.addedNodes,
removedNodes: mutation.removedNodes,
attributeName: mutation.attributeName,
oldValue: mutation.oldValue,
});
});
});
observer.observe(targetElement, {
childList: true, // Watch for added/removed children
attributes: true, // Watch for attribute changes
characterData: true, // Watch for text content changes
subtree: true, // Watch entire subtree, not just direct children
attributeOldValue: true, // Record old attribute values
characterDataOldValue: true, // Record old text values
attributeFilter: ["class", "style"], // Only watch specific attributes
});
// Stop observing
observer.disconnect();React to Dynamic Content
class DynamicContentHandler {
constructor(container) {
this.container = container;
this.handlers = new Map();
this.observer = new MutationObserver(this.handleMutations.bind(this));
this.observer.observe(container, {
childList: true,
subtree: true,
});
}
// Register handler for elements matching selector
register(selector, { onAdd, onRemove }) {
this.handlers.set(selector, { onAdd, onRemove });
// Handle existing elements
if (onAdd) {
this.container.querySelectorAll(selector).forEach((el) => {
onAdd(el);
});
}
}
handleMutations(mutations) {
const addedElements = new Set();
const removedElements = new Set();
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
addedElements.add(node);
node.querySelectorAll?.("*").forEach((child) => {
addedElements.add(child);
});
}
});
mutation.removedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
removedElements.add(node);
node.querySelectorAll?.("*").forEach((child) => {
removedElements.add(child);
});
}
});
});
// Process handlers
this.handlers.forEach(({ onAdd, onRemove }, selector) => {
addedElements.forEach((el) => {
if (el.matches?.(selector)) {
onAdd?.(el);
}
});
removedElements.forEach((el) => {
if (el.matches?.(selector)) {
onRemove?.(el);
}
});
});
}
destroy() {
this.observer.disconnect();
}
}
// Usage
const handler = new DynamicContentHandler(document.body);
handler.register("[data-tooltip]", {
onAdd: (el) => {
new Tooltip(el);
},
onRemove: (el) => {
el._tooltip?.destroy();
},
});
handler.register("video", {
onAdd: (el) => {
el.addEventListener("play", trackVideoPlay);
},
});Form Dirty State Detection
class FormDirtyTracker {
constructor(form) {
this.form = form;
this.isDirty = false;
this.initialValues = this.captureValues();
// Watch for programmatic changes
this.observer = new MutationObserver(this.checkDirty.bind(this));
this.observer.observe(form, {
subtree: true,
attributes: true,
attributeFilter: ["value", "checked", "selected"],
characterData: true,
});
// Also watch input events
form.addEventListener("input", () => this.checkDirty());
form.addEventListener("change", () => this.checkDirty());
}
captureValues() {
const values = new Map();
const inputs = this.form.querySelectorAll("input, textarea, select");
inputs.forEach((input) => {
const key = input.name || input.id;
if (!key) return;
if (input.type === "checkbox" || input.type === "radio") {
values.set(key, input.checked);
} else if (input.type === "file") {
values.set(key, "");
} else {
values.set(key, input.value);
}
});
return values;
}
checkDirty() {
const currentValues = this.captureValues();
let isDirty = false;
currentValues.forEach((value, key) => {
if (value !== this.initialValues.get(key)) {
isDirty = true;
}
});
if (isDirty !== this.isDirty) {
this.isDirty = isDirty;
this.form.dispatchEvent(
new CustomEvent("dirtychange", {
detail: { isDirty },
}),
);
}
}
reset() {
this.initialValues = this.captureValues();
this.isDirty = false;
}
destroy() {
this.observer.disconnect();
}
}
// Usage
const tracker = new FormDirtyTracker(document.getElementById("my-form"));
form.addEventListener("dirtychange", (e) => {
saveButton.disabled = !e.detail.isDirty;
if (e.detail.isDirty) {
window.onbeforeunload = () => "You have unsaved changes";
} else {
window.onbeforeunload = null;
}
});Attribute Change Watcher
class AttributeWatcher {
constructor(element, attributes, callback) {
this.element = element;
this.callback = callback;
this.observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.type === "attributes") {
const newValue = element.getAttribute(mutation.attributeName);
callback({
attribute: mutation.attributeName,
oldValue: mutation.oldValue,
newValue: newValue,
element: mutation.target,
});
}
});
});
this.observer.observe(element, {
attributes: true,
attributeOldValue: true,
attributeFilter: attributes,
});
}
disconnect() {
this.observer.disconnect();
}
}
// Usage
const watcher = new AttributeWatcher(
document.getElementById("theme-root"),
["data-theme", "data-mode"],
({ attribute, oldValue, newValue }) => {
if (attribute === "data-theme") {
updateThemeStyles(newValue);
trackThemeChange(oldValue, newValue);
}
},
);Resize Observer
Watches for changes to an element's size. More efficient than window resize events and works for individual elements.
Basic Usage
const observer = new ResizeObserver((entries) => {
entries.forEach((entry) => {
console.log({
element: entry.target,
contentRect: entry.contentRect,
borderBoxSize: entry.borderBoxSize,
contentBoxSize: entry.contentBoxSize,
});
});
});
observer.observe(element);Responsive Component
class ResponsiveComponent {
constructor(element) {
this.element = element;
this.breakpoints = {
small: 300,
medium: 600,
large: 900,
};
this.observer = new ResizeObserver(this.handleResize.bind(this));
this.observer.observe(element);
}
handleResize(entries) {
const entry = entries[0];
const width = entry.contentRect.width;
// Remove all size classes
Object.keys(this.breakpoints).forEach((size) => {
this.element.classList.remove(`size-${size}`);
});
// Add appropriate class
if (width < this.breakpoints.small) {
this.element.classList.add("size-small");
} else if (width < this.breakpoints.medium) {
this.element.classList.add("size-medium");
} else {
this.element.classList.add("size-large");
}
// Dispatch custom event
this.element.dispatchEvent(
new CustomEvent("sizechange", {
detail: { width, height: entry.contentRect.height },
}),
);
}
destroy() {
this.observer.disconnect();
}
}
// Container queries polyfill-like behavior
document.querySelectorAll("[data-responsive]").forEach((el) => {
new ResponsiveComponent(el);
});Dynamic Chart Resizing
class ResizableChart {
constructor(container, data) {
this.container = container;
this.data = data;
this.canvas = document.createElement("canvas");
container.appendChild(this.canvas);
this.observer = new ResizeObserver(this.throttledResize.bind(this));
this.observer.observe(container);
}
throttledResize = throttle((entries) => {
const entry = entries[0];
const { width, height } = entry.contentRect;
if (width === 0 || height === 0) return;
this.updateDimensions(width, height);
this.render();
}, 100);
updateDimensions(width, height) {
const dpr = window.devicePixelRatio || 1;
this.canvas.width = width * dpr;
this.canvas.height = height * dpr;
this.canvas.style.width = `${width}px`;
this.canvas.style.height = `${height}px`;
const ctx = this.canvas.getContext("2d");
ctx.scale(dpr, dpr);
}
render() {
// Render chart with new dimensions
}
destroy() {
this.observer.disconnect();
}
}Textarea Auto-Resize
class AutoResizeTextarea {
constructor(textarea) {
this.textarea = textarea;
this.minHeight = parseInt(getComputedStyle(textarea).minHeight) || 100;
// Watch for content changes via ResizeObserver on a mirror element
this.mirror = this.createMirror();
this.observer = new ResizeObserver(() => {
this.resize();
});
this.observer.observe(this.mirror);
textarea.addEventListener("input", () => this.updateMirror());
this.updateMirror();
}
createMirror() {
const mirror = document.createElement("div");
mirror.className = "textarea-mirror";
const style = getComputedStyle(this.textarea);
mirror.style.cssText = `
position: absolute;
visibility: hidden;
white-space: pre-wrap;
word-wrap: break-word;
width: ${style.width};
padding: ${style.padding};
border: ${style.border};
font: ${style.font};
line-height: ${style.lineHeight};
`;
document.body.appendChild(mirror);
return mirror;
}
updateMirror() {
this.mirror.textContent = this.textarea.value + "\n";
this.mirror.style.width = getComputedStyle(this.textarea).width;
}
resize() {
const height = Math.max(this.minHeight, this.mirror.offsetHeight);
this.textarea.style.height = `${height}px`;
}
destroy() {
this.observer.disconnect();
this.mirror.remove();
}
}Performance Observer (Bonus)
Not an observer pattern per se, but essential for monitoring performance.
// Observe performance entries
const observer = new PerformanceObserver((list) => {
list.getEntries().forEach((entry) => {
console.log({
name: entry.name,
type: entry.entryType,
duration: entry.duration,
startTime: entry.startTime,
});
// Send to analytics
if (entry.entryType === "largest-contentful-paint") {
analytics.track("LCP", { value: entry.startTime });
}
});
});
// Watch for specific entry types
observer.observe({
entryTypes: [
"largest-contentful-paint",
"first-input",
"layout-shift",
"longtask",
"resource",
],
});React Hooks for Observers
useIntersectionObserver
function useIntersectionObserver(options = {}) {
const [entry, setEntry] = useState(null);
const [node, setNode] = useState(null);
const observer = useRef(null);
useEffect(() => {
if (observer.current) {
observer.current.disconnect();
}
observer.current = new IntersectionObserver(
([entry]) => setEntry(entry),
options,
);
if (node) {
observer.current.observe(node);
}
return () => {
observer.current?.disconnect();
};
}, [node, options.threshold, options.root, options.rootMargin]);
return [setNode, entry];
}
// Usage
function LazyImage({ src, alt }) {
const [ref, entry] = useIntersectionObserver({
rootMargin: "100px",
});
const [loaded, setLoaded] = useState(false);
return (
<div ref={ref} className="lazy-image">
{entry?.isIntersecting || loaded ? (
<img src={src} alt={alt} onLoad={() => setLoaded(true)} />
) : (
<div className="placeholder" />
)}
</div>
);
}useResizeObserver
function useResizeObserver() {
const [size, setSize] = useState({ width: 0, height: 0 });
const ref = useRef(null);
useEffect(() => {
if (!ref.current) return;
const observer = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setSize({
width: entry.contentRect.width,
height: entry.contentRect.height,
});
}
});
observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return [ref, size];
}
// Usage
function ResponsiveChart({ data }) {
const [ref, { width, height }] = useResizeObserver();
return (
<div ref={ref} className="chart-container">
<Chart data={data} width={width} height={height} />
</div>
);
}Summary
| Observer | Watches For | Common Use Cases |
|---|---|---|
| IntersectionObserver | Visibility in viewport | Lazy loading, infinite scroll, animations |
| MutationObserver | DOM changes | Dynamic content, attribute tracking |
| ResizeObserver | Element size changes | Responsive components, charts |
| PerformanceObserver | Performance metrics | Core Web Vitals, monitoring |
Key Principles:
- Prefer observers over polling or scroll events
- Always disconnect observers when done
- Batch operations in observer callbacks
- Use throttle/debounce for expensive operations in callbacks
- Handle edge cases (element removed, observer disconnected)