Reference

Framework API

Every export from @/framework.

Everything a section needs comes from one import:

import { Blocks, useCart, useAPI, api, useSectionState } from '@/framework'

The modules underneath stay separate — small files, clear ownership — but you should not have to know that useCart lives in cart.js while useSelectedBlock lives in theme-editor.js.

Anything re-exported from @/framework is supported. Anything else is internal and will move.

Sections and blocks · Blocks · Block · groupBlocksByType · useSectionState · clearSectionState

Other sections · useSectionProps · getSectionProps · getAllSectionProps · useShared · usePublish · getShared · setShared · subscribeShared · clearShared

Theme editor · editorProps · useRevealOnSelect · useSelectedIndex · useSelectedBlock · useSectionSelected

Shopify data · useThemeContext · useRoutes · useShop · useCustomer · useTemplate · useLocalization · useDesignMode · useTranslate · getTranslate · translate · getThemeContext · getRoutes · isDesignMode

Cart · useCart · useCartCount · useCartStatus · useCartError · addToCart · changeLine · removeLine · refreshCart · formatMoney

Overlays · openOverlay · closeOverlay · toggleOverlay · useOpenOverlay · useIsOverlayOpen

API · api · apiNames · useAPI · useAPIAction · admin · service · fetchApi · clearApiCache · apiFetch · getApiBase · AdminError · ApiError

Behaviour · useCarousel · usePresence · usePrefersReducedMotion

State · createStore · useStore


Sections and blocks

Blocks

<Blocks blocks components context fallback />

Renders a section's blocks from a type → component map, in the order the merchant arranged them. Handles the lookup, the React key, and the editor attributes.

It deliberately wraps nothing — an extra element would change the arrangement inside a flex or grid container — so each component spreads editor onto its own root.

1import { Blocks } from '@/framework'
2
3const BLOCKS = {
4 heading: ({ settings, editor }) => <h2 {...editor}>{settings.text}</h2>,
5 text: ({ settings, editor }) => <p {...editor}>{settings.body}</p>,
6}
7
8export default function MySection({ blocks, product }) {
9 return <Blocks blocks={blocks} components={BLOCKS} context={{ product }} />
10}

Each component receives { block, settings, editor, context, index }. A type with no component renders nothing and says so in the console while in the editor. fallback gives it something to render instead.

Block

<Block block index components context fallback />

One block on its own, for a section that places blocks in more than one region. Same props as Blocks passes, so the components are interchangeable.

1import { Block, groupBlocksByType } from '@/framework'
2
3const groups = groupBlocksByType(blocks)
4
5<aside>
6 {(groups.get('tab') ?? []).map((block, index) => (
7 <Block key={block.id} block={block} index={index} components={BLOCKS} context={ctx} />
8 ))}
9</aside>

groupBlocksByType

groupBlocksByType(blocks)Map<string, Block[]>

Splits a block list by type, preserving order within each group. For sections that render some blocks in a column and others in a row.

1import { groupBlocksByType } from '@/framework'
2
3const groups = groupBlocksByType(blocks)
4
5groups.get('tab') // [{ id, type: 'tab', settings }, …]
6groups.get('missing') // undefined — always guard with ?? []

useSectionState

useSectionState(sectionId, name, initial)[value, setValue]

useState that survives the theme editor replacing a section's markup. Editing any setting remounts React and resets ordinary state, which throws the merchant away from what they were editing.

Keyed by section id, so two instances of a section never share one. Module scope, so it lasts one page load — for editor ergonomics, not persistence.

1import { useSectionState } from '@/framework'
2
3function Tabs({ id, blocks }) {
4 const [active, setActive] = useSectionState(id, 'tab', 0)
5
6 return blocks.map((block, index) => (
7 <button key={block.id} onClick={() => setActive(index)} aria-selected={active === index}>
8 {block.settings.label}
9 </button>
10 ))
11}

Reads and writes exactly like useState, updater form included.

clearSectionState

clearSectionState(sectionId)

Forgets everything stored for a section. Rarely needed — the store dies with the page — but useful when the content has changed enough that a remembered position would be misleading.

1import { clearSectionState } from '@/framework'
2
3// The collection was refiltered; slide 8 no longer means anything.
4clearSectionState(sectionId)

Talking to other sections

useSectionProps

useSectionProps(name | { id })object | null

Reads another section's Liquid payload. Every section renders its props into the page as JSON, so this needs no cooperation from the section it reads.

