Studio Docs

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. Never ask the user for a session authtoken. If nothing resolves, or a refresh fails with 400 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 316 is a valid credential aimed at another org: fix the org or the api_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 is the latest tag and does NOT contain BYOS: zero occurrences of useStudioState in its dist. 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. As of this writing that is the case for every published release, so this chapter applies only to an SDK 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:

PieceGives youAPI
State variablesValues shared across components / sections / templates, bindable by authorsstate.variables, registerComponentStateVariable
StorageWhere values live: session, local, or your own storestorage, getState, setState, subscribe
Functions + action propsLogic authors invoke by name from a buttonregisterStudioFunctions, 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/bring-your-own-components/bring-your-own-state.md.

Decide What’s Actually Needed: Don’t Wire All Three by Reflex

Evidence in the appReach for
Two components must agree on a value (cart count, active filter, theme)State variable
The app already owns that state in Redux / Zustand / signalsState variable + storage: "custom"
Value should survive reload / revisit but there’s no app storestorage: "session" / "local"
An author should choose what a button doesFunction + 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 instanceNot BYOS, expose-section-props

Task

  1. 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:

    • init tier: state.variables in studioSdk.init, for keys the app owns globally.
    • component tier: 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): component beats init for the same key, deterministically and regardless of module evaluation order. Same-tier re-declaration keeps the first. A runtime set always beats a declared default. So a component can own its key without coordinating with the init file.

    Types: string · number · boolean · choice · array · object. choice requires options.

  2. Choose storage. Omitting state entirely keeps the SDK’s in-memory Map (back-compatible). Providing state without storage gives the built-in session store, unless getState/setState are present, which implies custom.

    storageBehaviorRequires
    "session"sessionStorage, survives reloads, cleared with the tab-
    "local"localStorage, survives across visits-
    "custom"delegates to the app’s storegetState + setState

    storageKey (built-in stores only) defaults to "cs-studio:state".

  3. For custom, wire all three functions, and don’t skip subscribe.

    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 unsubscribe
      variables: { "cart:count": { type: "number", defaultValue: 0 } },
    }

    getState/setState are mandatory for custom. Without subscribe the flow is one-way: Studio’s own writes land, but a dispatch from elsewhere in the app never re-renders Studio-bound components. Wire it whenever anything outside Studio mutates these keys.

    A getState returning 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.

  4. Read/write from the right place.

    • In React, use const [v, setV] = useStudioState<T>(key), setV(next) or setV(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.
  5. Register functions if authors should choose behavior. ctx is { 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.

  6. Expose action props so authors can attach them. action is a first-class prop type alongside string, number, boolean, choice, href, imageurl, datestring, array, object, slot, json_rte, any, see register-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, so onSelect(id, meta) loses nothing. From your own code, call callStudioFunction("cart:add", { sku }).

  7. 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 layout
    registerGlobalStateVariables(studioStateConfig.variables ?? {});

    That declares at the init tier, so component declarations still win.

  8. 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, subscribe is missing. For an action prop: bind it, click in the canvas, and confirm the registered function ran.

Inputs Needed From the User

  1. variables: the keys to share, each with type + default (and options for choice). Derive candidates from the app: values already lifted into a store or passed through several components.
  2. storage: session / local / custom. Default session unless the app already owns the state.
  3. getState / setState / subscribe: required for custom. Ask which store and how it reads/writes.
  4. functions: named logic authors should be able to attach, if any.

Acceptance

  • Preflight passed: the installed @contentstack/studio-react actually 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.
  • storage is set explicitly, and for custom both getState and setState exist.
  • subscribe is 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 action props, not hardcoded in the component.
  • SSR apps statically import a module calling registerGlobalStateVariables. No hydration mismatch on state-bound nodes.

Common Pitfalls

PitfallWhy it bitesFix
Expecting an undeclared key in the Data PickerOnly declared variables are author-bindable. Ad-hoc keys are code-only by designDeclare it at the init or component tier
storage: "custom" without getState/setStateThe SDK can’t read or write a store it doesn’t knowBoth are mandatory for custom
Custom store without subscribeOne-way: external mutations never re-render Studio-bound components. Looks like a stale-render bugPass subscribe(key, listener) returning an unsubscribe
Lazy client init on an SSR appClient declarations evaluate after hydration, so state-bound nodes mismatch the server HTMLStatic import + registerGlobalStateVariables (step 7)
Assuming init state.variables overrides a component declarationComponent tier wins by design, regardless of module orderComponent declaration is authoritative. Init covers keys no component owns
Interactive components with no action propsAuthors can’t attach behavior: interactivity stays hardcoded and the component is a black boxAdd type: "action" props and register the functions
Unnamespaced function namesFlat 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 dataShared mutable state is the wrong tool for a value fetched oncewire-external-data‘s data prop

See Also