Prototyper UI

Store Adapters

Integrate Stream UI with external state managers like Zustand, Redux, or Jotai.

By default, Stream UI uses its own lightweight reactive store (createStateStore) to manage spec state. This works well for self-contained UIs, but when your app already has a state management layer, you may want stream-ui to read from and write to that same store. Store adapters bridge the gap: they wrap an external store so it conforms to the StateStore interface that the engine expects.

The StateStore Interface

Every store in stream-ui, whether built-in or adapted, implements this interface:

Prop

Type

The built-in createStateStore holds an immutable snapshot in memory, uses structural sharing on writes, and notifies listeners only when the root reference changes. createStoreAdapter produces an object with the same shape, but delegates to your external store for storage and subscription.

createStoreAdapter

import { createStoreAdapter } from "@prototyperai/stream-ui/core"
import type { StoreAdapterConfig } from "@prototyperai/stream-ui/core"

const adapter = createStoreAdapter({
  getSnapshot: () => /* return current state object */,
  setSnapshot: (next) => /* replace state with next */,
  subscribe: (listener) => /* register listener, return unsubscribe */,
})

Prop

Type

The adapter handles JSON Pointer path resolution, immutable updates with structural sharing, and batched multi-path writes internally. Your config only needs to provide raw get/set/subscribe for the full state object.

How It Works

When the engine calls adapter.set("/form/email", "a@b.com"):

  1. getSnapshot() retrieves the current state from your store.
  2. immutableSetByPath produces a new state object with structural sharing (only the changed branch is cloned).
  3. If the value actually changed, setSnapshot(next) pushes the new state into your store.
  4. Your store's own subscription mechanism notifies listeners, including the stream-ui renderer.

The update() method works the same way but batches multiple path writes into a single setSnapshot call.

Wiring the Adapter

The StreamUIProvider currently creates an internal createStateStore automatically. To use a store adapter, compose the lower-level providers directly and pass your adapter to StateProvider:

import { StateProvider, ActionProvider, FunctionsProvider } from "@prototyperai/stream-ui"
import { ElementRenderer } from "@prototyperai/stream-ui"

function CustomRenderer({ spec, registry, storeAdapter, handlers }) {
  return (
    <StateProvider store={storeAdapter}>
      <ActionProvider handlers={handlers}>
        <FunctionsProvider>
          <ElementRenderer elementKey={spec.root} />
        </FunctionsProvider>
      </ActionProvider>
    </StateProvider>
  )
}

The StateProvider accepts any object implementing the StateStore interface, which is exactly what createStoreAdapter returns.

Zustand Example

Zustand stores expose getState, setState, and subscribe directly, making the adapter setup straightforward.

import { create } from "zustand"
import { createStoreAdapter } from "@prototyperai/stream-ui/core"

const useAppStore = create(() => ({
  form: { name: "", email: "" },
  theme: "light",
  items: [],
}))

const storeAdapter = createStoreAdapter({
  getSnapshot: () => useAppStore.getState(),
  setSnapshot: (next) => useAppStore.setState(next, true), // true = replace (not merge)
  subscribe: (listener) => useAppStore.subscribe(listener),
})

Pass true as the second argument to setState so the state is replaced rather than shallow-merged. The adapter already handles immutable updates with structural sharing, so a full replacement is correct.

Now Zustand and stream-ui share the same state. Reading /form/email in a spec expression reads from the Zustand store, and writing via $bindState updates it. You can also read the state in your own components with the normal Zustand hook:

function Header() {
  const theme = useAppStore((s) => s.theme)
  return <header className={theme === "dark" ? "bg-gray-900" : "bg-white"}>...</header>
}

Redux Toolkit Example

Redux stores have a similar shape. The key difference is that getState() returns the full Redux state tree, so you may want to scope the adapter to a specific slice.

import { configureStore, createSlice } from "@reduxjs/toolkit"
import { createStoreAdapter } from "@prototyperai/stream-ui/core"

const uiSlice = createSlice({
  name: "ui",
  initialState: {
    form: { name: "", email: "" },
    step: 0,
  },
  reducers: {
    replaceState: (_state, action) => action.payload,
  },
})

const reduxStore = configureStore({
  reducer: { ui: uiSlice.reducer },
})

const storeAdapter = createStoreAdapter({
  getSnapshot: () => reduxStore.getState().ui,
  setSnapshot: (next) => reduxStore.dispatch(uiSlice.actions.replaceState(next)),
  subscribe: (listener) => reduxStore.subscribe(listener),
})

The adapter reads and writes only the ui slice. Other Redux slices remain untouched. Because subscribe fires on any Redux state change, the adapter's internal === check on the snapshot prevents unnecessary re-renders when unrelated slices update.

Jotai Example

Jotai is atom-based, so the adapter wraps a single atom that holds the full state object.

import { createStore, atom } from "jotai"
import { createStoreAdapter } from "@prototyperai/stream-ui/core"

const stateAtom = atom({
  form: { name: "", email: "" },
  count: 0,
})

const jotaiStore = createStore()

const storeAdapter = createStoreAdapter({
  getSnapshot: () => jotaiStore.get(stateAtom),
  setSnapshot: (next) => jotaiStore.set(stateAtom, next),
  subscribe: (listener) => jotaiStore.sub(stateAtom, listener),
})

Jotai's createStore() provides an imperative API (get, set, sub) that maps directly to the adapter config. The sub method returns an unsubscribe function, matching the expected signature.

When to Use a Store Adapter

Use an adapter when:

  • Your app already has a Zustand/Redux/Jotai store and you want stream-ui state to live alongside app state in a single source of truth.
  • You need to read stream-ui state from components outside the Renderer tree (e.g., a global header that reacts to form progress).
  • You want to persist state to localStorage, sync it across tabs, or apply middleware (logging, undo/redo) through your existing store's ecosystem.
  • Multiple Renderer instances need to share the same state.

Stick with the built-in store when:

  • The stream-ui component is self-contained and doesn't need to share state with the rest of your app.
  • You want the simplest setup with no external dependencies.
  • The spec's state field fully describes the initial state and nothing outside the renderer needs to read or write it.

Tips

  • Structural sharing is handled for you. The adapter uses immutableSetByPath internally, so even if your external store does reference-equality checks (like Zustand selectors), only changed branches get new references.
  • Batch writes are atomic. When the engine calls update({ "/a": 1, "/b": 2 }), your store receives a single setSnapshot call with both changes applied.
  • No-op writes are skipped. If set() is called with a value identical to what's already at that path, setSnapshot is never called, avoiding unnecessary re-renders.
  • Initialize your external store with the spec's initial state. The spec's state field won't be applied automatically when using an adapter — your store should already contain the matching initial values.

On this page