Re-reads when the theme editor swaps that section, so an edit never leaves you holding a stale payload.

1import { useSectionProps } from '@/framework'
2
3function PromoBar() {
4 const header = useSectionProps('header') // by component name
5 const other = useSectionProps({ id: sectionId }) // by section id
6
7 // A merchant can remove any section at any time.
8 if (!header) return null
9
10 return <p>{header.settings.announcement}</p>
11}

getSectionProps

getSectionProps(name | { id })object | null

The same read, outside React — an event handler, a module-level lookup.

1import { getSectionProps } from '@/framework'
2
3document.addEventListener('click', () => {
4 const header = getSectionProps('header')
5 if (header?.settings.sticky) console.log('sticky header')
6})

getAllSectionProps

getAllSectionProps()Record<string, object>

Every mounted section's payload, keyed by component name. For the console while debugging — too blunt for render, because it changes whenever the editor swaps anything.

1import { getAllSectionProps } from '@/framework'
2
3console.table(getAllSectionProps())

useShared

useShared(key, initial)[value, setValue]

useState shared across every island. Any component using the same key sees the same value, with no module to write.

Keys are global — prefix them with something you own.

1import { useShared } from '@/framework'
2
3function SizeGuideButton() {
4 const [open, setOpen] = useShared('size-guide:open', false)
5 return <button onClick={() => setOpen(true)}>Size guide</button>
6}
7
8function SizeGuideModal() {
9 const [open, setOpen] = useShared('size-guide:open', false)
10 return open ? <dialog open><button onClick={() => setOpen(false)}>Close</button></dialog> : null
11}

usePublish

usePublish(key, value)

Exposes part of a component's state for other islands to read. The publishing component decides what is part of its contract, rather than everything it holds becoming one.

The value clears when the publisher unmounts, so a reader is never left holding state from a section a merchant just deleted.

1import { usePublish, useShared } from '@/framework'
2
3function ProductForm({ selected }) {
4 usePublish('pdp:variant', selected) // read-only for everyone else
5 return null
6}
7
8function ShippingEstimate() {
9 const [variant] = useShared('pdp:variant')
10 return variant ? <p>Ships in 2 days</p> : null
11}

getShared

getShared(key)any

The current value, outside React.

1import { getShared } from '@/framework'
2
3const variant = getShared('pdp:variant')

setShared

setShared(key, next)

Sets a shared value from outside React. Accepts an updater function, like setState.

1import { setShared } from '@/framework'
2
3setShared('size-guide:open', true)
4setShared('wishlist:ids', (ids = []) => [...ids, productId])

subscribeShared

subscribeShared(key, listener)() => void

Runs a listener whenever a key changes. Returns an unsubscribe function.

1import { getShared, subscribeShared } from '@/framework'
2
3const stop = subscribeShared('pdp:variant', () => {
4 console.log('variant is now', getShared('pdp:variant'))
5})
6
7// later
8stop()

clearShared

clearShared(key?)

Clears one key, a whole group by prefix:, or everything.

1import { clearShared } from '@/framework'
2
3clearShared('pdp:variant') // one key
4clearShared('pdp:') // everything under a prefix
5clearShared() // all of it

Theme editor

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

editorProps

editorProps(shopifyAttributes)object

Turns Shopify's shopify_attributes string into props you can spread. This is what makes Inspect and block selection land on a block rather than the whole section.

Parsed by the browser's own HTML parser rather than a regex, so quoting and escaping are handled correctly.

1import { editorProps } from '@/framework'
2
3{slides.map((slide) => (
4 <li key={slide.id} {...editorProps(slide.shopify_attributes)}>
5 {slide.settings.heading}
6 </li>
7))}

If you render blocks with Blocks, this is already done for you.

useRevealOnSelect

useRevealOnSelect(items, (index, item) => void)

Runs something when the merchant selects one of your blocks — checklist rule 2. A carousel slides, an accordion opens, a tab switches: the trigger is identical, only the reveal differs.

Fires every time, including re-selecting the block already selected — the case a naive useEffect on the id misses.

1import { useRevealOnSelect } from '@/framework'
2
3function Accordion({ blocks }) {
4 const [openId, setOpenId] = useState(null)
5
6 useRevealOnSelect(blocks, (index, block) => setOpenId(block.id))
7
8 return blocks.map((block) => (
9 <details key={block.id} open={openId === block.id}>{block.settings.heading}</details>
10 ))
11}

