Shopify Theme Editor Events: JS That Survives Re-render

The Shopify theme editor fires DOM events on document whenever a merchant adds, edits, selects or reorders a section or block: shopify:section:load, shopify:section:unload, shopify:block:select and six others. Your JavaScript needs them because the editor swaps section HTML in place without reloading the page, so code that only ran on page load never sees the new markup.

That is the whole story behind the familiar “my slider breaks in the customiser” bug. Shopify’s guide to integrating sections and blocks with the theme editor puts it plainly: the HTML is “dynamically added, removed, or re-rendered directly onto the existing DOM, without reloading the entire page. However, any associated JavaScript that runs when the page loads won’t run again.”

So a carousel initialised in a DOMContentLoaded handler works on the live storefront and dies the first time a merchant changes a slide’s heading.

Which events does the Shopify theme editor fire?

Nine, all dispatched so they bubble to document, and none of them cancellable.

Eventevent.targetevent.detailFires when
shopify:section:loadsectionsectionIda section is added or re-rendered
shopify:section:unloadsectionsectionIda section is deleted or about to be re-rendered
shopify:section:selectsectionsectionId, loadthe merchant selects it in the sidebar
shopify:section:deselectsectionsectionIdthe merchant deselects it
shopify:section:reordersectionsectionIdit is moved
shopify:block:selectblockblockId, sectionId, loada block is selected
shopify:block:deselectblockblockId, sectionIda block is deselected
shopify:inspector:activatenonenonethe preview inspector is switched on
shopify:inspector:deactivatenonenonethe preview inspector is switched off

The load flag on the select events is true when the selection came from a re-render rather than a click, which lets you skip work the load handler already did.

On an edit, unload fires on the old section before it is replaced and load fires on the new one. Anything you attached to the old node is now attached to a detached element, and any setInterval you started is still running against it.

How does the editor find your blocks?

Sections get their wrapper and identifying attributes automatically. Blocks do not. You add them yourself with block.shopify_attributes, which outputs something like data-shopify-editor-block="{"id":"column1","type":"column"}" in the editor and nothing at all on the live site.

<slide-show data-section-id="{{ section.id }}">
  {%- for block in section.blocks -%}
    <div class="slide" id="Slide-{{ block.id }}" {{ block.shopify_attributes }}>
      {{ block.settings.heading | escape }}
    </div>
  {%- endfor -%}
</slide-show>

Leave shopify_attributes off and shopify:block:select has no element to target, so clicking a slide in the sidebar does nothing in the preview. Check for it first when a block seems to ignore selection.

Do you need to listen for shopify:section:load at all?

Often you do not, if the component is a custom element. Dawn, Shopify’s reference theme, builds its interactive pieces as custom elements (slideshow-component, product-modal and so on), and that choice does most of the editor work for free.

A custom element’s connectedCallback runs every time the element is inserted into the document, and disconnectedCallback runs every time it is removed. The editor’s re-render is a removal followed by an insertion. So setup lives in one place and cleanup lives in the other, and the same code path serves the storefront, the editor and the Section Rendering API when your own JavaScript swaps a section in.

class SlideShow extends HTMLElement {
  connectedCallback() {
    this.controller = new AbortController();
    const { signal } = this.controller;

    this.slides = [...this.querySelectorAll('.slide')];
    this.index = 0;
    this.timer = setInterval(() => this.next(), 5000);

    this.addEventListener('keydown', (event) => {
      if (event.key === 'ArrowRight') this.next();
    }, { signal });
    window.addEventListener('resize', () => this.layout(), { signal });

    this.layout();
  }

  disconnectedCallback() {
    clearInterval(this.timer);
    this.controller.abort();
  }

  next() {
    this.goTo((this.index + 1) % this.slides.length);
  }

  goTo(index) {
    this.index = index;
    this.slides.forEach((slide, i) => slide.toggleAttribute('hidden', i !== index));
  }

  pause() {
    clearInterval(this.timer);
    this.timer = null;
  }

  resume() {
    if (!this.timer) this.timer = setInterval(() => this.next(), 5000);
  }

  layout() {
    this.goTo(this.index);
  }
}

if (!customElements.get('slide-show')) {
  customElements.define('slide-show', SlideShow);
}

The AbortController earns its place in disconnectedCallback. One abort() removes every listener registered with its signal, including the one on window, which is the listener that leaks when a section is re-rendered fifty times in an editing session. The customElements.get guard matters too: a section can ship its script tag more than once on a page, and defining the same name twice throws.

Why does my script tag not run after a section re-renders?

Because the browser does not execute <script> elements inserted as HTML. MDN’s innerHTML reference notes that the property prevents injected <script> elements from executing. A <script src="slider.js"> inside a section file runs on the first page load and is inert on every re-render after it.

Custom elements sidestep this: the class is already defined from the first load, so a freshly inserted <slide-show> upgrades immediately. If you are stuck with a legacy script that has to re-run, Dawn shows the workaround in its theme-editor.js, which finds the zoom script on shopify:section:load, creates a new <script> with the same src and swaps it in. Created elements do execute. It works, but it is a sign the script should become a custom element.

What should the events still handle?

Selection. A slideshow on autoplay rotates away from the slide the merchant is editing, five seconds after they clicked it. The guide says selected sections and blocks must stay visible while selected, and gives exactly this example: pause the slideshow while a block is selected.

That behaviour belongs in a small editor-only file, loaded from Liquid with request.design_mode, which is true only inside the editor. Dawn loads its theme-editor.js the same way.

{%- if request.design_mode -%}
  <script src="{{ 'theme-editor.js' | asset_url }}" defer="defer"></script>
{%- endif -%}
document.addEventListener('shopify:block:select', (event) => {
  const show = event.target.closest('slide-show');
  if (!show) return;
  show.pause();
  show.goTo(show.slides.indexOf(event.target));
});

document.addEventListener('shopify:block:deselect', (event) => {
  event.target.closest('slide-show')?.resume();
});

Both handlers are delegated from document, so a re-render never leaves them bound to a dead node, and the closest('slide-show') check means blocks in any other section pass straight through. In JavaScript you can check Shopify.designMode instead of loading a separate file, since it is true in the editor and undefined elsewhere, but keeping editor code out of the storefront bundle is the cleaner trade.

Two more cases are worth covering. Modals and drawers that are open when the merchant selects something else should close, which is why Dawn calls its hideProductModal() on nearly every event. And anything a section moved outside itself, such as a modal appended to <body>, will not be removed when the section is. Dawn handles that on shopify:section:unload by deleting every [data-section="<sectionId>"] element left behind.

What I would actually do

Write every interactive section as a custom element with setup in connectedCallback and teardown in disconnectedCallback, using one AbortController per instance. That covers re-renders, deletions and the Section Rendering API without a single editor event. Then add a short theme-editor.js, loaded behind request.design_mode, for the things only a merchant editing the page needs: pausing on shopify:block:select, closing overlays, and cleaning up anything a section portalled out of its own DOM.

I would only reach for shopify:section:load as the main mechanism when maintaining an older theme built on jQuery plugins, and treat each handler there as a note on what to rewrite next.

If your theme falls apart in the customiser, or you want sections merchants can edit without filing a ticket, our Shopify development work covers custom theme builds and fixing existing ones.

Need this built properly?

Get in touch