Building sections

Theme editor

The four rules every interactive component follows.

React sections work in the editor properly, and keeping them that way is a requirement for any new component — not a nice-to-have.

Everything here is inert on the live storefront: shopify_attributes renders empty and the events are never dispatched.

Selection

Blocks carry Shopify's own shopify_attributes, passed through verbatim rather than reconstructed. Spread it with editorProps():

<SwiperSlide {...editorProps(block.shopify_attributes)}>

That's what makes Inspect land on the right block. If you render blocks with <Blocks> this is already done for you.

Reacting to selection

Rule 2 of the checklist — selecting a block reveals it — is the same job in every component. The trigger is identical; only the reveal differs. So it's one hook:

1import { useRevealOnSelect } from '@/framework'
2
3useRevealOnSelect(blocks, (index) => setOpenIndex(index)) // accordion
4useRevealOnSelect(blocks, (index) => setActiveTab(index)) // tabs
5useRevealOnSelect(blocks, (index) => swiper.slideTo(index)) // carousel
6useRevealOnSelect(blocks, (_, block) => scrollTo(refs[block.id]))

It fires only for blocks in the list you pass, so it's already scoped to your component — and it fires every time, including when the merchant re-selects the block that is already selected.

If you need the state rather than a callback:

1const selected = useSelectedIndex(blocks)
2
3selected.index // -1 when the selection isn't one of yours
4selected.item // the block itself, or null
5selected.isSelected // pause autoplay, highlight, whatever

useSelectedBlock() is the raw page-wide selection underneath both, as { id, seq }. Reach for it only when you're not working from a list.

Depend on the whole object, not .id. seq increments on every selection event. Without it a component goes dead after the first click: the merchant selects slide 3, scrolls away, clicks slide 3 again, and nothing happens because the id never changed. useRevealOnSelect handles this for you.

Selection is deliberately not scoped by section id. Block ids are unique across the page, so finding the id among your own blocks already scopes it — and comparing section ids on top of that silently breaks sections inside groups, whose ids are prefixed (sections--…__announcement_bar) and never match.

Surviving edits

Changing a setting makes Shopify replace the section's markup, which remounts React and resets every piece of component state.

This is the single thing that breaks editor support, and it isn't obvious — the component works perfectly until a merchant edits the thing they just selected.

Selection itself is held in a module-level store, not component state, so a remounted component reads the selection that is still current and comes back pinned to the same block. Anything else that should survive goes in useSectionState:

const [openTab, setOpenTab] = useSectionState(id, 'tab', 0)

Reads and writes exactly like useState, including the updater form. The difference is only in what happens across a remount: the value comes back. Keyed by section id, so two instances of a section don't share one.

It is module scope, so it lasts one page load. This is for editor ergonomics — carousel position, open accordion, active tab — not persistence. Anything that should outlive a navigation belongs in sessionStorage or the cart, and anything the shopper can link to belongs in the URL.

Checklist for any new interactive component

  1. Blocks carry shopify_attributes via editorProps(), so Inspect and selection can find them.
  2. Selecting a block reveals it — a carousel slides to it, a rotator stops on it, an accordion opens it. Depend on the whole useSelectedBlock() object, not .id, or re-clicking the same block does nothing.
  3. Autoplay pauses while a block is selected. Otherwise the merchant is editing a moving target.
  4. State that matters survives a remount. If the answer to "what happens when this section re-renders mid-edit" is "it resets", it needs useSectionState.

Section-level selection

useSectionSelected(id) is true while this section is the selected one — for editor-only affordances, like showing an empty-state hint where a merchant has not added blocks yet.

useDesignMode() tells you whether you are in the editor at all. Use it for hints and warnings, not for changing what shoppers see.