useSelectedIndex

useSelectedIndex(items){ index, item, isSelected, seq }

Which of your items the editor has selected, as state rather than a callback. index is -1 when the selection is not one of yours.

1import { useSelectedIndex } from '@/framework'
2
3function Rotator({ slides }) {
4 const selected = useSelectedIndex(slides)
5
6 // Pause while the merchant is editing one of these slides.
7 const paused = selected.isSelected
8
9 return <p>{paused ? `Editing slide ${selected.index + 1}` : 'Rotating'}</p>
10}

useSelectedBlock

useSelectedBlock(){ id, seq }

The raw page-wide selection. Reach for it only when you are not working from a list — otherwise useSelectedIndex is already scoped.

Depend on the whole object, not .id. seq increments on every selection event, so re-selecting the same block still triggers an effect.

1import { useSelectedBlock } from '@/framework'
2
3const selection = useSelectedBlock()
4
5useEffect(() => {
6 if (!selection.id) return
7 console.log('selected', selection.id)
8}, [selection]) // not [selection.id]

useSectionSelected

useSectionSelected(sectionId)boolean

True while this section is the selected one. For editor-only affordances — an empty-state hint where a merchant has not added blocks yet.

1import { useSectionSelected } from '@/framework'
2
3function Gallery({ id, blocks }) {
4 const selected = useSectionSelected(id)
5
6 if (blocks.length === 0) {
7 return selected ? <p>Add an image block to get started.</p> : null
8 }
9
10 return <ul>{/* … */}</ul>
11}

Shopify data

Read from the payload snippets/theme-context.liquid emits once per page.

The cart here is a snapshot from render time, and the page may have been served from cache. Treat it as initial state; after any mutation trust the cart store.

useThemeContext

useThemeContext()object

The whole payload — shop, routes, customer, template, localization, cart snapshot, design mode.

1import { useThemeContext } from '@/framework'
2
3const { shop, routes, customer, template } = useThemeContext()

Prefer the narrower hooks below; they document what a component actually needs.

useRoutes

useRoutes()object

Shopify's route table. Never hard-code /cart — routes differ on international domains and on stores with a custom root.

1import { useRoutes } from '@/framework'
2
3function CartLink() {
4 const routes = useRoutes()
5 return <a href={routes.cart_url}>Cart</a>
6}

useShop

useShop()object

Name, currency, money format, domains, and whether customer accounts are on.

1import { useShop } from '@/framework'
2
3function Footer() {
4 const shop = useShop()
5 return <p>© {new Date().getFullYear()} {shop.name}</p>
6}

useCustomer

useCustomer()object | null

The logged-in customer, or null.

1import { useCustomer, useRoutes } from '@/framework'
2
3function Greeting() {
4 const customer = useCustomer()
5 const routes = useRoutes()
6
7 if (!customer) return <a href={routes.account_login_url}>Log in</a>
8 return <p>Hello, {customer.first_name}</p>
9}

useTemplate

useTemplate()object

Which template is rendering — name, suffix, directory. For a component that appears on several templates and needs to behave differently on one.

1import { useTemplate } from '@/framework'
2
3function Breadcrumbs() {
4 const template = useTemplate()
5 if (template.name === 'index') return null
6 return <nav aria-label="Breadcrumb">{/* … */}</nav>
7}

useLocalization

useLocalization()object

Active country and language, plus the lists a switcher needs.

1import { useLocalization } from '@/framework'
2
3function CountryPicker() {
4 const { country, available_countries: countries } = useLocalization()
5
6 return (
7 <select defaultValue={country.iso_code}>
8 {countries.map((c) => <option key={c.iso_code} value={c.iso_code}>{c.name}</option>)}
9 </select>
10 )
11}

useDesignMode

useDesignMode()boolean

True inside the theme editor. Use it for merchant-facing hints — not for changing what shoppers see, or the preview stops matching the storefront.

1import { useDesignMode } from '@/framework'
2
3function MegaMenu({ mega, links }) {
4 const inEditor = useDesignMode()
5
6 if (!mega && inEditor) {
7 console.info(`[header] no menu item named "${mega?.menu_item}". Available:`,
8 links.map((l) => l.title))
9 }
10
11 return null
12}

useTranslate

useTranslate()(key, params?, fallback?) => string

Translated theme strings, resolved by Liquid and read with no request. Only for words the theme writes — content is already translated before React sees it.

