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
@/frameworkis 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'23const BLOCKS = {4 heading: ({ settings, editor }) => <h2 {...editor}>{settings.text}</h2>,5 text: ({ settings, editor }) => <p {...editor}>{settings.body}</p>,6}78export 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'23const groups = groupBlocksByType(blocks)45<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'23const groups = groupBlocksByType(blocks)45groups.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'23function Tabs({ id, blocks }) {4 const [active, setActive] = useSectionState(id, 'tab', 0)56 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'23// 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'23function PromoBar() {4 const header = useSectionProps('header') // by component name5 const other = useSectionProps({ id: sectionId }) // by section id67 // A merchant can remove any section at any time.8 if (!header) return null910 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'23document.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'23console.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'23function SizeGuideButton() {4 const [open, setOpen] = useShared('size-guide:open', false)5 return <button onClick={() => setOpen(true)}>Size guide</button>6}78function SizeGuideModal() {9 const [open, setOpen] = useShared('size-guide:open', false)10 return open ? <dialog open><button onClick={() => setOpen(false)}>Close</button></dialog> : null11}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'23function ProductForm({ selected }) {4 usePublish('pdp:variant', selected) // read-only for everyone else5 return null6}78function ShippingEstimate() {9 const [variant] = useShared('pdp:variant')10 return variant ? <p>Ships in 2 days</p> : null11}getShared
getShared(key) → any
The current value, outside React.
1import { getShared } from '@/framework'23const 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'23setShared('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'23const stop = subscribeShared('pdp:variant', () => {4 console.log('variant is now', getShared('pdp:variant'))5})67// later8stop()clearShared
clearShared(key?)
Clears one key, a whole group by prefix:, or everything.
1import { clearShared } from '@/framework'23clearShared('pdp:variant') // one key4clearShared('pdp:') // everything under a prefix5clearShared() // all of itTheme 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'23{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'23function Accordion({ blocks }) {4 const [openId, setOpenId] = useState(null)56 useRevealOnSelect(blocks, (index, block) => setOpenId(block.id))78 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'23function Rotator({ slides }) {4 const selected = useSelectedIndex(slides)56 // Pause while the merchant is editing one of these slides.7 const paused = selected.isSelected89 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.seqincrements on every selection event, so re-selecting the same block still triggers an effect.
1import { useSelectedBlock } from '@/framework'23const selection = useSelectedBlock()45useEffect(() => {6 if (!selection.id) return7 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'23function Gallery({ id, blocks }) {4 const selected = useSectionSelected(id)56 if (blocks.length === 0) {7 return selected ? <p>Add an image block to get started.</p> : null8 }910 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'23const { 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'23function 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'23function 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'23function Greeting() {4 const customer = useCustomer()5 const routes = useRoutes()67 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'23function Breadcrumbs() {4 const template = useTemplate()5 if (template.name === 'index') return null6 return <nav aria-label="Breadcrumb">{/* … */}</nav>7}useLocalization
useLocalization() → object
Active country and language, plus the lists a switcher needs.
1import { useLocalization } from '@/framework'23function CountryPicker() {4 const { country, available_countries: countries } = useLocalization()56 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'23function MegaMenu({ mega, links }) {4 const inEditor = useDesignMode()56 if (!mega && inEditor) {7 console.info(`[header] no menu item named "${mega?.menu_item}". Available:`,8 links.map((l) => l.title))9 }1011 return null12}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'23function BuyButton({ adding }) {4 const t = useTranslate()56 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"34t('nope.missing') // → 'nope.missing', warns in the editor5t('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'23const 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'23// 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'23const currency = getThemeContext().shop.currencygetRoutes
getRoutes() → object
The route table outside React — module scope, an event handler.
1import { getRoutes } from '@/framework'23export 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'23if (!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'23function LineItems() {4 const cart = useCart()5 if (!cart?.items.length) return <p>Your cart is empty.</p>67 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'23function 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'23const status = useCartStatus()45<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'23const error = useCartError()4return error ? <p role="alert">{error.message}</p> : nulladdToCart
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'23await addToCart([{ id: variantId, quantity: 1 }])45// 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'23<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'23<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'23document.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'23const cart = useCart()45// Prefer this:6<p>{cart.total_price_formatted}</p>78// Use formatMoney only when you did the arithmetic yourself:9const remaining = threshold - cart.total_price10<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'23openOverlay('cart')closeOverlay
closeOverlay()
Closes whatever is open. Takes no name, because only one can be.
1import { closeOverlay } from '@/framework'23<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'23<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'23function Backdrop() {4 const open = useOpenOverlay()5 return open ? <div className="fixed inset-0 bg-black/40" /> : null6}useIsOverlayOpen
useIsOverlayOpen(name) → boolean
Whether one particular overlay is open. What a drawer itself should use.
1import { useIsOverlayOpen, usePresence } from '@/framework'23function CartDrawer() {4 const open = useIsOverlayOpen('cart')5 const { mounted, visible } = usePresence(open, 300)67 if (!mounted) return null8 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'23api.GetShop // an Admin operation4api.judgeme.listReviews // a service operation5api.GetShopp // undefined — the typo fails immediatelyapiNames
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'23console.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'23function StoreAvailability({ variantId }) {4 const { data, loading, error, refetch } = useAPI(5 api.GetVariantInventory,6 { id: `gid://shopify/ProductVariant/${variantId}` },7 { skip: !variantId }8 )910 if (loading) return <p>Checking…</p>11 if (error) return null // the gateway may not be deployed1213 return <p>{data.productVariant.inventoryItem.tracked ? 'Tracked' : 'Untracked'}</p>14}| Option | |
|---|---|
skip | Don't fetch — for a value that isn't ready yet |
cache | Keep the result for the page. Off by default: stock and prices go stale |
onSuccess / onError | Callbacks; 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'23function Newsletter() {4 const subscribe = useAPIAction(api.SubscribeCustomer)56 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 }1112 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'23const { 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'23const { 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'23<button4 onMouseEnter={() => fetchApi('admin', 'GetShop', {}, { cache: true })}5 onClick={openPanel}6>7 Store details8</button>clearApiCache
clearApiCache(prefix?)
Drops cached results — one prefix, or everything. Only affects entries stored
with cache: true.
1import { clearApiCache } from '@/framework'23clearApiCache('admin:') // everything Admin4clearApiCache() // all of itapiFetch
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'23const 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'23console.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'23try {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'23try {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'34function 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 })1213 if (carousel.count === 0) return null1415 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>2425 <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'23function Drawer({ open }) {4 const { mounted, visible } = usePresence(open, 300)5 if (!mounted) return null67 return (8 <div9 // 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'23function 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'23const wishlistStore = createStore({ ids: [], status: 'idle' })45export function useWishlist() {6 return useStore(wishlistStore, (state) => state.ids)7}89export 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'23useStore(cartStore, (s) => s.cart.item_count) // fine — a primitive4useStore(cartStore, (s) => s.cart) // fine — the same reference5useStore(cartStore, (s) => s.cart.items ?? []) // loops — a new [] every callIf 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// Before2const { data } = useAdmin('GetShop')34// After5const { 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// Before2const { data } = useService('judgeme.listReviews', { productId })34// After5const { 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// Before2const subscribe = useApiAction((email) => admin('SubscribeCustomer', { email }))3await subscribe.run(email)45// After6const subscribe = useAPIAction(api.SubscribeCustomer)7await subscribe.run({ email })