Modern JavaScript Features to Use
TL;DR: Browser-native JavaScript APIs that are Baseline widely available in 2026 — no polyfill, no library needed.
Every API on this page is native, well-supported across current browsers, and needs no polyfill or third-party library for a Shopify theme’s actual audience. Reach for these before adding a dependency — see Third-Party Libraries for the decision framework around when a library is still worth it.
Observing things, instead of polling or listening to scroll/resize
Section titled “Observing things, instead of polling or listening to scroll/resize”// ❌ WRONG — fires on every scroll event, hundreds of times a second,// and does its own visibility math by handwindow.addEventListener('scroll', () => { const rect = element.getBoundingClientRect(); if (rect.top < window.innerHeight) revealElement();});
// ✅ RIGHT — the browser tells you exactly when visibility changes,// with no per-frame math and no scroll listener at allconst observer = new IntersectionObserver((entries) => { entries.forEach((entry) => { if (entry.isIntersecting) revealElement(entry.target); });});observer.observe(element);IntersectionObserver is the right tool for lazy-loading content, scroll-triggered animations, and infinite scroll — anywhere you’d otherwise reach for a scroll listener and manual math. ResizeObserver is the equivalent for size changes: use it instead of a resize listener plus getBoundingClientRect() when a component needs to react to its own size, not the window’s.
// A component that needs to know its own size, not the viewport'sconst resizeObserver = new ResizeObserver((entries) => { for (const entry of entries) { entry.target.classList.toggle('is-narrow', entry.contentRect.width < 400); }});resizeObserver.observe(this);Always disconnect both in disconnectedCallback, the same rule as any other observer or listener covered in Custom Element Lifecycle & Progressive Enhancement.
AbortController — cancelling fetches and listeners together
Section titled “AbortController — cancelling fetches and listeners together”// ✅ One AbortController can cancel a fetch AND remove event listeners,// all in a single call — useful when a component disconnects mid-requestclass SearchResults extends HTMLElement { connectedCallback() { this.controller = new AbortController(); const { signal } = this.controller;
this.input.addEventListener('input', this.handleInput, { signal }); fetch('/search.json?q=' + this.input.value, { signal }) .then((res) => res.json()) .then(this.renderResults); }
disconnectedCallback() { this.controller.abort(); // cancels the fetch AND removes the listener, in one call }}This is a meaningful upgrade over manually pairing every addEventListener with a removeEventListener: pass the same signal to as many listeners and fetches as you want, and one abort() call cleans up all of them at once.
structuredClone — a real deep clone, no library needed
Section titled “structuredClone — a real deep clone, no library needed”// ❌ WRONG — JSON.parse(JSON.stringify(x)) drops undefined, functions,// Dates (silently becomes a string), and throws on circular referencesconst copy = JSON.parse(JSON.stringify(cartState));
// ✅ RIGHT — a real structural clone, built into the browserconst copy = structuredClone(cartState);Reach for this whenever you need a deep copy of theme state (before mutating a draft of the cart, for example) instead of the JSON.parse(JSON.stringify()) workaround or a cloning library.
Small standard-library additions worth knowing
Section titled “Small standard-library additions worth knowing”// Array.at() — negative indexing, no more `arr[arr.length - 1]`const lastVariant = product.variants.at(-1);
// Object.groupBy() — group an array into an object by a key, without a// hand-rolled reduce()const byOption = Object.groupBy(product.variants, (variant) => variant.option1);Both are small, but they replace patterns (arr[arr.length - 1], a manual reduce for grouping) that show up often enough in theme code to be worth using directly.
<dialog> and the Popover API — native overlays, no library
Section titled “<dialog> and the Popover API — native overlays, no library”<!-- A real, accessible modal: focus trapping, Escape-to-close, and a ::backdrop, all built in — no modal library required --><dialog id="size-guide"> <button autofocus formmethod="dialog">Close</button> <p>Size guide content…</p></dialog><button commandfor="size-guide" command="show-modal">Size guide</button>// Programmatic control, if you're not using the declarative// commandfor/command attributes abovedocument.getElementById('size-guide').showModal();<dialog> with showModal() gives you a real modal: it traps focus, closes on Escape, and creates a ::backdrop you can style, all without a modal library. For lighter-weight overlays, non-modal ones like a tooltip, dropdown menu, or a toast that shouldn’t block the rest of the page, the popover attribute is the equivalent for non-modal cases:
<button popovertarget="filter-menu">Filters</button><div id="filter-menu" popover> <!-- closes automatically on an outside click or Escape --></div>See Third-Party Libraries for the full list of native-first swaps like this one.
Do / Don’t
Section titled “Do / Don’t”| ✅ Do | ❌ Don’t |
|---|---|
Use IntersectionObserver/ResizeObserver instead of scroll/resize listeners with manual math, and always disconnect them in disconnectedCallback. | A scroll listener doing visibility math by hand where IntersectionObserver would do it natively, and more efficiently. |
Use one AbortController per component to cancel fetches and remove listeners together, instead of tracking each cleanup separately. | JSON.parse(JSON.stringify()) for a deep clone, silently dropping undefined values and breaking on Date objects or circular references. |
Use structuredClone for deep copies instead of the JSON.parse(JSON.stringify()) workaround. | Building a custom modal from a styled <div> instead of <dialog>, and having to hand-roll focus trapping and Escape-to-close as a result. |
Reach for <dialog>/showModal() and the popover attribute before a modal or dropdown library. | Tracking multiple cleanup functions separately when a single shared AbortController and signal would cancel all of them together. |
Key takeaways
Section titled “Key takeaways”IntersectionObserverfor visibility changes,ResizeObserverfor size changes — notscroll/resizelisteners.AbortController: onesignal, passed to multiple listeners and fetches, cancelled with oneabort()call.structuredClone(value)for a real deep clone.Array.at(-1)for negative indexing;Object.groupBy()for grouping without a manualreduce.<dialog>+showModal()for modals; thepopoverattribute for non-modal overlays.
Further reading
Section titled “Further reading”- JavaScript Architecture: Global vs. Scoped, State & Events: where these APIs fit into a component’s lifecycle
- Third-Party Libraries: the decision framework for when a native API isn’t enough
- Modern CSS Features: the CSS-side equivalent of this page
- IntersectionObserver (MDN)
- AbortController (MDN)
- The Popover API (MDN)