1import { useTranslate } from '@/framework'
2
3function BuyButton({ adding }) {
4 const t = useTranslate()
5
6 return (
7 <button aria-label={t('a11y.cart')}>
8 {adding ? t('products.adding') : t('products.add_to_cart')}
9 </button>
10 )
11}

Placeholders are substituted client-side, and a missing key returns the key itself so it is visible in review:

1t('products.availability_summary_html', { count: 3, total: 8 })
2// "Available at 3 of 8 locations"
3
4t('nope.missing') // → 'nope.missing', warns in the editor
5t('nope.missing', null, 'Add to cart') // → 'Add to cart'

See Multi-language for adding a string.

getTranslate

getTranslate()(key, params?, fallback?) => string

The same translator outside React, matching getRoutes / getThemeContext.

1import { getTranslate } from '@/framework'
2
3const t = getTranslate()
4element.setAttribute('aria-label', t('a11y.close'))

translate

translate(key, params?, fallback?)string

The translator itself, when a component already has the key and does not need a hook. useTranslate and getTranslate both return this function.

1import { translate } from '@/framework'
2
3// A default that still translates, in a component with no other need for a hook.
4export default function CarouselArrow({ side, label }) {
5 return (
6 <button aria-label={label ?? translate(side === 'left' ? 'a11y.previous' : 'a11y.next')}>
7 {/* … */}
8 </button>
9 )
10}

getThemeContext

getThemeContext()object

The same payload outside React. Parsed once and memoised.

1import { getThemeContext } from '@/framework'
2
3const currency = getThemeContext().shop.currency

getRoutes

getRoutes()object

The route table outside React — module scope, an event handler.

1import { getRoutes } from '@/framework'
2
3export async function refetchCart() {
4 const response = await fetch(`${getRoutes().cart_url}.js`)
5 return response.json()
6}

isDesignMode

isDesignMode()boolean

Design mode outside React. Useful to skip analytics or polling in the editor.

1import { isDesignMode } from '@/framework'
2
3if (!isDesignMode()) startAnalytics()

Cart

One store, so the header count, the drawer and the cart page can never disagree.

useCart

useCart()object | null

The current cart. Reflects every mutation made through the helpers below.

1import { useCart } from '@/framework'
2
3function LineItems() {
4 const cart = useCart()
5 if (!cart?.items.length) return <p>Your cart is empty.</p>
6
7 return cart.items.map((line) => (
8 <div key={line.key}>{line.title} × {line.quantity}</div>
9 ))
10}

useCartCount

useCartCount()number

Item count. A primitive, so a header badge re-renders only when the number actually changes.

1import { useCartCount } from '@/framework'
2
3function CartBadge() {
4 const count = useCartCount()
5 return <span aria-label={`${count} items`}>{count}</span>
6}

useCartStatus

useCartStatus()'idle' | 'loading' | 'error'

Whether a cart request is in flight, for disabling buttons and setting aria-busy.

1import { useCartStatus } from '@/framework'
2
3const status = useCartStatus()
4
5<button disabled={status === 'loading'}>
6 {status === 'loading' ? 'Updating…' : 'Update'}
7</button>

useCartError

useCartError()Error | null

The last cart error — a sold-out variant, a quantity rule. Clears on the next successful request.

1import { useCartError } from '@/framework'
2
3const error = useCartError()
4return error ? <p role="alert">{error.message}</p> : null

addToCart

addToCart(items, { openDrawer = true })Promise

Adds lines and opens the cart drawer. Updates the shared store, so the header count moves without the calling section knowing the header exists.

1import { addToCart } from '@/framework'
2
3await addToCart([{ id: variantId, quantity: 1 }])
4
5// A subscription line, added without opening the drawer.
6await addToCart(
7 [{ id: variantId, quantity: 1, selling_plan: planId }],
8 { openDrawer: false }
9)

changeLine

changeLine(key, quantity)Promise

Changes a line's quantity. Use the line's key, not its variant id — the same variant can appear twice with different properties.

1import { changeLine } from '@/framework'
2
3<button onClick={() => changeLine(line.key, line.quantity + 1)}>+</button>

removeLine

removeLine(key)Promise

Removes a line. The same as changeLine(key, 0), said plainly.

1import { removeLine } from '@/framework'
2
3<button onClick={() => removeLine(line.key)}>Remove</button>

refreshCart

refreshCart()Promise

