Learning Guides
Menu

Observer Patterns

12 min readFrontend Patterns & Concepts

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

JAVASCRIPT
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

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

JAVASCRIPT
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

JAVASCRIPT
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

JAVASCRIPT
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

JAVASCRIPT
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

JAVASCRIPT
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)

JAVASCRIPT
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

JAVASCRIPT
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

JAVASCRIPT
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

JAVASCRIPT
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

JAVASCRIPT
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

JAVASCRIPT
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

JAVASCRIPT
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

JAVASCRIPT
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

JAVASCRIPT
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.

JAVASCRIPT
// 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

JAVASCRIPT
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

JAVASCRIPT
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

ObserverWatches ForCommon Use Cases
IntersectionObserverVisibility in viewportLazy loading, infinite scroll, animations
MutationObserverDOM changesDynamic content, attribute tracking
ResizeObserverElement size changesResponsive components, charts
PerformanceObserverPerformance metricsCore Web Vitals, monitoring

Key Principles:

  1. Prefer observers over polling or scroll events
  2. Always disconnect observers when done
  3. Batch operations in observer callbacks
  4. Use throttle/debounce for expensive operations in callbacks
  5. Handle edge cases (element removed, observer disconnected)