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"):
getSnapshot()retrieves the current state from your store.immutableSetByPathproduces a new state object with structural sharing (only the changed branch is cloned).- If the value actually changed,
setSnapshot(next)pushes the new state into your store. - 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
Renderertree (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
Rendererinstances 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
statefield 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
immutableSetByPathinternally, 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 singlesetSnapshotcall with both changes applied. - No-op writes are skipped. If
set()is called with a value identical to what's already at that path,setSnapshotis never called, avoiding unnecessary re-renders. - Initialize your external store with the spec's initial state. The spec's
statefield won't be applied automatically when using an adapter — your store should already contain the matching initial values.