wire-studio-state
When to use
Wire shared state and app behavior into Studio: declared state variables authors can bind, a custom store (Redux / Zustand / signals / web storage) via getState/setState, and named functions authors attach to buttons through action props.
Use when a composition needs state shared across components, or an author needs to wire a button to app logic. Phrases: "share state between sections", "cart count in Studio", "use my Redux store", "Bring Your Own State", "BYOS", "author should pick which function the button calls", "action prop", "stateVariable", "getState/setState". Do NOT use for external read-only data passed once into a page (that's wire-external-data), for personalization branching (wire-variant-alias), or for plain per-prop defaults (wire-component-default-data).
Auth preflight: settle the credential before the first API call. Resolve it OAuth-first per
authenticate-cma:CS_OAUTH_ACCESS_TOKEN, else the Contentstack MCP's stored session: Read that skill for where it is, even with no MCP connected. Never ask the user for a sessionauthtoken. If nothing resolves, or a refresh fails with400 invalid_refresh_token, hand them! CONTENTSTACK_REGION=<code> npx @contentstack/mcp --auth(it needs a TTY and a browser, so it cannot be run for them) and wait.403 error_code 316is a valid credential aimed at another org: fix the org or theapi_key, do not re-authenticate.
Wire Studio state + functions (BYOS)
Preflight: is BYOS in the installed SDK? Check the EXPORT, not the version
Runtime-verified, and the reason a version check is not enough: public npm's @contentstack/studio-react 1.7.0 does NOT contain BYOS: zero occurrences of useStudioState in its dist. 1.9.0 (the latest tag on 2026-10-05) does. The SDK monorepo also calls itself 1.7.0 but carries the feature (with studio-registry at 1.8.0 / studio-react-components at 1.9.0). Same version number, different contents. So never gate on a version string. Gate on whether the symbol is actually there:
npm ls @contentstack/studio-react --depth=0 # what is installed
grep -rq "useStudioState" node_modules/@contentstack/studio-react/dist \
&& echo "BYOS present" || echo "BYOS ABSENT"
Do not use node -e "require('@contentstack/studio-react/package.json')": the package's exports map doesn't expose ./package.json, so it dies with ERR_PACKAGE_PATH_NOT_EXPORTED. npm ls works.
Availability has two independent halves: the SDK in your app and the Studio editor. The editor half (declared variables in the Data Picker, the action prop picker) is deployed on dev / non-prod environments and not yet on prod (per the Studio team), so a prod stack can't author BYOS even with a correct SDK build. Check the DC alongside the export: analyze-project-fit § 2b.
BYOS ABSENT means stop. Releases up to 1.7.0 are ABSENT; 1.9.0 ships it. This chapter applies only to a build that ships it. Either obtain such a build, or fall back to component-local state (a plain useState inside one registered component, no author binding, no cross-component sharing) and say so explicitly. Don't emit useStudioState against a package that can't import it.
Context
Registered components give Studio your markup. BYOS gives it your behavior. Three independent pieces:
| Piece | Gives you | API |
|---|---|---|
| State variables | Values shared across components / sections / templates, bindable by authors | state.variables, registerComponentStateVariable |
| Storage | Where values live: session, local, or your own store | storage, getState, setState, subscribe |
| Functions + action props | Logic authors invoke by name from a button | registerStudioFunctions, type: "action" |
Why this matters. Without BYOS every interactive piece must be a self-contained registered component with behavior baked in: authors re-bind props and nothing else. With it, an author-dropped "Add to cart" calls your cart:add, and every component reading cart:count updates. Skipping BYOS is the difference between Studio composing your app and Studio decorating it.
Full reference: docs/15-build/10-components/45-component-state.md.
Decide what's actually needed: don't wire all three by reflex
| Evidence in the app | Reach for |
|---|---|
| Two components must agree on a value (cart count, active filter, theme) | State variable |
| The app already owns that state in Redux / Zustand / signals | State variable + storage: "custom" |
| Value should survive reload / revisit but there's no app store | storage: "session" / "local" |
| An author should choose what a button does | Function + action prop |
| Read-only data fetched once for a page (pricing, geo, inventory) | Not BYOS, wire-external-data's data prop |
| One prop varying per template instance | Not BYOS, expose-section-props |
Task
-
Declare the variables. Only declared variables are author-bindable. Undeclared keys work in code via
useStudioState("key")but never reach the Data Picker. Pick the tier deliberately:inittier:state.variablesinstudioSdk.init, for keys the app owns globally.componenttier:registerComponentStateVariable(key, def)at component module scope, for keys a component owns.
registerComponentStateVariable("cart:filter", { type: "string", defaultValue: "all" });export function CartFilter() {const [filter, setFilter] = useStudioState<string>("cart:filter");// …}Precedence (verified in
studio-registry/src/state-store):componentbeatsinitfor the same key, deterministically and regardless of module evaluation order. Same-tier re-declaration keeps the first. A runtimesetalways beats a declared default. So a component can own its key without coordinating with the init file.Types:
string · number · boolean · choice · array · object.choicerequiresoptions. -
Choose storage. Omitting
stateentirely keeps the SDK's in-memory Map (back-compatible). Providingstatewithoutstoragegives the built-in session store, unlessgetState/setStateare present, which impliescustom.storageBehavior Requires "session"sessionStorage, survives reloads, cleared with the tab- "local"localStorage, survives across visits- "custom"delegates to the app's store getState+setStatestorageKey(built-in stores only) defaults to"cs-studio:state". -
For
custom, wire all three functions, and don't skipsubscribe.state: {storage: "custom",getState: (key) => store.getState().studio[key],setState: (key, value) => store.dispatch({ type: "studio/set", key, value }),subscribe: (key, listener) => store.subscribe(listener), // returns unsubscribevariables: { "cart:count": { type: "number", defaultValue: 0 } },}getState/setStateare mandatory forcustom. Withoutsubscribethe flow is one-way: Studio's own writes land, but adispatchfrom elsewhere in the app never re-renders Studio-bound components. Wire it whenever anything outside Studio mutates these keys.A
getStatereturning a fresh object/array per call is safe: the hook caches per key and re-reads only on a version change, so it won't trip React's "getSnapshot should be cached" warning. -
Read/write from the right place.
- In React, use
const [v, setV] = useStudioState<T>(key),setV(next)orsetV(prev => …). SSR-safe: the server renders the declared default and subscribes only on the client. - Outside React (handlers, analytics, business logic), use
studioState.get / set / subscribe / reset. Same store instance as the hook and author bindings. getStudioStateSnapshot()returns[{ key, value }]for declared variables, which is what the picker lists.
- In React, use
-
Register functions if authors should choose behavior.
ctxis{ get, set, subscribe }over the same store. Registrations merge. Re-registration overwrites with a dev warning (HMR).registerStudioFunctions({"cart:add": (ctx, args) => {ctx.set("cart:count", (prev) => (prev ?? 0) + 1);ctx.set("cart:last_added", args?.sku ?? "");},});Namespace the names (
cart:add,coupon:apply): the registry is flat and a collision silently overwrites in production, where the dev warning doesn't fire. -
Expose
actionprops so authors can attach them.actionis a first-class prop type alongsidestring,number,boolean,choice,href,imageurl,datestring,array,object,slot,json_rte,any, seeregister-component.props: {label: { type: "string", defaultValue: "Add to cart" },onCtaClick: { type: "action", displayName: "CTA Click Function" },}The component just calls the prop,
onCtaClick?.({ sku }). The SDK wraps the author's chosen function. Extra positional args are forwarded verbatim, soonSelect(id, meta)loses nothing. From your own code, callcallStudioFunction("cart:add", { sku }). -
SSR: declare on the client before hydration. Server init declares at module scope, but the client init is usually a lazy import that evaluates after hydration, so state-bound nodes hydrate against undeclared keys and mismatch the server HTML. Statically import a module that declares:
// lib/register-state-variables.ts — imported STATICALLY from _app / root layoutregisterGlobalStateVariables(studioStateConfig.variables ?? {});That declares at the
inittier, so component declarations still win. -
Verify. Open the Data Picker on a component prop: every declared variable appears under Component Props with its current value. Bind one, change it from a second component, and confirm the first re-renders. For
custom: mutate the key from outside Studio (a dispatch in the console) and confirm the bound component updates. If it doesn't,subscribeis missing. For anactionprop: bind it, click in the canvas, and confirm the registered function ran.
Inputs needed from the user
variables: the keys to share, each with type + default (andoptionsforchoice). Derive candidates from the app: values already lifted into a store or passed through several components.storage:session/local/custom. Defaultsessionunless the app already owns the state.getState/setState/subscribe: required forcustom. Ask which store and how it reads/writes.functions: named logic authors should be able to attach, if any.
Acceptance
- Preflight passed: the installed
@contentstack/studio-reactactually exports the state APIs (≥ 1.7.0). Nothing was written against a version that can't import them. - Every variable authors need is declared (init or component tier): nothing relies on an ad-hoc key being bindable.
- The tier choice is deliberate, and no key is declared in both places expecting init to win.
-
storageis set explicitly, and forcustombothgetStateandsetStateexist. -
subscribeis wired whenever anything outside Studio mutates these keys, verified by mutating from outside and watching a bound component update. - Declared variables appear in the Data Picker under Component Props. A value set by one component is observed by another.
- If authors should control behavior, functions are registered and exposed via
actionprops, not hardcoded in the component. - SSR apps statically import a module calling
registerGlobalStateVariables. No hydration mismatch on state-bound nodes.
Common pitfalls
| Pitfall | Why it bites | Fix |
|---|---|---|
| Expecting an undeclared key in the Data Picker | Only declared variables are author-bindable. Ad-hoc keys are code-only by design | Declare it at the init or component tier |
storage: "custom" without getState/setState | The SDK can't read or write a store it doesn't know | Both are mandatory for custom |
Custom store without subscribe | One-way: external mutations never re-render Studio-bound components. Looks like a stale-render bug | Pass subscribe(key, listener) returning an unsubscribe |
| Lazy client init on an SSR app | Client declarations evaluate after hydration, so state-bound nodes mismatch the server HTML | Static import + registerGlobalStateVariables (step 7) |
Assuming init state.variables overrides a component declaration | Component tier wins by design, regardless of module order | Component declaration is authoritative. Init covers keys no component owns |
Interactive components with no action props | Authors can't attach behavior: interactivity stays hardcoded and the component is a black box | Add type: "action" props and register the functions |
| Unnamespaced function names | Flat registry. A collision overwrites silently in production (the dev warning is dev-only) | Namespace them: cart:add, checkout:apply |
| Using BYOS for read-only page data | Shared mutable state is the wrong tool for a value fetched once | wire-external-data's data prop |
See also
docs/15-build/10-components/45-component-state.md: full referenceregister-component: whereactionprops are declaredwire-external-data: read-only external data, not shared statewire-variant-alias: user-state branching via Personalizeexpose-section-props: per-instance prop overrides