Re-reads the cart from Shopify. For when something outside the theme changed it — a discount applied at checkout, a second tab.

1import { refreshCart } from '@/framework'
2
3document.addEventListener('visibilitychange', () => {
4 if (!document.hidden) refreshCart()
5})

formatMoney

formatMoney(cents, currency?)string

Formats an integer in the shop's minor units. Only for values you computed client-side — anything Liquid serialised already has a *_formatted field, and that one is correct for every currency.

1import { formatMoney, useCart } from '@/framework'
2
3const cart = useCart()
4
5// Prefer this:
6<p>{cart.total_price_formatted}</p>
7
8// Use formatMoney only when you did the arithmetic yourself:
9const remaining = threshold - cart.total_price
10<p>{formatMoney(remaining)} away from free shipping</p>

Overlays

One overlay is open at a time, so opening one closes the other.

openOverlay

openOverlay(name)

Opens an overlay by name — 'cart', 'mobile-menu', or one of your own.

1import { openOverlay } from '@/framework'
2
3openOverlay('cart')

closeOverlay

closeOverlay()

Closes whatever is open. Takes no name, because only one can be.

1import { closeOverlay } from '@/framework'
2
3<button onClick={closeOverlay} aria-label="Close">×</button>

toggleOverlay

toggleOverlay(name)

Opens it, or closes it if it is already the open one.

1import { toggleOverlay } from '@/framework'
2
3<button onClick={() => toggleOverlay('mobile-menu')}>Menu</button>

useOpenOverlay

useOpenOverlay()string | null

Which overlay is open. For something that reacts to any overlay — locking body scroll, dimming a backdrop.

1import { useOpenOverlay } from '@/framework'
2
3function Backdrop() {
4 const open = useOpenOverlay()
5 return open ? <div className="fixed inset-0 bg-black/40" /> : null
6}

useIsOverlayOpen

useIsOverlayOpen(name)boolean

Whether one particular overlay is open. What a drawer itself should use.

1import { useIsOverlayOpen, usePresence } from '@/framework'
2
3function CartDrawer() {
4 const open = useIsOverlayOpen('cart')
5 const { mounted, visible } = usePresence(open, 300)
6
7 if (!mounted) return null
8 return <aside className={visible ? 'translate-x-0' : 'translate-x-full'}>{/* … */}</aside>
9}

API

See The API for what the gateway is and why it takes a name.

api

The generated registry of everything the API can be asked for. Regenerated from src/api/ on every build, so it can never drift from the server's allowlist.

Names autocomplete, and a typo is undefined rather than a string that reaches production and comes back a 404.

1import { api } from '@/framework'
2
3api.GetShop // an Admin operation
4api.judgeme.listReviews // a service operation
5api.GetShopp // undefined — the typo fails immediately

apiNames

apiNames{ admin: string[], services: Record<string, string[]> }

Flat lists of every name, for diagnostics — answering "what does this deployment actually know about?" from the console.

1import { apiNames } from '@/framework'
2
3console.table(apiNames.admin) // ['GetShop', 'GetVariantInventory', …]
4console.log(apiNames.services.judgeme) // ['findProduct', 'listReviews', …]

useAPI

useAPI(target, variables, options){ data, loading, error, refetch }

Reads from the API. Handles loading and error state, aborts on unmount, and guarantees a slow response for the previous variables can't land after a fast one for the current ones.

The same call for Admin operations and third-party services — the target carries its own kind.

1import { api, useAPI } from '@/framework'
2
3function StoreAvailability({ variantId }) {
4 const { data, loading, error, refetch } = useAPI(
5 api.GetVariantInventory,
6 { id: `gid://shopify/ProductVariant/${variantId}` },
7 { skip: !variantId }
8 )
9
10 if (loading) return <p>Checking</p>
11 if (error) return null // the gateway may not be deployed
12
13 return <p>{data.productVariant.inventoryItem.tracked ? 'Tracked' : 'Untracked'}</p>
14}
Option
skipDon't fetch — for a value that isn't ready yet
cacheKeep the result for the page. Off by default: stock and prices go stale
onSuccess / onErrorCallbacks; identity changes don't refetch

Variables are compared by value and key order doesn't matter, so { a, b } and { b, a } are one request. Identical in-flight requests are always deduplicated.

useAPIAction

useAPIAction(target | fn){ run, data, loading, error, reset }

A write, triggered by an event rather than by rendering. run resolves with { data, error } and never rejects, so an unhandled rejection can't escape an event handler.

1import { api, useAPIAction } from '@/framework'
2
3function Newsletter() {
4 const subscribe = useAPIAction(api.SubscribeCustomer)
5
6 async function onSubmit(event) {
7 event.preventDefault()
8 const { error } = await subscribe.run({ email: event.target.email.value })
9 if (!error) event.target.reset()
10 }
11
12 return (
13 <form onSubmit={onSubmit}>
14 <input name="email" type="email" required />
15 <button disabled={subscribe.loading}>
16 {subscribe.loading ? 'Subscribing…' : 'Subscribe'}
17 </button>
18 {subscribe.error && <p role="alert">{subscribe.error.message}</p>}
19 </form>
20 )
21}

Pass a function instead when the action is more than one request:

1const checkout = useAPIAction(async (email) => {
2 const { customerCreate } = await admin('CreateCustomer', { email })
3 return admin('TagCustomer', { id: customerCreate.customer.id })
4})

admin

admin(name, variables)Promise<data>

Runs an Admin operation and returns the GraphQL data object — so destructure the field you asked for. For non-React code; components should use useAPI.

Throws AdminError when a mutation reports userErrors.

1import { admin } from '@/framework'
2
3const { shop } = await admin('GetShop')
4const { product } = await admin('GetProductMetafields', { id, namespace: 'specs' })

service

service('name.operation', variables)Promise<data>

Calls a third-party service operation and returns the upstream response unchanged — the keys are that API's, not ours.

1import { service } from '@/framework'
2
3const { product } = await service('judgeme.findProduct', { productId })
4const { reviews } = await service('judgeme.listReviews', { productId: product.id })

fetchApi

fetchApi(kind, name, variables, options)Promise

The deduped, optionally-cached fetch underneath the hooks, without React. For a prefetch on hover or a module-level warm-up.

1import { fetchApi } from '@/framework'
2
3<button
4 onMouseEnter={() => fetchApi('admin', 'GetShop', {}, { cache: true })}
5 onClick={openPanel}
6>
7 Store details
8</button>

clearApiCache

clearApiCache(prefix?)

Drops cached results — one prefix, or everything. Only affects entries stored with cache: true.

1import { clearApiCache } from '@/framework'
2
3clearApiCache('admin:') // everything Admin
4clearApiCache() // all of it

apiFetch

apiFetch(path, { method, body, signal })Promise<any>

The low-level client for the gateway, for a route you added yourself. Handles the base path, JSON encoding and error shaping.

1import { apiFetch } from '@/framework'
2
3const health = await apiFetch('/health')
4const result = await apiFetch('/custom', { method: 'POST', body: { hello: 'world' } })

getApiBase

getApiBase()string

The configured base path — the App Proxy subpath, from Theme settings → API.

1import { getApiBase } from '@/framework'
2
3console.log(getApiBase()) // '/apps/admin-api'

AdminError

Thrown when a mutation succeeds at the transport level but reports userErrors — a taken email, a value out of range. Carries userErrors and data.

1import { admin, AdminError } from '@/framework'
2
3try {
4 await admin('SubscribeCustomer', { email })
5} catch (error) {
6 if (error instanceof AdminError) {
7 // The ordinary, expected failures. Safe to show a shopper.
8 console.error(error.userErrors)
9 }
10}

ApiError

Thrown for transport failures — a non-2xx response, a network error, a malformed body. Carries status and payload.

1import { apiFetch, ApiError } from '@/framework'
2
3try {
4 await apiFetch('/health')
5} catch (error) {
6 if (error instanceof ApiError) console.error(error.status, error.payload)
7}

Behaviour

useCarousel

useCarousel(options){ swiperProps, navigation, setPrevEl, setNextEl, … }

Swiper wiring that behaves in the theme editor: selecting a block scrolls to it, autoplay pauses while a block is selected, the position survives a remount, and loop and autoplay disable themselves when they cannot work.

1import { editorProps, useCarousel } from '@/framework'
2import CarouselArrow from '@components/atoms/CarouselArrow'
3
4function Hero({ id, slides, settings }) {
5 const carousel = useCarousel({
6 sectionId: id,
7 items: slides,
8 loop: settings.loop,
9 autoplay: settings.autoplay,
10 slidesPerView: 1,
11 })
12
13 if (carousel.count === 0) return null
14
15 return (
16 <div className="relative">
17 <Swiper {...carousel.swiperProps} navigation={carousel.navigation}>
18 {slides.map((slide) => (
19 <SwiperSlide key={slide.id} {...editorProps(slide.shopify_attributes)}>
20 {slide.heading}
21 </SwiperSlide>
22 ))}
23 </Swiper>
24
25 <CarouselArrow ref={carousel.setPrevEl} side="left" disabled={carousel.atStart} overlay />
26 <CarouselArrow ref={carousel.setNextEl} side="right" disabled={carousel.atEnd} overlay />
27 </div>
28 )
29}

Full option and return tables are on Carousels.

usePresence

usePresence(open, duration){ mounted, visible }

Splits mounted (in the DOM) from visible (in its open state), so an element can animate on the way out. if (!open) return null makes an exit animation impossible — the node is gone before anything can run.

Collapses to instant under prefers-reduced-motion.

1import { usePresence } from '@/framework'
2
3function Drawer({ open }) {
4 const { mounted, visible } = usePresence(open, 300)
5 if (!mounted) return null
6
7 return (
8 <div
9 // Stops the fading element eating clicks on the way out.
10 style={{ pointerEvents: visible ? 'auto' : 'none' }}
11 className={`transition-opacity duration-300 motion-reduce:transition-none ${
12 visible ? 'opacity-100' : 'opacity-0'
13 }`}
14 >
15 {/* … */}
16 </div>
17 )
18}

The duration must match the CSS, or the element unmounts mid-animation.

usePrefersReducedMotion

usePrefersReducedMotion()boolean

Tracks the visitor's motion setting live rather than reading it once — it can be toggled while the page is open. Anything that moves on its own should check it.

1import { usePrefersReducedMotion } from '@/framework'
2
3function Marquee({ children }) {
4 const reduced = usePrefersReducedMotion()
5 return <div className={reduced ? '' : 'animate-marquee'}>{children}</div>
6}

State

createStore

createStore(initial){ getState, setState, subscribe }

A shared domain with one obvious owner. Every island bundles into one file, so a module-level variable is already shared — this adds only the ability to tell React it changed.

Export functions, not the store: a store anyone can setState has no contract.

1import { createStore, useStore } from '@/framework'
2
3const wishlistStore = createStore({ ids: [], status: 'idle' })
4
5export function useWishlist() {
6 return useStore(wishlistStore, (state) => state.ids)
7}
8
9export function addToWishlist(id) {
10 wishlistStore.setState((state) => ({ ...state, ids: [...state.ids, id] }))
11}

Reach for Zustand or Jotai when you need devtools, middleware, persistence or selector memoisation — not at this size.

useStore

useStore(store, selector)any

Subscribes to a store. Without a selector you get the whole state.

Selectors must return an Object.is-stable value or React re-renders forever.

1import { useStore } from '@/framework'
2
3useStore(cartStore, (s) => s.cart.item_count) // fine — a primitive
4useStore(cartStore, (s) => s.cart) // fine — the same reference
5useStore(cartStore, (s) => s.cart.items ?? []) // loops — a new [] every call

If you need a derived array, compute it with useMemo in the component rather than in the selector.


Deprecated

These still work. useAPI covers all three and takes a checked target rather than a string, so a typo fails immediately instead of reaching production.

Options and return shapes are unchanged — migrating is the import and the first argument.

useAdmin

useAdmin(name, variables, options){ data, loading, error, refetch }

Deprecated. Reads an Admin operation by string name. Use useAPI with a registry target.

1// Before
2const { data } = useAdmin('GetShop')
3
4// After
5const { data } = useAPI(api.GetShop)

useService

useService('name.operation', variables, options){ data, loading, error, refetch }

Deprecated. The same, for a third-party service. useAPI handles both kinds, because the target states which it is.

1// Before
2const { data } = useService('judgeme.listReviews', { productId })
3
4// After
5const { data } = useAPI(api.judgeme.listReviews, { productId })

useApiAction

useApiAction(fn){ run, data, loading, error, reset }

Deprecated. A write wrapping a function you supply. useAPIAction takes a target directly, which is one less thing to write — and it still accepts a function when the action is more than one request.

1// Before
2const subscribe = useApiAction((email) => admin('SubscribeCustomer', { email }))
3await subscribe.run(email)
4
5// After
6const subscribe = useAPIAction(api.SubscribeCustomer)
7await subscribe.run({ email })