فهرست منبع

external store adapter for state management (#139)

* external store adapter for state management

Introduces a `StateStore` interface that lets users plug in their own state management (Redux, Zustand, XState, etc.) instead of being locked into the internal `useState`-based store.

- Added `StateStore` interface and `createStateStore()` factory to `@json-render/core`
- `StateProvider`, `JSONUIProvider`, and `createRenderer` now accept an optional `store` prop for controlled mode
- When `store` is provided, it becomes the single source of truth (`initialState`/`onStateChange` are ignored)
- When `store` is omitted, everything works exactly as before (fully backward compatible)
- Applied across all platform packages: react, react-native, react-pdf

* improvements

* update docs

* improvements

* fixes

* fix CI

* add store adapters

* fixes

* fixes

* fixes

* fixes

* e2e tests

* improvements

* fixes

* fixes

* fixes

* fixes

* update lockfile for widened react peer deps

* fix dashboard build
Chris Tate 4 ماه پیش
والد
کامیت
3201854481
100فایلهای تغییر یافته به همراه3461 افزوده شده و 763 حذف شده
  1. 16 0
      AGENTS.md
  2. 3 0
      README.md
  3. 26 1
      apps/web/app/(main)/docs/api/react-native/page.mdx
  4. 17 1
      apps/web/app/(main)/docs/api/react-pdf/page.mdx
  5. 26 1
      apps/web/app/(main)/docs/api/react/page.mdx
  6. 20 0
      apps/web/app/(main)/docs/changelog/page.mdx
  7. 19 0
      apps/web/app/(main)/docs/data-binding/page.mdx
  8. 1 1
      apps/web/eslint.config.js
  9. 2 2
      apps/web/package.json
  10. 1 1
      apps/web/tsconfig.json
  11. 1 1
      examples/chat/eslint.config.js
  12. 1 1
      examples/chat/package.json
  13. 21 17
      examples/dashboard/components/widget.tsx
  14. 1 1
      examples/dashboard/eslint.config.js
  15. 1 1
      examples/dashboard/lib/render/renderer.tsx
  16. 1 1
      examples/dashboard/package.json
  17. 1 1
      examples/no-ai/eslint.config.js
  18. 1 1
      examples/no-ai/package.json
  19. 1 1
      examples/react-pdf/package.json
  20. 1 1
      examples/remotion/package.json
  21. 1 1
      examples/stripe-app/drawer-app/eslint.config.js
  22. 1 1
      examples/stripe-app/drawer-app/package.json
  23. 1 1
      examples/stripe-app/drawer-app/src/lib/render/renderer.tsx
  24. 1 1
      examples/stripe-app/fullpage-app/eslint.config.js
  25. 1 1
      examples/stripe-app/fullpage-app/package.json
  26. 1 1
      examples/stripe-app/fullpage-app/src/lib/render/renderer.tsx
  27. 1 0
      package.json
  28. 1 1
      packages/codegen/package.json
  29. 1 1
      packages/codegen/tsconfig.json
  30. 57 0
      packages/core/README.md
  31. 6 1
      packages/core/package.json
  32. 9 0
      packages/core/src/env.d.ts
  33. 5 0
      packages/core/src/index.ts
  34. 213 0
      packages/core/src/state-store.test.ts
  35. 231 0
      packages/core/src/state-store.ts
  36. 6 0
      packages/core/src/store-utils.ts
  37. 31 1
      packages/core/src/types.ts
  38. 1 1
      packages/core/tsconfig.json
  39. 1 1
      packages/core/tsup.config.ts
  40. 1 1
      packages/eslint-config/package.json
  41. 63 0
      packages/jotai/README.md
  42. 54 0
      packages/jotai/package.json
  43. 147 0
      packages/jotai/src/index.test.ts
  44. 62 0
      packages/jotai/src/index.ts
  45. 9 0
      packages/jotai/tsconfig.json
  46. 10 0
      packages/jotai/tsup.config.ts
  47. 21 0
      packages/react-native/README.md
  48. 4 3
      packages/react-native/package.json
  49. 8 158
      packages/react-native/src/contexts/state.tsx
  50. 2 1
      packages/react-native/src/index.ts
  51. 29 8
      packages/react-native/src/renderer.tsx
  52. 1 1
      packages/react-native/tsconfig.json
  53. 13 0
      packages/react-pdf/README.md
  54. 3 2
      packages/react-pdf/package.json
  55. 49 0
      packages/react-pdf/src/contexts/state.test.tsx
  56. 8 117
      packages/react-pdf/src/contexts/state.tsx
  57. 2 1
      packages/react-pdf/src/index.ts
  58. 17 4
      packages/react-pdf/src/renderer.tsx
  59. 1 1
      packages/react-pdf/tsconfig.json
  60. 41 0
      packages/react-state/package.json
  61. 9 0
      packages/react-state/src/env.d.ts
  62. 332 0
      packages/react-state/src/index.test.tsx
  63. 220 0
      packages/react-state/src/index.tsx
  64. 9 0
      packages/react-state/tsconfig.json
  65. 10 0
      packages/react-state/tsup.config.ts
  66. 16 0
      packages/react-state/vitest.config.ts
  67. 31 0
      packages/react/README.md
  68. 3 2
      packages/react/package.json
  69. 14 146
      packages/react/src/contexts/state.test.tsx
  70. 8 158
      packages/react/src/contexts/state.tsx
  71. 2 1
      packages/react/src/index.ts
  72. 29 8
      packages/react/src/renderer.tsx
  73. 1 1
      packages/react/tsconfig.json
  74. 61 0
      packages/redux/README.md
  75. 64 0
      packages/redux/package.json
  76. 230 0
      packages/redux/src/index.test.ts
  77. 88 0
      packages/redux/src/index.ts
  78. 9 0
      packages/redux/tsconfig.json
  79. 15 0
      packages/redux/tsup.config.ts
  80. 1 1
      packages/remotion/package.json
  81. 1 1
      packages/remotion/tsconfig.json
  82. 1 1
      packages/shadcn/package.json
  83. 1 1
      packages/shadcn/tsconfig.json
  84. 1 1
      packages/typescript-config/package.json
  85. 1 1
      packages/ui/eslint.config.mjs
  86. 3 3
      packages/ui/package.json
  87. 1 1
      packages/ui/tsconfig.json
  88. 63 0
      packages/zustand/README.md
  89. 54 0
      packages/zustand/package.json
  90. 198 0
      packages/zustand/src/index.test.ts
  91. 83 0
      packages/zustand/src/index.ts
  92. 9 0
      packages/zustand/tsconfig.json
  93. 10 0
      packages/zustand/tsup.config.ts
  94. 303 93
      pnpm-lock.yaml
  95. 1 0
      pnpm-workspace.yaml
  96. 22 0
      skills/json-render-core/SKILL.md
  97. 19 1
      skills/json-render-react-native/SKILL.md
  98. 20 1
      skills/json-render-react/SKILL.md
  99. 22 0
      tests/e2e/package.json
  100. 221 0
      tests/e2e/state-store-e2e.test.ts

+ 16 - 0
AGENTS.md

@@ -27,6 +27,21 @@ This ensures we don't install outdated versions that may have incompatible types
 - Do not use emojis in code or UI
 - Use shadcn CLI to add shadcn/ui components: `pnpm dlx shadcn@latest add <component>`
 
+## AI SDK / AI Gateway
+
+When using the Vercel AI SDK (`ai` package) with AI Gateway, pass the model as a plain string identifier -- do not import a provider constructor:
+
+```ts
+import { streamText } from "ai";
+
+const result = streamText({
+  model: "anthropic/claude-haiku-4.5",
+  prompt: "...",
+});
+```
+
+This requires `AI_GATEWAY_API_KEY` to be set in the environment. See `tests/e2e/` for examples.
+
 ## Dev Servers
 
 All apps and examples with dev servers use [portless](https://github.com/vercel-labs/portless) to avoid hardcoded ports. Portless assigns random ports and exposes each app via `.localhost` URLs.
@@ -53,6 +68,7 @@ Do **not** add `--port` flags -- portless handles port assignment automatically.
 - When making user-facing changes (new packages, API changes, new features, renamed exports, changed behavior), update the relevant documentation:
   - Package `README.md` files in `packages/*/README.md`
   - Root `README.md` (if packages table, install commands, or examples are affected)
+  - Web app docs in `apps/web/` (if guides, API references, or examples need updating)
   - Skills in `skills/*/SKILL.md` (if the package has a corresponding skill)
   - `AGENTS.md` (if workflow or conventions change)
 

+ 3 - 0
README.md

@@ -114,6 +114,9 @@ function Dashboard({ spec }) {
 | `@json-render/react-native` | React Native renderer with standard mobile components |
 | `@json-render/remotion` | Remotion video renderer, timeline schema |
 | `@json-render/react-pdf` | React PDF renderer for generating PDF documents from specs |
+| `@json-render/redux` | Redux / Redux Toolkit adapter for `StateStore` |
+| `@json-render/zustand` | Zustand adapter for `StateStore` |
+| `@json-render/jotai` | Jotai adapter for `StateStore` |
 
 ## Renderers
 

+ 26 - 1
apps/web/app/(main)/docs/api/react-native/page.mdx

@@ -63,11 +63,36 @@ React Native renderer with standard components, providers, and hooks.
 ### StateProvider
 
 ```tsx
-<StateProvider initialState={object}>
+<StateProvider initialState={object} onStateChange={fn}>
   {children}
 </StateProvider>
 ```
 
+| Prop | Type | Description |
+|------|------|-------------|
+| `store` | `StateStore` | External store (controlled mode). When provided, `initialState` and `onStateChange` are ignored. |
+| `initialState` | `Record<string, unknown>` | Initial state model (uncontrolled mode). |
+| `onStateChange` | `(changes: Array<{ path: string; value: unknown }>) => void` | Callback when state changes (uncontrolled mode). Called once per `set` or `update` with all changed entries. |
+
+#### External Store (Controlled Mode)
+
+Pass a `StateStore` to bypass the internal state and wire json-render to any state management library:
+
+```tsx
+import { createStateStore, type StateStore } from "@json-render/react-native";
+
+const store = createStateStore({ count: 0 });
+
+<StateProvider store={store}>
+  {children}
+</StateProvider>
+
+// Mutate from anywhere — components re-render automatically:
+store.set("/count", 1);
+```
+
+The `store` prop is also available on `JSONUIProvider` and `createRenderer`.
+
 ### ActionProvider
 
 ```tsx

+ 17 - 1
apps/web/app/(main)/docs/api/react-pdf/page.mdx

@@ -108,14 +108,17 @@ const PDFRenderer = createRenderer(catalog, components);
 ```typescript
 interface CreateRendererProps {
   spec: Spec | null;
+  store?: StateStore;
   state?: Record<string, unknown>;
   onAction?: (actionName: string, params?: Record<string, unknown>) => void;
-  onStateChange?: (path: string, value: unknown) => void;
+  onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
   loading?: boolean;
   fallback?: ComponentRenderer;
 }
 ```
 
+When `store` is provided, `state` and `onStateChange` are ignored (controlled mode).
+
 ## Renderer
 
 The main component that renders a spec to `@react-pdf/renderer` elements.
@@ -344,6 +347,19 @@ Renders current page number and total pages. Format uses `{pageNumber}` and `{to
 }
 ```
 
+## External Store (Controlled Mode)
+
+Pass a `StateStore` to `StateProvider`, `JSONUIProvider`, or `createRenderer` for full control over state:
+
+```tsx
+import { createStateStore, type StateStore } from "@json-render/react-pdf";
+
+const store = createStateStore({ invoice: { total: 100 } });
+store.set("/invoice/total", 200);
+```
+
+When `store` is provided, `initialState` / `state` and `onStateChange` are ignored.
+
 ## Server-Safe Import
 
 Import schema and catalog definitions without pulling in React or `@react-pdf/renderer`:

+ 26 - 1
apps/web/app/(main)/docs/api/react/page.mdx

@@ -10,11 +10,36 @@ React components, providers, and hooks.
 ### StateProvider
 
 ```tsx
-<StateProvider initialState={object}>
+<StateProvider initialState={object} onStateChange={fn}>
   {children}
 </StateProvider>
 ```
 
+| Prop | Type | Description |
+|------|------|-------------|
+| `store` | `StateStore` | External store (controlled mode). When provided, `initialState` and `onStateChange` are ignored. |
+| `initialState` | `Record<string, unknown>` | Initial state model (uncontrolled mode). |
+| `onStateChange` | `(changes: Array<{ path: string; value: unknown }>) => void` | Callback when state changes (uncontrolled mode). Called once per `set` or `update` with all changed entries. |
+
+#### External Store (Controlled Mode)
+
+Pass a `StateStore` to bypass the internal state and wire json-render to any state management library:
+
+```tsx
+import { createStateStore, type StateStore } from "@json-render/react";
+
+const store = createStateStore({ count: 0 });
+
+<StateProvider store={store}>
+  {children}
+</StateProvider>
+
+// Mutate from anywhere — React re-renders automatically:
+store.set("/count", 1);
+```
+
+The `store` prop is also available on `JSONUIProvider` and `createRenderer`.
+
 ### ActionProvider
 
 ```tsx

+ 20 - 0
apps/web/app/(main)/docs/changelog/page.mdx

@@ -9,6 +9,26 @@ Notable changes and updates to json-render.
 
 February 2026
 
+### New: External State Store
+
+The `StateStore` interface lets you plug in your own state management (Redux, Zustand, Jotai, XState, etc.) instead of the built-in internal store. Pass a `store` prop to `StateProvider`, `JSONUIProvider`, or `createRenderer` for controlled mode.
+
+New adapter packages: `@json-render/redux`, `@json-render/zustand`, `@json-render/jotai`.
+
+### Changed: `onStateChange` signature updated (breaking)
+
+The `onStateChange` callback now receives a single array of changed entries instead of being called once per path. This makes batch updates via `update()` easier to handle:
+
+```ts
+// Before
+onStateChange?: (path: string, value: unknown) => void
+
+// After
+onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void
+```
+
+The callback is only called when a `set()` or `update()` call actually changes the state. A `set()` call produces a single-element array; an `update()` call produces one array with all changed paths.
+
 ### New: `@json-render/react-pdf`
 
 PDF renderer for json-render, powered by [`@react-pdf/renderer`](https://react-pdf.org/). Define catalogs and registries the same way as `@json-render/react`, but output PDF documents instead of web UI.

+ 19 - 0
apps/web/app/(main)/docs/data-binding/page.mdx

@@ -259,6 +259,25 @@ The condition uses the same [visibility](/docs/visibility) expression format.
   </table>
 </div>
 
+## External Store (Controlled Mode)
+
+For advanced use cases, you can pass a `StateStore` to `StateProvider` to use your own state management (Redux, Zustand, XState, etc.) instead of the built-in internal store:
+
+```tsx
+import { createStateStore, type StateStore } from "@json-render/react";
+
+const store = createStateStore({ user: { name: "Alice" } });
+
+<StateProvider store={store}>
+  {children}
+</StateProvider>
+
+// Mutate from anywhere — React re-renders automatically:
+store.set("/user/name", "Bob");
+```
+
+When `store` is provided, `initialState` and `onStateChange` are ignored. The store is the single source of truth. See the [React API reference](/docs/api/react#external-store-controlled-mode) for the full `StateStore` interface.
+
 ## Next
 
 - [Visibility](/docs/visibility) — conditionally show or hide elements

+ 1 - 1
apps/web/eslint.config.js

@@ -1,4 +1,4 @@
-import { nextJsConfig } from "@repo/eslint-config/next-js";
+import { nextJsConfig } from "@internal/eslint-config/next-js";
 
 /** @type {import("eslint").Linter.Config[]} */
 export default [

+ 2 - 2
apps/web/package.json

@@ -53,8 +53,8 @@
     "zod": "^4.0.0"
   },
   "devDependencies": {
-    "@repo/eslint-config": "workspace:*",
-    "@repo/typescript-config": "workspace:*",
+    "@internal/eslint-config": "workspace:*",
+    "@internal/typescript-config": "workspace:*",
     "@tailwindcss/postcss": "^4.1.18",
     "@types/mdx": "^2.0.13",
     "@types/node": "^22.15.3",

+ 1 - 1
apps/web/tsconfig.json

@@ -1,5 +1,5 @@
 {
-  "extends": "@repo/typescript-config/nextjs.json",
+  "extends": "@internal/typescript-config/nextjs.json",
   "compilerOptions": {
     "plugins": [
       {

+ 1 - 1
examples/chat/eslint.config.js

@@ -1,4 +1,4 @@
-import { nextJsConfig } from "@repo/eslint-config/next-js";
+import { nextJsConfig } from "@internal/eslint-config/next-js";
 
 /** @type {import("eslint").Linter.Config[]} */
 export default [

+ 1 - 1
examples/chat/package.json

@@ -36,7 +36,7 @@
     "zod": "4.3.5"
   },
   "devDependencies": {
-    "@repo/eslint-config": "workspace:*",
+    "@internal/eslint-config": "workspace:*",
     "@tailwindcss/postcss": "^4.1.18",
     "@types/node": "^22.10.0",
     "@types/react": "19.2.3",

+ 21 - 17
examples/dashboard/components/widget.tsx

@@ -196,24 +196,28 @@ export function Widget({
     onDeleted?.();
   }, [widgetId, onDeleted]);
 
-  const handleStateChange = useCallback((path: string, value: unknown) => {
-    setState((prev) => {
-      const next = { ...prev };
-      // Convert path like "customerForm/name" to nested object
-      const parts = path.split("/");
-      let current: Record<string, unknown> = next;
-      for (let i = 0; i < parts.length - 1; i++) {
-        const part = parts[i]!;
-        if (!(part in current) || typeof current[part] !== "object") {
-          current[part] = {};
+  const handleStateChange = useCallback(
+    (changes: Array<{ path: string; value: unknown }>) => {
+      setState((prev) => {
+        const next = { ...prev };
+        for (const { path, value } of changes) {
+          const parts = path.split("/");
+          let current: Record<string, unknown> = next;
+          for (let i = 0; i < parts.length - 1; i++) {
+            const part = parts[i]!;
+            if (!(part in current) || typeof current[part] !== "object") {
+              current[part] = {};
+            }
+            current = current[part] as Record<string, unknown>;
+          }
+          const lastPart = parts[parts.length - 1]!;
+          current[lastPart] = value;
         }
-        current = current[part] as Record<string, unknown>;
-      }
-      const lastPart = parts[parts.length - 1]!;
-      current[lastPart] = value;
-      return next;
-    });
-  }, []);
+        return next;
+      });
+    },
+    [],
+  );
 
   // Use spec from stream, or initial spec for saved widgets
   const currentSpec = spec || initialSpec;

+ 1 - 1
examples/dashboard/eslint.config.js

@@ -1,4 +1,4 @@
-import { nextJsConfig } from "@repo/eslint-config/next-js";
+import { nextJsConfig } from "@internal/eslint-config/next-js";
 
 /** @type {import("eslint").Linter.Config[]} */
 export default [

+ 1 - 1
examples/dashboard/lib/render/renderer.tsx

@@ -24,7 +24,7 @@ interface DashboardRendererProps {
   spec: Spec | null;
   state?: Record<string, unknown>;
   setState?: SetState;
-  onStateChange?: (path: string, value: unknown) => void;
+  onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
   loading?: boolean;
 }
 

+ 1 - 1
examples/dashboard/package.json

@@ -42,7 +42,7 @@
     "zod": "^4.0.0"
   },
   "devDependencies": {
-    "@repo/eslint-config": "workspace:*",
+    "@internal/eslint-config": "workspace:*",
     "@tailwindcss/postcss": "^4.1.18",
     "@types/node": "^22.10.0",
     "@types/react": "19.2.3",

+ 1 - 1
examples/no-ai/eslint.config.js

@@ -1,4 +1,4 @@
-import { nextJsConfig } from "@repo/eslint-config/next-js";
+import { nextJsConfig } from "@internal/eslint-config/next-js";
 
 /** @type {import("eslint").Linter.Config[]} */
 export default [

+ 1 - 1
examples/no-ai/package.json

@@ -26,7 +26,7 @@
     "zod": "4.3.5"
   },
   "devDependencies": {
-    "@repo/eslint-config": "workspace:*",
+    "@internal/eslint-config": "workspace:*",
     "@tailwindcss/postcss": "^4.1.18",
     "@types/node": "^22.10.0",
     "@types/react": "19.2.3",

+ 1 - 1
examples/react-pdf/package.json

@@ -28,7 +28,7 @@
     "zod": "4.3.5"
   },
   "devDependencies": {
-    "@repo/typescript-config": "workspace:*",
+    "@internal/typescript-config": "workspace:*",
     "@types/node": "^22.10.0",
     "@types/react": "19.2.3",
     "@types/react-dom": "19.2.3",

+ 1 - 1
examples/remotion/package.json

@@ -29,7 +29,7 @@
     "zod": "^4.0.0"
   },
   "devDependencies": {
-    "@repo/eslint-config": "workspace:*",
+    "@internal/eslint-config": "workspace:*",
     "@tailwindcss/postcss": "^4.1.18",
     "@types/node": "^22.10.0",
     "@types/react": "19.2.3",

+ 1 - 1
examples/stripe-app/drawer-app/eslint.config.js

@@ -1,4 +1,4 @@
-import { config as reactConfig } from "@repo/eslint-config/react-internal";
+import { config as reactConfig } from "@internal/eslint-config/react-internal";
 
 /** @type {import("eslint").Linter.Config[]} */
 export default [

+ 1 - 1
examples/stripe-app/drawer-app/package.json

@@ -21,7 +21,7 @@
     "test": "jest"
   },
   "devDependencies": {
-    "@repo/eslint-config": "workspace:*",
+    "@internal/eslint-config": "workspace:*",
     "@stripe/ui-extension-tools": "^0.0.1",
     "eslint": "^9.39.0"
   }

+ 1 - 1
examples/stripe-app/drawer-app/src/lib/render/renderer.tsx

@@ -27,7 +27,7 @@ export interface StripeRendererProps {
   /** Function to update data */
   setData?: SetState;
   /** Callback when data changes */
-  onStateChange?: (path: string, value: unknown) => void;
+  onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
   /** Whether the spec is currently loading/streaming */
   loading?: boolean;
 }

+ 1 - 1
examples/stripe-app/fullpage-app/eslint.config.js

@@ -1,4 +1,4 @@
-import { config as reactConfig } from "@repo/eslint-config/react-internal";
+import { config as reactConfig } from "@internal/eslint-config/react-internal";
 
 /** @type {import("eslint").Linter.Config[]} */
 export default [

+ 1 - 1
examples/stripe-app/fullpage-app/package.json

@@ -21,7 +21,7 @@
     "test": "jest"
   },
   "devDependencies": {
-    "@repo/eslint-config": "workspace:*",
+    "@internal/eslint-config": "workspace:*",
     "@stripe/ui-extension-tools": "^0.0.1",
     "eslint": "^9.39.0"
   }

+ 1 - 1
examples/stripe-app/fullpage-app/src/lib/render/renderer.tsx

@@ -27,7 +27,7 @@ export interface StripeRendererProps {
   /** Function to update data */
   setData?: SetState;
   /** Callback when data changes */
-  onStateChange?: (path: string, value: unknown) => void;
+  onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
   /** Whether the spec is currently loading/streaming */
   loading?: boolean;
 }

+ 1 - 0
package.json

@@ -20,6 +20,7 @@
     "test": "vitest run",
     "test:watch": "vitest",
     "test:coverage": "vitest run --coverage",
+    "test:e2e": "pnpm --filter e2e-tests test",
     "prepare": "husky",
     "changeset": "changeset",
     "ci:version": "changeset version && pnpm install --no-frozen-lockfile",

+ 1 - 1
packages/codegen/package.json

@@ -44,7 +44,7 @@
     "@json-render/core": "workspace:*"
   },
   "devDependencies": {
-    "@repo/typescript-config": "workspace:*",
+    "@internal/typescript-config": "workspace:*",
     "tsup": "^8.0.2",
     "typescript": "^5.4.5"
   }

+ 1 - 1
packages/codegen/tsconfig.json

@@ -1,5 +1,5 @@
 {
-  "extends": "@repo/typescript-config/base.json",
+  "extends": "@internal/typescript-config/base.json",
   "compilerOptions": {
     "outDir": "dist",
     "rootDir": "src"

+ 57 - 0
packages/core/README.md

@@ -221,6 +221,63 @@ Schema options:
 
 The transform splits text blocks around spec data by emitting `text-end`/`text-start` pairs, ensuring the AI SDK creates separate text parts and preserving correct interleaving of prose and UI in `message.parts`.
 
+### State Store
+
+| Export | Purpose |
+|--------|---------|
+| `createStateStore(initialState?)` | Create a framework-agnostic in-memory `StateStore` |
+| `StateStore` | Interface for plugging in external state management (Redux, Zustand, XState, etc.) |
+| `StateModel` | State model type (`Record<string, unknown>`) |
+
+The `StateStore` interface allows renderers to use external state management instead of the built-in internal store:
+
+```typescript
+import { createStateStore, type StateStore } from "@json-render/core";
+
+// Simple in-memory store
+const store = createStateStore({ count: 0 });
+
+store.get("/count");          // 0
+store.set("/count", 1);       // updates and notifies subscribers
+store.getSnapshot();          // { count: 1 }
+
+// Subscribe to changes (compatible with React's useSyncExternalStore)
+const unsubscribe = store.subscribe(() => {
+  console.log("state changed:", store.getSnapshot());
+});
+```
+
+Pass the store to `StateProvider` in any renderer package (`@json-render/react`, `@json-render/react-native`, `@json-render/react-pdf`) for controlled mode.
+
+### Store Utilities (for adapter authors)
+
+Available via `@json-render/core/store-utils`:
+
+| Export | Purpose |
+|--------|---------|
+| `createStoreAdapter(config)` | Build a full `StateStore` from a minimal `{ getSnapshot, setSnapshot, subscribe }` config |
+| `immutableSetByPath(root, path, value)` | Immutably set a value at a JSON Pointer path with structural sharing |
+| `flattenToPointers(obj)` | Flatten a nested object into JSON Pointer keyed entries |
+| `StoreAdapterConfig` | Config type for `createStoreAdapter` |
+
+```typescript
+import { createStoreAdapter, immutableSetByPath, flattenToPointers } from "@json-render/core/store-utils";
+```
+
+`createStoreAdapter` handles `get`, `set` (with no-op detection), batched `update`, `getSnapshot`, `getServerSnapshot`, and `subscribe` -- adapter authors only need to supply the snapshot source, write API, and subscribe mechanism:
+
+```typescript
+import { createStoreAdapter } from "@json-render/core/store-utils";
+
+const store = createStoreAdapter({
+  getSnapshot: () => myLib.getState(),
+  setSnapshot: (next) => myLib.setState(next),
+  subscribe: (listener) => myLib.subscribe(listener),
+});
+```
+
+The official adapter packages (`@json-render/redux`, `@json-render/zustand`, `@json-render/jotai`) are all built on top of `createStoreAdapter`.
+
 ### Types
 
 | Export | Purpose |

+ 6 - 1
packages/core/package.json

@@ -34,6 +34,11 @@
       "types": "./dist/index.d.ts",
       "import": "./dist/index.mjs",
       "require": "./dist/index.js"
+    },
+    "./store-utils": {
+      "types": "./dist/store-utils.d.ts",
+      "import": "./dist/store-utils.mjs",
+      "require": "./dist/store-utils.js"
     }
   },
   "files": [
@@ -48,7 +53,7 @@
     "zod": "^4.0.0"
   },
   "devDependencies": {
-    "@repo/typescript-config": "workspace:*",
+    "@internal/typescript-config": "workspace:*",
     "tsup": "^8.0.2",
     "typescript": "^5.4.5"
   },

+ 9 - 0
packages/core/src/env.d.ts

@@ -0,0 +1,9 @@
+// Minimal process.env typing for dev-only warnings.
+// Uses a namespaced interface so it merges cleanly with @types/node if present.
+declare namespace NodeJS {
+  interface ProcessEnv {
+    readonly NODE_ENV?: string;
+  }
+}
+
+declare const process: { readonly env: NodeJS.ProcessEnv };

+ 5 - 0
packages/core/src/index.ts

@@ -15,6 +15,7 @@ export type {
   AndCondition,
   OrCondition,
   StateModel,
+  StateStore,
   ComponentSchema,
   ValidationMode,
   PatchOp,
@@ -57,6 +58,10 @@ export {
   SPEC_DATA_PART_TYPE,
 } from "./types";
 
+// State Store
+export type { StoreAdapterConfig } from "./state-store";
+export { createStateStore } from "./state-store";
+
 // Visibility
 export type { VisibilityContext } from "./visibility";
 

+ 213 - 0
packages/core/src/state-store.test.ts

@@ -0,0 +1,213 @@
+import { describe, it, expect, vi } from "vitest";
+import { createStateStore, flattenToPointers } from "./state-store";
+
+describe("createStateStore", () => {
+  it("creates a store with initial state", () => {
+    const store = createStateStore({ name: "test" });
+    expect(store.getSnapshot()).toEqual({ name: "test" });
+    expect(store.get("/name")).toBe("test");
+  });
+
+  it("set notifies subscribers", () => {
+    const store = createStateStore({});
+    const listener = vi.fn();
+    store.subscribe(listener);
+
+    store.set("/x", 1);
+
+    expect(listener).toHaveBeenCalledTimes(1);
+    expect(store.get("/x")).toBe(1);
+  });
+
+  it("set skips notification when value is unchanged", () => {
+    const store = createStateStore({ x: 1 });
+    const listener = vi.fn();
+    store.subscribe(listener);
+
+    store.set("/x", 1);
+
+    expect(listener).not.toHaveBeenCalled();
+    expect(store.getSnapshot()).toEqual({ x: 1 });
+  });
+
+  it("update notifies subscribers once", () => {
+    const store = createStateStore({});
+    const listener = vi.fn();
+    store.subscribe(listener);
+
+    store.update({ "/a": 1, "/b": 2 });
+
+    expect(listener).toHaveBeenCalledTimes(1);
+    expect(store.get("/a")).toBe(1);
+    expect(store.get("/b")).toBe(2);
+  });
+
+  it("update skips notification when no values changed", () => {
+    const store = createStateStore({ a: 1, b: 2 });
+    const listener = vi.fn();
+    store.subscribe(listener);
+
+    store.update({ "/a": 1, "/b": 2 });
+
+    expect(listener).not.toHaveBeenCalled();
+  });
+
+  it("unsubscribe stops notifications", () => {
+    const store = createStateStore({});
+    const listener = vi.fn();
+    const unsubscribe = store.subscribe(listener);
+
+    store.set("/x", 1);
+    expect(listener).toHaveBeenCalledTimes(1);
+
+    unsubscribe();
+    store.set("/x", 2);
+    expect(listener).toHaveBeenCalledTimes(1);
+  });
+
+  it("getSnapshot returns a new reference after mutation", () => {
+    const store = createStateStore({ x: 1 });
+    const snap1 = store.getSnapshot();
+
+    store.set("/x", 2);
+    const snap2 = store.getSnapshot();
+
+    expect(snap1).not.toBe(snap2);
+    expect(snap2.x).toBe(2);
+  });
+
+  it("getSnapshot returns same reference when set is a no-op", () => {
+    const store = createStateStore({ x: 1 });
+    const snap1 = store.getSnapshot();
+
+    store.set("/x", 1);
+    const snap2 = store.getSnapshot();
+
+    expect(snap1).toBe(snap2);
+  });
+
+  it("set on nested path does not mutate previous snapshot", () => {
+    const store = createStateStore({ user: { name: "Alice", age: 30 } });
+    const snap1 = store.getSnapshot();
+
+    store.set("/user/name", "Bob");
+    const snap2 = store.getSnapshot();
+
+    expect(snap1.user).toEqual({ name: "Alice", age: 30 });
+    expect((snap2.user as Record<string, unknown>).name).toBe("Bob");
+    expect(snap1.user).not.toBe(snap2.user);
+  });
+
+  it("update on nested paths does not mutate previous snapshot", () => {
+    const store = createStateStore({
+      user: { name: "Alice" },
+      meta: { version: 1 },
+    });
+    const snap1 = store.getSnapshot();
+
+    store.update({ "/user/name": "Bob", "/meta/version": 2 });
+    const snap2 = store.getSnapshot();
+
+    expect((snap1.user as Record<string, unknown>).name).toBe("Alice");
+    expect((snap1.meta as Record<string, unknown>).version).toBe(1);
+    expect((snap2.user as Record<string, unknown>).name).toBe("Bob");
+    expect((snap2.meta as Record<string, unknown>).version).toBe(2);
+  });
+
+  it("set preserves structural sharing for untouched branches", () => {
+    const store = createStateStore({
+      a: { x: 1 },
+      b: { y: 2 },
+    });
+    const snap1 = store.getSnapshot();
+
+    store.set("/a/x", 99);
+    const snap2 = store.getSnapshot();
+
+    expect(snap2.b).toBe(snap1.b);
+    expect(snap2.a).not.toBe(snap1.a);
+  });
+
+  it("getServerSnapshot returns the same state as getSnapshot", () => {
+    const store = createStateStore({ x: 1 });
+    expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
+
+    store.set("/x", 2);
+    expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
+  });
+});
+
+describe("flattenToPointers", () => {
+  it("flattens top-level keys", () => {
+    expect(flattenToPointers({ a: 1, b: "hello" })).toEqual({
+      "/a": 1,
+      "/b": "hello",
+    });
+  });
+
+  it("flattens nested plain objects", () => {
+    expect(flattenToPointers({ user: { name: "Alice", age: 30 } })).toEqual({
+      "/user/name": "Alice",
+      "/user/age": 30,
+    });
+  });
+
+  it("preserves arrays as leaf values", () => {
+    expect(flattenToPointers({ items: [1, 2, 3] })).toEqual({
+      "/items": [1, 2, 3],
+    });
+  });
+
+  it("preserves null as a leaf value", () => {
+    expect(flattenToPointers({ x: null })).toEqual({ "/x": null });
+  });
+
+  it("handles deeply nested objects", () => {
+    expect(flattenToPointers({ a: { b: { c: 42 } } })).toEqual({
+      "/a/b/c": 42,
+    });
+  });
+
+  it("returns empty object for empty input", () => {
+    expect(flattenToPointers({})).toEqual({});
+  });
+
+  it("handles mixed nesting", () => {
+    expect(
+      flattenToPointers({
+        count: 1,
+        user: { name: "Alice" },
+        tags: ["a", "b"],
+      }),
+    ).toEqual({
+      "/count": 1,
+      "/user/name": "Alice",
+      "/tags": ["a", "b"],
+    });
+  });
+
+  it("stops recursion on circular references via seen set", () => {
+    const obj: Record<string, unknown> = { name: "root" };
+    obj.self = obj;
+
+    const result = flattenToPointers(obj);
+
+    expect(result["/name"]).toBe("root");
+    expect(result["/self/name"]).toBe("root");
+    expect(result["/self/self"]).toBe(obj);
+  });
+
+  it("caps recursion at depth limit", () => {
+    let current: Record<string, unknown> = { leaf: true };
+    for (let i = 0; i < 25; i++) {
+      current = { nested: current };
+    }
+
+    const result = flattenToPointers(current);
+
+    const keys = Object.keys(result);
+    expect(keys.length).toBe(1);
+    const key = keys[0]!;
+    expect(key.split("/").length).toBeLessThanOrEqual(22);
+  });
+});

+ 231 - 0
packages/core/src/state-store.ts

@@ -0,0 +1,231 @@
+import {
+  getByPath,
+  parseJsonPointer,
+  type StateModel,
+  type StateStore,
+} from "./types";
+
+/**
+ * Immutably set a value at a JSON Pointer path using structural sharing.
+ * Only objects along the path are shallow-cloned; untouched branches keep
+ * their original references.
+ */
+export function immutableSetByPath(
+  root: StateModel,
+  path: string,
+  value: unknown,
+): StateModel {
+  const segments = parseJsonPointer(path);
+  if (segments.length === 0) return root;
+
+  const result = { ...root };
+  let current: Record<string, unknown> = result;
+
+  for (let i = 0; i < segments.length - 1; i++) {
+    const seg = segments[i]!;
+    const child = current[seg];
+    if (Array.isArray(child)) {
+      current[seg] = [...child];
+    } else if (child !== null && typeof child === "object") {
+      current[seg] = { ...(child as Record<string, unknown>) };
+    } else {
+      const nextSeg = segments[i + 1];
+      current[seg] = nextSeg !== undefined && /^\d+$/.test(nextSeg) ? [] : {};
+    }
+    current = current[seg] as Record<string, unknown>;
+  }
+
+  const lastSeg = segments[segments.length - 1]!;
+  if (Array.isArray(current)) {
+    if (lastSeg === "-") {
+      (current as unknown[]).push(value);
+    } else {
+      (current as unknown[])[parseInt(lastSeg, 10)] = value;
+    }
+  } else {
+    current[lastSeg] = value;
+  }
+
+  return result;
+}
+
+/**
+ * Create a simple in-memory {@link StateStore}.
+ *
+ * This is the default store used by `StateProvider` when no external store is
+ * provided. It mirrors the previous `useState`-based behaviour but is
+ * framework-agnostic so it can also be used in tests or non-React contexts.
+ */
+export function createStateStore(initialState: StateModel = {}): StateStore {
+  let state: StateModel = { ...initialState };
+  const listeners = new Set<() => void>();
+
+  function notify() {
+    for (const listener of listeners) {
+      listener();
+    }
+  }
+
+  return {
+    get(path: string): unknown {
+      return getByPath(state, path);
+    },
+
+    set(path: string, value: unknown): void {
+      if (getByPath(state, path) === value) return;
+      state = immutableSetByPath(state, path, value);
+      notify();
+    },
+
+    update(updates: Record<string, unknown>): void {
+      let changed = false;
+      let next = state;
+      for (const [path, value] of Object.entries(updates)) {
+        if (getByPath(next, path) !== value) {
+          next = immutableSetByPath(next, path, value);
+          changed = true;
+        }
+      }
+      if (!changed) return;
+      state = next;
+      notify();
+    },
+
+    getSnapshot(): StateModel {
+      return state;
+    },
+
+    getServerSnapshot(): StateModel {
+      return state;
+    },
+
+    subscribe(listener: () => void): () => void {
+      listeners.add(listener);
+      return () => {
+        listeners.delete(listener);
+      };
+    },
+  };
+}
+
+/**
+ * Configuration for {@link createStoreAdapter}. Adapter authors supply these
+ * three callbacks; everything else (get, set, update, no-op detection,
+ * getServerSnapshot) is handled by the returned {@link StateStore}.
+ */
+export interface StoreAdapterConfig {
+  /** Return the current state snapshot from the underlying store. */
+  getSnapshot: () => StateModel;
+  /** Write a new state snapshot to the underlying store. */
+  setSnapshot: (next: StateModel) => void;
+  /** Subscribe to changes in the underlying store. Return an unsubscribe fn. */
+  subscribe: (listener: () => void) => () => void;
+}
+
+/**
+ * Build a full {@link StateStore} from a minimal adapter config.
+ *
+ * Handles `get`, `set` (with no-op detection), `update` (batched, with no-op
+ * detection), `getSnapshot`, `getServerSnapshot`, and `subscribe` -- so each
+ * adapter only needs to wire its snapshot source, write API, and subscribe
+ * mechanism.
+ */
+export function createStoreAdapter(config: StoreAdapterConfig): StateStore {
+  return {
+    get(path: string): unknown {
+      return getByPath(config.getSnapshot(), path);
+    },
+
+    set(path: string, value: unknown): void {
+      const current = config.getSnapshot();
+      if (getByPath(current, path) === value) return;
+      config.setSnapshot(immutableSetByPath(current, path, value));
+    },
+
+    update(updates: Record<string, unknown>): void {
+      let next = config.getSnapshot();
+      let changed = false;
+      for (const [path, value] of Object.entries(updates)) {
+        if (getByPath(next, path) !== value) {
+          next = immutableSetByPath(next, path, value);
+          changed = true;
+        }
+      }
+      if (!changed) return;
+      config.setSnapshot(next);
+    },
+
+    getSnapshot: config.getSnapshot,
+
+    getServerSnapshot: config.getSnapshot,
+
+    subscribe: config.subscribe,
+  };
+}
+
+const MAX_FLATTEN_DEPTH = 20;
+
+/**
+ * Recursively flatten a plain object into a `Record<string, unknown>` keyed by
+ * JSON Pointer paths. Only leaf values (non-plain-object) appear in the output.
+ *
+ * Includes circular reference protection and a depth cap to prevent stack
+ * overflow on pathological inputs.
+ *
+ * ```ts
+ * flattenToPointers({ user: { name: "Alice" }, count: 1 })
+ * // => { "/user/name": "Alice", "/count": 1 }
+ * ```
+ */
+export function flattenToPointers(
+  obj: Record<string, unknown>,
+  prefix = "",
+  _depth = 0,
+  _seen?: Set<object>,
+  _warned?: { current: boolean },
+): Record<string, unknown> {
+  const seen = _seen ?? new Set<object>();
+  const warned = _warned ?? { current: false };
+  const result: Record<string, unknown> = {};
+  for (const [key, value] of Object.entries(obj)) {
+    const pointer = `${prefix}/${key}`;
+    if (
+      _depth < MAX_FLATTEN_DEPTH &&
+      value !== null &&
+      typeof value === "object" &&
+      !Array.isArray(value) &&
+      Object.getPrototypeOf(value) === Object.prototype &&
+      !seen.has(value)
+    ) {
+      seen.add(value);
+      Object.assign(
+        result,
+        flattenToPointers(
+          value as Record<string, unknown>,
+          pointer,
+          _depth + 1,
+          seen,
+          warned,
+        ),
+      );
+    } else {
+      if (
+        process.env.NODE_ENV !== "production" &&
+        !warned.current &&
+        _depth >= MAX_FLATTEN_DEPTH &&
+        value !== null &&
+        typeof value === "object" &&
+        !Array.isArray(value) &&
+        Object.getPrototypeOf(value) === Object.prototype &&
+        !seen.has(value as object)
+      ) {
+        warned.current = true;
+        console.warn(
+          `flattenToPointers: depth limit (${MAX_FLATTEN_DEPTH}) reached. Nested state beyond this depth will be treated as a leaf value.`,
+        );
+      }
+      result[pointer] = value;
+    }
+  }
+  return result;
+}

+ 6 - 0
packages/core/src/store-utils.ts

@@ -0,0 +1,6 @@
+export {
+  immutableSetByPath,
+  flattenToPointers,
+  createStoreAdapter,
+} from "./state-store";
+export type { StoreAdapterConfig } from "./state-store";

+ 31 - 1
packages/core/src/types.ts

@@ -178,6 +178,36 @@ export interface Spec {
  */
 export type StateModel = Record<string, unknown>;
 
+/**
+ * An abstract store that owns state and notifies subscribers on change.
+ *
+ * Consumers can supply their own implementation (backed by Redux, Zustand,
+ * XState, etc.) or use the built-in {@link createStateStore} for a simple
+ * in-memory store.
+ */
+export interface StateStore {
+  /** Read a value by JSON Pointer path. */
+  get: (path: string) => unknown;
+  /**
+   * Write a value by JSON Pointer path and notify subscribers.
+   * Equality is checked by reference (`===`), not deep comparison.
+   * Callers must pass a new object/array reference for changes to be detected.
+   */
+  set: (path: string, value: unknown) => void;
+  /**
+   * Write multiple values at once and notify subscribers (single notification).
+   * Each value is compared by reference (`===`); only paths whose value
+   * actually changed are applied.
+   */
+  update: (updates: Record<string, unknown>) => void;
+  /** Return the full state object (used by `useSyncExternalStore`). */
+  getSnapshot: () => StateModel;
+  /** Optional server snapshot for SSR (passed to `useSyncExternalStore`). Falls back to `getSnapshot` when omitted. */
+  getServerSnapshot?: () => StateModel;
+  /** Register a listener that is called on every state change. Returns an unsubscribe function. */
+  subscribe: (listener: () => void) => () => void;
+}
+
 /**
  * Component schema definition using Zod
  */
@@ -236,7 +266,7 @@ function unescapeJsonPointer(token: string): string {
 /**
  * Parse a JSON Pointer path into unescaped segments.
  */
-function parseJsonPointer(path: string): string[] {
+export function parseJsonPointer(path: string): string[] {
   const raw = path.startsWith("/") ? path.slice(1).split("/") : path.split("/");
   return raw.map(unescapeJsonPointer);
 }

+ 1 - 1
packages/core/tsconfig.json

@@ -1,5 +1,5 @@
 {
-  "extends": "@repo/typescript-config/react-library.json",
+  "extends": "@internal/typescript-config/react-library.json",
   "compilerOptions": {
     "outDir": "dist",
     "rootDir": "src"

+ 1 - 1
packages/core/tsup.config.ts

@@ -1,7 +1,7 @@
 import { defineConfig } from "tsup";
 
 export default defineConfig({
-  entry: ["src/index.ts"],
+  entry: ["src/index.ts", "src/store-utils.ts"],
   format: ["cjs", "esm"],
   dts: true,
   sourcemap: true,

+ 1 - 1
packages/eslint-config/package.json

@@ -1,5 +1,5 @@
 {
-  "name": "@repo/eslint-config",
+  "name": "@internal/eslint-config",
   "version": "0.0.0",
   "type": "module",
   "private": true,

+ 63 - 0
packages/jotai/README.md

@@ -0,0 +1,63 @@
+# @json-render/jotai
+
+Jotai adapter for json-render's `StateStore` interface. Wire a Jotai atom as the state backend for json-render.
+
+## Installation
+
+```bash
+npm install @json-render/jotai @json-render/core @json-render/react jotai
+```
+
+## Usage
+
+```ts
+import { atom } from "jotai";
+import { jotaiStateStore } from "@json-render/jotai";
+import { StateProvider } from "@json-render/react";
+
+// 1. Create an atom that holds the json-render state
+const uiAtom = atom<Record<string, unknown>>({ count: 0 });
+
+// 2. Create the json-render StateStore adapter
+const store = jotaiStateStore({ atom: uiAtom });
+
+// 3. Use it
+<StateProvider store={store}>
+  {/* json-render reads/writes go through Jotai */}
+</StateProvider>
+```
+
+### With a shared Jotai store
+
+If your app already uses a Jotai `<Provider>` with a custom store, pass it so both json-render and your components share the same state:
+
+```ts
+import { atom, createStore } from "jotai";
+import { Provider as JotaiProvider } from "jotai/react";
+import { jotaiStateStore } from "@json-render/jotai";
+import { StateProvider } from "@json-render/react";
+
+const jStore = createStore();
+const uiAtom = atom<Record<string, unknown>>({ count: 0 });
+
+const store = jotaiStateStore({ atom: uiAtom, store: jStore });
+
+<JotaiProvider store={jStore}>
+  <StateProvider store={store}>
+    {/* Both json-render and useAtom() see the same state */}
+  </StateProvider>
+</JotaiProvider>
+```
+
+## API
+
+### `jotaiStateStore(options)`
+
+Creates a `StateStore` backed by a Jotai atom.
+
+#### Options
+
+| Option | Type | Required | Description |
+|--------|------|----------|-------------|
+| `atom` | `WritableAtom<StateModel, [StateModel], void>` | Yes | A writable atom holding the state model |
+| `store` | Jotai `Store` | No | The Jotai store instance. Defaults to a new store created internally. Pass your own to share state with `<Provider>`. |

+ 54 - 0
packages/jotai/package.json

@@ -0,0 +1,54 @@
+{
+  "name": "@json-render/jotai",
+  "version": "0.8.0",
+  "license": "Apache-2.0",
+  "description": "Jotai adapter for json-render StateStore",
+  "keywords": [
+    "json-render",
+    "jotai",
+    "state-management",
+    "adapter"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/vercel-labs/json-render.git",
+    "directory": "packages/jotai"
+  },
+  "homepage": "https://github.com/vercel-labs/json-render#readme",
+  "bugs": {
+    "url": "https://github.com/vercel-labs/json-render/issues"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "main": "./dist/index.js",
+  "module": "./dist/index.mjs",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
+      "import": "./dist/index.mjs",
+      "require": "./dist/index.js"
+    }
+  },
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "tsup",
+    "dev": "tsup --watch",
+    "check-types": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@json-render/core": "workspace:*"
+  },
+  "peerDependencies": {
+    "jotai": ">=2.0.0"
+  },
+  "devDependencies": {
+    "@internal/typescript-config": "workspace:*",
+    "jotai": "^2.18.0",
+    "tsup": "^8.0.2",
+    "typescript": "^5.4.5"
+  }
+}

+ 147 - 0
packages/jotai/src/index.test.ts

@@ -0,0 +1,147 @@
+import { describe, it, expect, vi } from "vitest";
+import { atom } from "jotai";
+import { createStore } from "jotai/vanilla";
+import { jotaiStateStore } from "./index";
+
+function createTestStore(initial: Record<string, unknown> = {}) {
+  const stateAtom = atom<Record<string, unknown>>(initial);
+  const jStore = createStore();
+  const store = jotaiStateStore({ atom: stateAtom, store: jStore });
+  return { stateAtom, jStore, store };
+}
+
+describe("jotaiStateStore", () => {
+  it("get/set round-trip", () => {
+    const { store } = createTestStore({ count: 0 });
+
+    expect(store.get("/count")).toBe(0);
+
+    store.set("/count", 42);
+
+    expect(store.get("/count")).toBe(42);
+    expect(store.getSnapshot().count).toBe(42);
+  });
+
+  it("update round-trip with multiple values", () => {
+    const { store } = createTestStore({});
+
+    store.update({ "/a": 1, "/b": "hello" });
+
+    expect(store.get("/a")).toBe(1);
+    expect(store.get("/b")).toBe("hello");
+    expect(store.getSnapshot()).toEqual({ a: 1, b: "hello" });
+  });
+
+  it("subscribe fires on set", () => {
+    const { store } = createTestStore({});
+    const listener = vi.fn();
+    store.subscribe(listener);
+
+    store.set("/x", 1);
+
+    expect(listener).toHaveBeenCalledTimes(1);
+  });
+
+  it("subscribe fires on update", () => {
+    const { store } = createTestStore({});
+    const listener = vi.fn();
+    store.subscribe(listener);
+
+    store.update({ "/a": 1, "/b": 2 });
+
+    expect(listener).toHaveBeenCalledTimes(1);
+  });
+
+  it("unsubscribe stops notifications", () => {
+    const { store } = createTestStore({});
+    const listener = vi.fn();
+    const unsub = store.subscribe(listener);
+
+    store.set("/x", 1);
+    expect(listener).toHaveBeenCalledTimes(1);
+
+    unsub();
+    store.set("/x", 2);
+    expect(listener).toHaveBeenCalledTimes(1);
+  });
+
+  it("getSnapshot immutability -- previous snapshot is not mutated", () => {
+    const { store } = createTestStore({ user: { name: "Alice", age: 30 } });
+    const snap1 = store.getSnapshot();
+
+    store.set("/user/name", "Bob");
+    const snap2 = store.getSnapshot();
+
+    expect(snap1.user).toEqual({ name: "Alice", age: 30 });
+    expect((snap2.user as Record<string, unknown>).name).toBe("Bob");
+    expect(snap1.user).not.toBe(snap2.user);
+  });
+
+  it("structural sharing -- untouched branches keep references", () => {
+    const { store } = createTestStore({
+      a: { x: 1 },
+      b: { y: 2 },
+    });
+    const snap1 = store.getSnapshot();
+
+    store.set("/a/x", 99);
+    const snap2 = store.getSnapshot();
+
+    expect(snap2.b).toBe(snap1.b);
+    expect(snap2.a).not.toBe(snap1.a);
+  });
+
+  it("getServerSnapshot returns same as getSnapshot", () => {
+    const { store } = createTestStore({ x: 1 });
+
+    expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
+
+    store.set("/x", 2);
+    expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
+  });
+
+  it("set skips update when value is unchanged", () => {
+    const { store } = createTestStore({ x: 1 });
+    const snap1 = store.getSnapshot();
+    const listener = vi.fn();
+    store.subscribe(listener);
+
+    store.set("/x", 1);
+
+    expect(listener).not.toHaveBeenCalled();
+    expect(store.getSnapshot()).toBe(snap1);
+  });
+
+  it("update skips update when no values changed", () => {
+    const { store } = createTestStore({ a: 1, b: 2 });
+    const snap1 = store.getSnapshot();
+    const listener = vi.fn();
+    store.subscribe(listener);
+
+    store.update({ "/a": 1, "/b": 2 });
+
+    expect(listener).not.toHaveBeenCalled();
+    expect(store.getSnapshot()).toBe(snap1);
+  });
+
+  it("reads from the shared Jotai store", () => {
+    const stateAtom = atom<Record<string, unknown>>({ count: 0 });
+    const jStore = createStore();
+    const store = jotaiStateStore({ atom: stateAtom, store: jStore });
+
+    jStore.set(stateAtom, { count: 99 });
+
+    expect(store.get("/count")).toBe(99);
+    expect(store.getSnapshot().count).toBe(99);
+  });
+
+  it("creates an internal store when none is provided", () => {
+    const stateAtom = atom<Record<string, unknown>>({ value: "hello" });
+    const store = jotaiStateStore({ atom: stateAtom });
+
+    expect(store.get("/value")).toBe("hello");
+
+    store.set("/value", "world");
+    expect(store.get("/value")).toBe("world");
+  });
+});

+ 62 - 0
packages/jotai/src/index.ts

@@ -0,0 +1,62 @@
+import type { StateModel, StateStore } from "@json-render/core";
+import { createStoreAdapter } from "@json-render/core/store-utils";
+import type { WritableAtom } from "jotai";
+import { createStore as createJotaiStore } from "jotai/vanilla";
+
+export type { StateStore } from "@json-render/core";
+
+type JotaiStore = ReturnType<typeof createJotaiStore>;
+
+/**
+ * Options for {@link jotaiStateStore}.
+ */
+export interface JotaiStateStoreOptions {
+  /** A writable atom that holds the json-render state model. */
+  atom: WritableAtom<StateModel, [StateModel], void>;
+  /**
+   * The Jotai store instance. Defaults to `createStore()` from `jotai/vanilla`.
+   * Pass your own if you use a `<Provider store={...}>` in your React tree.
+   */
+  store?: JotaiStore;
+}
+
+/**
+ * Create a {@link StateStore} backed by a Jotai atom.
+ *
+ * @example
+ * ```ts
+ * import { atom } from "jotai";
+ * import { jotaiStateStore } from "@json-render/jotai";
+ *
+ * const uiAtom = atom<Record<string, unknown>>({ count: 0 });
+ *
+ * const store = jotaiStateStore({ atom: uiAtom });
+ *
+ * <StateProvider store={store}>...</StateProvider>
+ * ```
+ *
+ * @example With a shared Jotai store:
+ * ```ts
+ * import { atom, createStore } from "jotai";
+ *
+ * const jStore = createStore();
+ * const uiAtom = atom<Record<string, unknown>>({ count: 0 });
+ *
+ * const store = jotaiStateStore({ atom: uiAtom, store: jStore });
+ *
+ * // In React:
+ * <JotaiProvider store={jStore}>
+ *   <StateProvider store={store}>...</StateProvider>
+ * </JotaiProvider>
+ * ```
+ */
+export function jotaiStateStore(options: JotaiStateStoreOptions): StateStore {
+  const stateAtom = options.atom;
+  const jStore = options.store ?? createJotaiStore();
+
+  return createStoreAdapter({
+    getSnapshot: () => jStore.get(stateAtom),
+    setSnapshot: (next) => jStore.set(stateAtom, next),
+    subscribe: (listener) => jStore.sub(stateAtom, listener),
+  });
+}

+ 9 - 0
packages/jotai/tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "extends": "@internal/typescript-config/base.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src"
+  },
+  "include": ["src"],
+  "exclude": ["node_modules", "dist"]
+}

+ 10 - 0
packages/jotai/tsup.config.ts

@@ -0,0 +1,10 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+  entry: ["src/index.ts"],
+  format: ["cjs", "esm"],
+  dts: true,
+  sourcemap: true,
+  clean: true,
+  external: ["@json-render/core", "@json-render/core/store-utils", "jotai"],
+});

+ 21 - 0
packages/react-native/README.md

@@ -214,6 +214,26 @@ const systemPrompt = catalog.prompt({
 });
 ```
 
+## External Store (Controlled Mode)
+
+For full control over state, pass a `StateStore` to bypass the internal state and wire json-render to any state management library (Redux, Zustand, XState, etc.):
+
+```tsx
+import { createStateStore, type StateStore } from "@json-render/react-native";
+
+// Use the built-in store outside of React
+const store = createStateStore({ count: 0 });
+
+<StateProvider store={store}>
+  {children}
+</StateProvider>
+
+// Mutate from anywhere — React will re-render automatically:
+store.set("/count", 1);
+```
+
+When `store` is provided, `initialState` and `onStateChange` are ignored. The store is the single source of truth. The same `store` prop is available on `createRenderer`, `JSONUIProvider`, and `StateProvider`.
+
 ## Hooks
 
 | Hook | Purpose |
@@ -227,3 +247,4 @@ const systemPrompt = catalog.prompt({
 | `useAction(name)` | Get a single action dispatch function |
 | `useUIStream(options)` | Stream specs from an API endpoint |
 | `createStandardActionHandlers(options)` | Create handlers for standard actions |
+| `createStateStore(initialState)` | Create a framework-agnostic in-memory `StateStore` |

+ 4 - 3
packages/react-native/package.json

@@ -56,10 +56,11 @@
     "typecheck": "tsc --noEmit"
   },
   "dependencies": {
-    "@json-render/core": "workspace:*"
+    "@json-render/core": "workspace:*",
+    "@internal/react-state": "workspace:*"
   },
   "devDependencies": {
-    "@repo/typescript-config": "workspace:*",
+    "@internal/typescript-config": "workspace:*",
     "@types/react": "19.2.3",
     "react-native": "0.83.1",
     "tsup": "^8.0.2",
@@ -67,7 +68,7 @@
     "zod": "^4.0.0"
   },
   "peerDependencies": {
-    "react": "^19.0.0",
+    "react": "^18.0.0 || ^19.0.0",
     "react-native": ">=0.71.0",
     "zod": "^4.0.0"
   }

+ 8 - 158
packages/react-native/src/contexts/state.tsx

@@ -1,158 +1,8 @@
-import React, {
-  createContext,
-  useContext,
-  useState,
-  useCallback,
-  useMemo,
-  useEffect,
-  useRef,
-  type ReactNode,
-} from "react";
-import { getByPath, setByPath, type StateModel } from "@json-render/core";
-
-/**
- * State context value
- */
-export interface StateContextValue {
-  /** The current state model */
-  state: StateModel;
-  /** Get a value by path */
-  get: (path: string) => unknown;
-  /** Set a value by path */
-  set: (path: string, value: unknown) => void;
-  /** Update multiple values at once */
-  update: (updates: Record<string, unknown>) => void;
-}
-
-const StateContext = createContext<StateContextValue | null>(null);
-
-/**
- * Props for StateProvider
- */
-export interface StateProviderProps {
-  /** Initial state model */
-  initialState?: StateModel;
-  /** Callback when state changes */
-  onStateChange?: (path: string, value: unknown) => void;
-  children: ReactNode;
-}
-
-/**
- * Provider for state model context
- */
-export function StateProvider({
-  initialState = {},
-  onStateChange,
-  children,
-}: StateProviderProps) {
-  const [state, setStateInternal] = useState<StateModel>(initialState);
-
-  // Keep a ref to the latest state so `get` doesn't change on every update.
-  const stateRef = useRef(state);
-  stateRef.current = state;
-
-  // Track the serialized initialState to detect actual value changes (not just reference changes)
-  const initialStateJsonRef = useRef<string>(JSON.stringify(initialState));
-
-  // Sync external state changes with internal state - only when values actually change
-  useEffect(() => {
-    const newJson = JSON.stringify(initialState);
-    if (newJson !== initialStateJsonRef.current) {
-      initialStateJsonRef.current = newJson;
-      if (initialState && Object.keys(initialState).length > 0) {
-        setStateInternal((prev) => ({ ...prev, ...initialState }));
-      }
-    }
-  }, [initialState]);
-
-  // `get` uses a ref so it never changes identity — consumers that only
-  // need `get` won't re-render on every state change.
-  const get = useCallback(
-    (path: string) => getByPath(stateRef.current, path),
-    [],
-  );
-
-  const set = useCallback(
-    (path: string, value: unknown) => {
-      setStateInternal((prev) => {
-        const next = { ...prev };
-        setByPath(next, path, value);
-        return next;
-      });
-      // Side effect after the state update
-      onStateChange?.(path, value);
-    },
-    [onStateChange],
-  );
-
-  const update = useCallback(
-    (updates: Record<string, unknown>) => {
-      const entries = Object.entries(updates);
-      setStateInternal((prev) => {
-        const next = { ...prev };
-        for (const [path, value] of entries) {
-          setByPath(next, path, value);
-        }
-        return next;
-      });
-      // Side effects after the state update
-      for (const [path, value] of entries) {
-        onStateChange?.(path, value);
-      }
-    },
-    [onStateChange],
-  );
-
-  const value = useMemo<StateContextValue>(
-    () => ({
-      state,
-      get,
-      set,
-      update,
-    }),
-    [state, get, set, update],
-  );
-
-  return (
-    <StateContext.Provider value={value}>{children}</StateContext.Provider>
-  );
-}
-
-/**
- * Hook to access the state context
- */
-export function useStateStore(): StateContextValue {
-  const ctx = useContext(StateContext);
-  if (!ctx) {
-    throw new Error("useStateStore must be used within a StateProvider");
-  }
-  return ctx;
-}
-
-/**
- * Hook to get a value from the state model
- */
-export function useStateValue<T>(path: string): T | undefined {
-  const { state } = useStateStore();
-  return getByPath(state, path) as T | undefined;
-}
-
-/**
- * Hook to get and set a value from the state model (like useState).
- *
- * @deprecated Use {@link useBoundProp} with `$bindState` expressions instead.
- * `useStateBinding` takes a raw state path string, while `useBoundProp` works
- * with the renderer's `bindings` map and supports both `$bindState` and
- * `$bindItem` expressions.
- */
-export function useStateBinding<T>(
-  path: string,
-): [T | undefined, (value: T) => void] {
-  const { state, set } = useStateStore();
-  const value = getByPath(state, path) as T | undefined;
-  const setValue = useCallback(
-    (newValue: T) => set(path, newValue),
-    [path, set],
-  );
-  return [value, setValue];
-}
+export {
+  StateProvider,
+  useStateStore,
+  useStateValue,
+  useStateBinding,
+  type StateContextValue,
+  type StateProviderProps,
+} from "@internal/react-state";

+ 2 - 1
packages/react-native/src/index.ts

@@ -54,7 +54,8 @@ export {
 } from "./schema";
 
 // Core types (re-exported for convenience)
-export type { Spec } from "@json-render/core";
+export type { Spec, StateStore } from "@json-render/core";
+export { createStateStore } from "@json-render/core";
 
 // Catalog-aware types for React Native
 export type {

+ 29 - 8
packages/react-native/src/renderer.tsx

@@ -11,6 +11,7 @@ import type {
   ActionBinding,
   Catalog,
   SchemaDefinition,
+  StateStore,
 } from "@json-render/core";
 import {
   resolveElementProps,
@@ -414,7 +415,12 @@ export interface JSONUIProviderProps {
    * Custom components are merged with (and override) standard components.
    */
   registry?: ComponentRegistry;
-  /** Initial state model */
+  /**
+   * External store (controlled mode). When provided, `initialState` and
+   * `onStateChange` are ignored.
+   */
+  store?: StateStore;
+  /** Initial state model (uncontrolled mode) */
   initialState?: Record<string, unknown>;
   /** Action handlers */
   handlers?: Record<
@@ -428,8 +434,8 @@ export interface JSONUIProviderProps {
     string,
     (value: unknown, args?: Record<string, unknown>) => boolean
   >;
-  /** Callback when state changes */
-  onStateChange?: (path: string, value: unknown) => void;
+  /** Callback when state changes (uncontrolled mode) */
+  onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
   children: ReactNode;
 }
 
@@ -438,6 +444,7 @@ export interface JSONUIProviderProps {
  */
 export function JSONUIProvider({
   registry,
+  store,
   initialState,
   handlers,
   navigate,
@@ -446,7 +453,11 @@ export function JSONUIProvider({
   children,
 }: JSONUIProviderProps) {
   return (
-    <StateProvider initialState={initialState} onStateChange={onStateChange}>
+    <StateProvider
+      store={store}
+      initialState={initialState}
+      onStateChange={onStateChange}
+    >
       <VisibilityProvider>
         <ActionProvider handlers={handlers} navigate={navigate}>
           <ValidationProvider customFunctions={validationFunctions}>
@@ -636,12 +647,17 @@ type DefineRegistryActionFn = (
 export interface CreateRendererProps {
   /** The spec to render (AI-generated JSON) */
   spec: Spec | null;
-  /** State context for dynamic values */
+  /**
+   * External store (controlled mode). When provided, `state` and
+   * `onStateChange` are ignored.
+   */
+  store?: StateStore;
+  /** State context for dynamic values (uncontrolled mode) */
   state?: Record<string, unknown>;
   /** Action handler */
   onAction?: (actionName: string, params?: Record<string, unknown>) => void;
-  /** Callback when state changes (e.g., from form inputs) */
-  onStateChange?: (path: string, value: unknown) => void;
+  /** Callback when state changes (uncontrolled mode) */
+  onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
   /** Whether the spec is currently loading/streaming */
   loading?: boolean;
   /** Fallback component for unknown types */
@@ -691,6 +707,7 @@ export function createRenderer<
   // Return the renderer component
   return function CatalogRenderer({
     spec,
+    store,
     state,
     onAction,
     onStateChange,
@@ -715,7 +732,11 @@ export function createRenderer<
       : undefined;
 
     return (
-      <StateProvider initialState={state} onStateChange={onStateChange}>
+      <StateProvider
+        store={store}
+        initialState={state}
+        onStateChange={onStateChange}
+      >
         <VisibilityProvider>
           <ActionProvider handlers={actionHandlers}>
             <ValidationProvider>

+ 1 - 1
packages/react-native/tsconfig.json

@@ -1,5 +1,5 @@
 {
-  "extends": "@repo/typescript-config/react-library.json",
+  "extends": "@internal/typescript-config/react-library.json",
   "compilerOptions": {
     "outDir": "dist",
     "rootDir": "src"

+ 13 - 0
packages/react-pdf/README.md

@@ -154,6 +154,19 @@ All render functions accept an optional second argument with:
 - `state` - Initial state for `$state` / `$cond` dynamic prop resolution
 - `handlers` - Action handlers
 
+## External Store (Controlled Mode)
+
+For full control over state, pass a `StateStore` to `StateProvider`, `JSONUIProvider`, or `createRenderer`. When `store` is provided, `initialState` and `onStateChange` are ignored and the store is the single source of truth:
+
+```tsx
+import { createStateStore, type StateStore } from "@json-render/react-pdf";
+
+const store = createStateStore({ invoice: { total: 100 } });
+
+// Mutate from anywhere — components re-render automatically:
+store.set("/invoice/total", 200);
+```
+
 ## Server-Safe Import
 
 Import schema and catalog definitions without pulling in React:

+ 3 - 2
packages/react-pdf/package.json

@@ -61,17 +61,18 @@
   },
   "dependencies": {
     "@json-render/core": "workspace:*",
+    "@internal/react-state": "workspace:*",
     "@react-pdf/renderer": "^4.3.2"
   },
   "devDependencies": {
-    "@repo/typescript-config": "workspace:*",
+    "@internal/typescript-config": "workspace:*",
     "@types/react": "19.2.3",
     "tsup": "^8.0.2",
     "typescript": "^5.4.5",
     "zod": "^4.0.0"
   },
   "peerDependencies": {
-    "react": "^19.0.0",
+    "react": "^18.0.0 || ^19.0.0",
     "zod": "^4.0.0"
   }
 }

+ 49 - 0
packages/react-pdf/src/contexts/state.test.tsx

@@ -0,0 +1,49 @@
+import { describe, it, expect } from "vitest";
+import React from "react";
+import { renderHook, act } from "@testing-library/react";
+import {
+  StateProvider,
+  useStateStore,
+  useStateValue,
+  useStateBinding,
+} from "./state";
+
+describe("state re-exports (smoke test)", () => {
+  it("StateProvider + useStateStore round-trip", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider initialState={{ count: 0 }}>{children}</StateProvider>
+    );
+
+    const { result } = renderHook(() => useStateStore(), { wrapper });
+
+    expect(result.current.get("/count")).toBe(0);
+
+    act(() => {
+      result.current.set("/count", 42);
+    });
+
+    expect(result.current.state.count).toBe(42);
+  });
+
+  it("useStateValue reads from state", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider initialState={{ name: "Alice" }}>{children}</StateProvider>
+    );
+
+    const { result } = renderHook(() => useStateValue("/name"), { wrapper });
+
+    expect(result.current).toBe("Alice");
+  });
+
+  it("useStateBinding returns value and setter", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider initialState={{ x: 1 }}>{children}</StateProvider>
+    );
+
+    const { result } = renderHook(() => useStateBinding("/x"), { wrapper });
+
+    const [value, setValue] = result.current;
+    expect(value).toBe(1);
+    expect(typeof setValue).toBe("function");
+  });
+});

+ 8 - 117
packages/react-pdf/src/contexts/state.tsx

@@ -1,117 +1,8 @@
-import React, {
-  createContext,
-  useContext,
-  useState,
-  useCallback,
-  useMemo,
-  useEffect,
-  useRef,
-  type ReactNode,
-} from "react";
-import { getByPath, setByPath, type StateModel } from "@json-render/core";
-
-export interface StateContextValue {
-  state: StateModel;
-  get: (path: string) => unknown;
-  set: (path: string, value: unknown) => void;
-  update: (updates: Record<string, unknown>) => void;
-}
-
-const StateContext = createContext<StateContextValue | null>(null);
-
-export interface StateProviderProps {
-  initialState?: StateModel;
-  onStateChange?: (path: string, value: unknown) => void;
-  children: ReactNode;
-}
-
-export function StateProvider({
-  initialState = {},
-  onStateChange,
-  children,
-}: StateProviderProps) {
-  const [state, setStateInternal] = useState<StateModel>(initialState);
-
-  const stateRef = useRef(state);
-  stateRef.current = state;
-
-  const initialStateJsonRef = useRef<string>(JSON.stringify(initialState));
-
-  useEffect(() => {
-    const newJson = JSON.stringify(initialState);
-    if (newJson !== initialStateJsonRef.current) {
-      initialStateJsonRef.current = newJson;
-      if (initialState && Object.keys(initialState).length > 0) {
-        setStateInternal((prev) => ({ ...prev, ...initialState }));
-      }
-    }
-  }, [initialState]);
-
-  const get = useCallback(
-    (path: string) => getByPath(stateRef.current, path),
-    [],
-  );
-
-  const set = useCallback(
-    (path: string, value: unknown) => {
-      setStateInternal((prev) => {
-        const next = { ...prev };
-        setByPath(next, path, value);
-        return next;
-      });
-      onStateChange?.(path, value);
-    },
-    [onStateChange],
-  );
-
-  const update = useCallback(
-    (updates: Record<string, unknown>) => {
-      const entries = Object.entries(updates);
-      setStateInternal((prev) => {
-        const next = { ...prev };
-        for (const [path, value] of entries) {
-          setByPath(next, path, value);
-        }
-        return next;
-      });
-      for (const [path, value] of entries) {
-        onStateChange?.(path, value);
-      }
-    },
-    [onStateChange],
-  );
-
-  const value = useMemo<StateContextValue>(
-    () => ({ state, get, set, update }),
-    [state, get, set, update],
-  );
-
-  return (
-    <StateContext.Provider value={value}>{children}</StateContext.Provider>
-  );
-}
-
-export function useStateStore(): StateContextValue {
-  const ctx = useContext(StateContext);
-  if (!ctx) {
-    throw new Error("useStateStore must be used within a StateProvider");
-  }
-  return ctx;
-}
-
-export function useStateValue<T>(path: string): T | undefined {
-  const { state } = useStateStore();
-  return getByPath(state, path) as T | undefined;
-}
-
-export function useStateBinding<T>(
-  path: string,
-): [T | undefined, (value: T) => void] {
-  const { state, set } = useStateStore();
-  const value = getByPath(state, path) as T | undefined;
-  const setValue = useCallback(
-    (newValue: T) => set(path, newValue),
-    [path, set],
-  );
-  return [value, setValue];
-}
+export {
+  StateProvider,
+  useStateStore,
+  useStateValue,
+  useStateBinding,
+  type StateContextValue,
+  type StateProviderProps,
+} from "@internal/react-state";

+ 2 - 1
packages/react-pdf/src/index.ts

@@ -2,7 +2,8 @@
 export { schema, type ReactPdfSchema, type ReactPdfSpec } from "./schema";
 
 // Core types (re-exported for convenience)
-export type { Spec } from "@json-render/core";
+export type { Spec, StateStore } from "@json-render/core";
+export { createStateStore } from "@json-render/core";
 
 // Catalog-aware types
 export type {

+ 17 - 4
packages/react-pdf/src/renderer.tsx

@@ -11,6 +11,7 @@ import type {
   ActionBinding,
   Catalog,
   SchemaDefinition,
+  StateStore,
 } from "@json-render/core";
 import {
   resolveElementProps,
@@ -343,6 +344,7 @@ export function Renderer({
 
 export interface JSONUIProviderProps {
   registry?: ComponentRegistry;
+  store?: StateStore;
   initialState?: Record<string, unknown>;
   handlers?: Record<
     string,
@@ -353,11 +355,12 @@ export interface JSONUIProviderProps {
     string,
     (value: unknown, args?: Record<string, unknown>) => boolean
   >;
-  onStateChange?: (path: string, value: unknown) => void;
+  onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
   children: ReactNode;
 }
 
 export function JSONUIProvider({
+  store,
   initialState,
   handlers,
   navigate,
@@ -366,7 +369,11 @@ export function JSONUIProvider({
   children,
 }: JSONUIProviderProps) {
   return (
-    <StateProvider initialState={initialState} onStateChange={onStateChange}>
+    <StateProvider
+      store={store}
+      initialState={initialState}
+      onStateChange={onStateChange}
+    >
       <VisibilityProvider>
         <ActionProvider handlers={handlers} navigate={navigate}>
           <ValidationProvider customFunctions={validationFunctions}>
@@ -430,9 +437,10 @@ export function defineRegistry<C extends Catalog>(
 
 export interface CreateRendererProps {
   spec: Spec | null;
+  store?: StateStore;
   state?: Record<string, unknown>;
   onAction?: (actionName: string, params?: Record<string, unknown>) => void;
-  onStateChange?: (path: string, value: unknown) => void;
+  onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
   loading?: boolean;
   fallback?: ComponentRenderer;
 }
@@ -461,6 +469,7 @@ export function createRenderer<
 
   return function CatalogRenderer({
     spec,
+    store,
     state,
     onAction,
     onStateChange,
@@ -484,7 +493,11 @@ export function createRenderer<
       : undefined;
 
     return (
-      <StateProvider initialState={state} onStateChange={onStateChange}>
+      <StateProvider
+        store={store}
+        initialState={state}
+        onStateChange={onStateChange}
+      >
         <VisibilityProvider>
           <ActionProvider handlers={actionHandlers}>
             <ValidationProvider>

+ 1 - 1
packages/react-pdf/tsconfig.json

@@ -1,5 +1,5 @@
 {
-  "extends": "@repo/typescript-config/react-library.json",
+  "extends": "@internal/typescript-config/react-library.json",
   "compilerOptions": {
     "outDir": "dist",
     "rootDir": "src"

+ 41 - 0
packages/react-state/package.json

@@ -0,0 +1,41 @@
+{
+  "name": "@internal/react-state",
+  "version": "0.8.0",
+  "private": true,
+  "license": "Apache-2.0",
+  "description": "Shared React state context for json-render renderer packages",
+  "main": "./dist/index.js",
+  "module": "./dist/index.mjs",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
+      "import": "./dist/index.mjs",
+      "require": "./dist/index.js"
+    }
+  },
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "tsup",
+    "dev": "tsup --watch",
+    "check-types": "tsc --noEmit",
+    "test": "vitest run"
+  },
+  "dependencies": {
+    "@json-render/core": "workspace:*"
+  },
+  "devDependencies": {
+    "@internal/typescript-config": "workspace:*",
+    "@testing-library/react": "^16.3.2",
+    "@types/react": "19.2.3",
+    "react": "^19.0.0",
+    "react-dom": "^19.0.0",
+    "tsup": "^8.0.2",
+    "typescript": "^5.4.5"
+  },
+  "peerDependencies": {
+    "react": "^18.0.0 || ^19.0.0"
+  }
+}

+ 9 - 0
packages/react-state/src/env.d.ts

@@ -0,0 +1,9 @@
+// Minimal process.env typing for dev-only warnings.
+// Uses a namespaced interface so it merges cleanly with @types/node if present.
+declare namespace NodeJS {
+  interface ProcessEnv {
+    readonly NODE_ENV?: string;
+  }
+}
+
+declare const process: { readonly env: NodeJS.ProcessEnv };

+ 332 - 0
packages/react-state/src/index.test.tsx

@@ -0,0 +1,332 @@
+import { describe, it, expect, vi } from "vitest";
+import React from "react";
+import { renderHook, act } from "@testing-library/react";
+import { createStateStore } from "@json-render/core";
+import {
+  StateProvider,
+  useStateStore,
+  useStateValue,
+  useStateBinding,
+} from "./index";
+
+// ============================================================================
+// Uncontrolled mode (default)
+// ============================================================================
+
+describe("StateProvider (uncontrolled)", () => {
+  it("provides initial state to children", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider initialState={{ user: { name: "John" } }}>
+        {children}
+      </StateProvider>
+    );
+
+    const { result } = renderHook(() => useStateStore(), { wrapper });
+
+    expect(result.current.state).toEqual({ user: { name: "John" } });
+  });
+
+  it("provides empty object when no initial state", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider>{children}</StateProvider>
+    );
+
+    const { result } = renderHook(() => useStateStore(), { wrapper });
+
+    expect(result.current.state).toEqual({});
+  });
+});
+
+describe("useStateStore (uncontrolled)", () => {
+  it("provides get function to retrieve values", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider initialState={{ user: { name: "John" } }}>
+        {children}
+      </StateProvider>
+    );
+
+    const { result } = renderHook(() => useStateStore(), { wrapper });
+
+    expect(result.current.get("/user/name")).toBe("John");
+  });
+
+  it("allows setting state at path with set function", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider initialState={{}}>{children}</StateProvider>
+    );
+
+    const { result } = renderHook(() => useStateStore(), { wrapper });
+
+    act(() => {
+      result.current.set("/user/name", "Alice");
+    });
+
+    expect((result.current.state.user as Record<string, unknown>).name).toBe(
+      "Alice",
+    );
+  });
+
+  it("calls onStateChange callback with changes array on set", () => {
+    const onStateChange = vi.fn();
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider initialState={{}} onStateChange={onStateChange}>
+        {children}
+      </StateProvider>
+    );
+
+    const { result } = renderHook(() => useStateStore(), { wrapper });
+
+    act(() => {
+      result.current.set("/count", 42);
+    });
+
+    expect(onStateChange).toHaveBeenCalledTimes(1);
+    expect(onStateChange).toHaveBeenCalledWith([{ path: "/count", value: 42 }]);
+  });
+
+  it("calls onStateChange callback once with all changes on update", () => {
+    const onStateChange = vi.fn();
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider initialState={{}} onStateChange={onStateChange}>
+        {children}
+      </StateProvider>
+    );
+
+    const { result } = renderHook(() => useStateStore(), { wrapper });
+
+    act(() => {
+      result.current.update({ "/name": "John", "/age": 30 });
+    });
+
+    expect(onStateChange).toHaveBeenCalledTimes(1);
+    expect(onStateChange).toHaveBeenCalledWith([
+      { path: "/name", value: "John" },
+      { path: "/age", value: 30 },
+    ]);
+  });
+
+  it("allows updating multiple values with update function", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider initialState={{}}>{children}</StateProvider>
+    );
+
+    const { result } = renderHook(() => useStateStore(), { wrapper });
+
+    act(() => {
+      result.current.update({
+        "/name": "John",
+        "/age": 30,
+      });
+    });
+
+    expect(result.current.state.name).toBe("John");
+    expect(result.current.state.age).toBe(30);
+  });
+});
+
+describe("useStateValue", () => {
+  it("returns value at specified path", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider initialState={{ user: { name: "John", age: 30 } }}>
+        {children}
+      </StateProvider>
+    );
+
+    const { result } = renderHook(() => useStateValue("/user/name"), {
+      wrapper,
+    });
+
+    expect(result.current).toBe("John");
+  });
+
+  it("returns undefined for missing path", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider initialState={{}}>{children}</StateProvider>
+    );
+
+    const { result } = renderHook(() => useStateValue("/missing"), { wrapper });
+
+    expect(result.current).toBeUndefined();
+  });
+
+  it("updates when state changes", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider initialState={{ count: 0 }}>{children}</StateProvider>
+    );
+
+    const { result, rerender } = renderHook(
+      () => ({
+        store: useStateStore(),
+        value: useStateValue<number>("/count"),
+      }),
+      { wrapper },
+    );
+
+    expect(result.current.value).toBe(0);
+
+    act(() => {
+      result.current.store.set("/count", 5);
+    });
+
+    rerender();
+    expect(result.current.value).toBe(5);
+  });
+});
+
+describe("useStateBinding", () => {
+  it("returns tuple with value and setter for path", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider initialState={{ name: "John" }}>{children}</StateProvider>
+    );
+
+    const { result } = renderHook(() => useStateBinding("/name"), { wrapper });
+
+    const [value, setValue] = result.current;
+    expect(value).toBe("John");
+    expect(typeof setValue).toBe("function");
+  });
+
+  it("setter updates the value", () => {
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider initialState={{ name: "John" }}>{children}</StateProvider>
+    );
+
+    const { result, rerender } = renderHook(() => useStateBinding("/name"), {
+      wrapper,
+    });
+
+    act(() => {
+      const [, setValue] = result.current;
+      setValue("Alice");
+    });
+
+    rerender();
+    const [value] = result.current;
+    expect(value).toBe("Alice");
+  });
+});
+
+// ============================================================================
+// Controlled mode (external store)
+// ============================================================================
+
+describe("StateProvider (controlled mode)", () => {
+  it("reads initial state from the external store", () => {
+    const store = createStateStore({ count: 7 });
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider store={store}>{children}</StateProvider>
+    );
+
+    const { result } = renderHook(() => useStateStore(), { wrapper });
+
+    expect(result.current.state).toEqual({ count: 7 });
+    expect(result.current.get("/count")).toBe(7);
+  });
+
+  it("re-renders when the external store updates", () => {
+    const store = createStateStore({ count: 0 });
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider store={store}>{children}</StateProvider>
+    );
+
+    const { result } = renderHook(() => useStateValue<number>("/count"), {
+      wrapper,
+    });
+
+    expect(result.current).toBe(0);
+
+    act(() => {
+      store.set("/count", 42);
+    });
+
+    expect(result.current).toBe(42);
+  });
+
+  it("set() writes through to the external store", () => {
+    const store = createStateStore({});
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider store={store}>{children}</StateProvider>
+    );
+
+    const { result } = renderHook(() => useStateStore(), { wrapper });
+
+    act(() => {
+      result.current.set("/name", "Alice");
+    });
+
+    expect(store.getSnapshot().name).toBe("Alice");
+    expect(result.current.state.name).toBe("Alice");
+  });
+
+  it("update() writes multiple values through to the external store", () => {
+    const store = createStateStore({});
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider store={store}>{children}</StateProvider>
+    );
+
+    const { result } = renderHook(() => useStateStore(), { wrapper });
+
+    act(() => {
+      result.current.update({ "/a": 1, "/b": 2 });
+    });
+
+    expect(store.getSnapshot().a).toBe(1);
+    expect(store.getSnapshot().b).toBe(2);
+  });
+
+  it("does NOT call onStateChange when using an external store", () => {
+    const store = createStateStore({});
+    const onStateChange = vi.fn();
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider store={store} onStateChange={onStateChange}>
+        {children}
+      </StateProvider>
+    );
+
+    const { result } = renderHook(() => useStateStore(), { wrapper });
+
+    act(() => {
+      result.current.set("/x", 99);
+    });
+
+    expect(onStateChange).not.toHaveBeenCalled();
+  });
+
+  it("ignores initialState when an external store is provided", () => {
+    const store = createStateStore({ fromStore: true });
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider store={store} initialState={{ fromProp: true }}>
+        {children}
+      </StateProvider>
+    );
+
+    const { result } = renderHook(() => useStateStore(), { wrapper });
+
+    expect(result.current.state).toEqual({ fromStore: true });
+    expect(result.current.get("/fromProp")).toBeUndefined();
+  });
+});
+
+// ============================================================================
+// initialState sync fast-path
+// ============================================================================
+
+describe("StateProvider initialState sync", () => {
+  it("does not re-flatten when initialState reference is unchanged", () => {
+    const initialState = { count: 0 };
+    const wrapper = ({ children }: { children: React.ReactNode }) => (
+      <StateProvider initialState={initialState}>{children}</StateProvider>
+    );
+
+    const { result, rerender } = renderHook(() => useStateStore(), { wrapper });
+
+    expect(result.current.state).toEqual({ count: 0 });
+
+    act(() => {
+      result.current.set("/count", 5);
+    });
+
+    rerender();
+    expect(result.current.state.count).toBe(5);
+  });
+});

+ 220 - 0
packages/react-state/src/index.tsx

@@ -0,0 +1,220 @@
+"use client";
+
+import React, {
+  createContext,
+  useContext,
+  useCallback,
+  useEffect,
+  useMemo,
+  useRef,
+  useSyncExternalStore,
+  type ReactNode,
+} from "react";
+import {
+  getByPath,
+  createStateStore,
+  type StateModel,
+  type StateStore,
+} from "@json-render/core";
+import { flattenToPointers } from "@json-render/core/store-utils";
+
+/**
+ * State context value
+ */
+export interface StateContextValue {
+  /** The current state model */
+  state: StateModel;
+  /** Get a value by path */
+  get: (path: string) => unknown;
+  /** Set a value by path */
+  set: (path: string, value: unknown) => void;
+  /** Update multiple values at once */
+  update: (updates: Record<string, unknown>) => void;
+}
+
+const StateContext = createContext<StateContextValue | null>(null);
+
+/**
+ * Props for StateProvider
+ */
+export interface StateProviderProps {
+  /**
+   * External store that owns the state. When provided, the provider operates
+   * in **controlled mode** — `initialState` and `onStateChange` are ignored
+   * and the store is the single source of truth.
+   */
+  store?: StateStore;
+  /** Initial state model (used only in uncontrolled mode) */
+  initialState?: StateModel;
+  /**
+   * Callback when state changes (used only in uncontrolled mode).
+   * Called once per `set` or `update` with all changed entries.
+   */
+  onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
+  children: ReactNode;
+}
+
+function computeInitialFlat(
+  isControlled: boolean,
+  initialState: StateModel,
+): Record<string, unknown> | null {
+  if (isControlled) return null;
+  if (Object.keys(initialState).length === 0) return {};
+  return flattenToPointers(initialState);
+}
+
+/**
+ * Provider for state model context.
+ *
+ * Supports two modes:
+ * - **Controlled**: pass a `store` prop (e.g. backed by Redux / Zustand).
+ * - **Uncontrolled** (default): omit `store` and optionally pass
+ *   `initialState` / `onStateChange`.
+ */
+export function StateProvider({
+  store: externalStore,
+  initialState = {},
+  onStateChange,
+  children,
+}: StateProviderProps) {
+  const internalStoreRef = useRef<StateStore | undefined>(undefined);
+  if (!externalStore && !internalStoreRef.current) {
+    internalStoreRef.current = createStateStore(initialState);
+  }
+
+  const store = externalStore ?? internalStoreRef.current!;
+
+  // Refs for stable callback identity — callbacks never change regardless of
+  // whether the consumer passes a new store / onStateChange reference.
+  const storeRef = useRef(store);
+  storeRef.current = store;
+
+  const isControlledRef = useRef(!!externalStore);
+  isControlledRef.current = !!externalStore;
+
+  const initialModeRef = useRef(externalStore ? "controlled" : "uncontrolled");
+  const modeWarnedRef = useRef(false);
+  if (process.env.NODE_ENV !== "production") {
+    const currentMode = externalStore ? "controlled" : "uncontrolled";
+    if (currentMode !== initialModeRef.current && !modeWarnedRef.current) {
+      modeWarnedRef.current = true;
+      console.warn(
+        `StateProvider: switching from ${initialModeRef.current} to ${currentMode} mode is not supported.`,
+      );
+    }
+  }
+
+  const prevInitialStateRef = useRef(initialState);
+  const prevFlatRef = useRef<Record<string, unknown> | null>(
+    computeInitialFlat(!!externalStore, initialState),
+  );
+  useEffect(() => {
+    if (externalStore) return;
+    if (initialState === prevInitialStateRef.current) return;
+    prevInitialStateRef.current = initialState;
+    const nextFlat =
+      initialState && Object.keys(initialState).length > 0
+        ? flattenToPointers(initialState)
+        : {};
+    const prevFlat = prevFlatRef.current ?? {};
+    const allKeys = new Set([
+      ...Object.keys(prevFlat),
+      ...Object.keys(nextFlat),
+    ]);
+    const updates: Record<string, unknown> = {};
+    for (const key of allKeys) {
+      if (prevFlat[key] !== nextFlat[key]) {
+        updates[key] = key in nextFlat ? nextFlat[key] : undefined;
+      }
+    }
+    prevFlatRef.current = nextFlat;
+    if (Object.keys(updates).length > 0) {
+      store.update(updates);
+    }
+  }, [externalStore, initialState, store]);
+
+  const state = useSyncExternalStore(
+    store.subscribe,
+    store.getSnapshot,
+    store.getServerSnapshot ?? store.getSnapshot,
+  );
+
+  const onStateChangeRef = useRef(onStateChange);
+  onStateChangeRef.current = onStateChange;
+
+  const set = useCallback((path: string, value: unknown) => {
+    const s = storeRef.current;
+    const prev = s.getSnapshot();
+    s.set(path, value);
+    if (!isControlledRef.current && s.getSnapshot() !== prev) {
+      onStateChangeRef.current?.([{ path, value }]);
+    }
+  }, []);
+
+  const update = useCallback((updates: Record<string, unknown>) => {
+    const s = storeRef.current;
+    const prev = s.getSnapshot();
+    s.update(updates);
+    if (!isControlledRef.current && s.getSnapshot() !== prev) {
+      const changes: Array<{ path: string; value: unknown }> = [];
+      for (const [path, value] of Object.entries(updates)) {
+        if (getByPath(prev, path) !== value) {
+          changes.push({ path, value });
+        }
+      }
+      if (changes.length > 0) {
+        onStateChangeRef.current?.(changes);
+      }
+    }
+  }, []);
+
+  const get = useCallback((path: string) => storeRef.current.get(path), []);
+
+  const value = useMemo<StateContextValue>(
+    () => ({ state, get, set, update }),
+    [state, get, set, update],
+  );
+
+  return (
+    <StateContext.Provider value={value}>{children}</StateContext.Provider>
+  );
+}
+
+/**
+ * Hook to access the state context
+ */
+export function useStateStore(): StateContextValue {
+  const ctx = useContext(StateContext);
+  if (!ctx) {
+    throw new Error("useStateStore must be used within a StateProvider");
+  }
+  return ctx;
+}
+
+/**
+ * Hook to get a value from the state model
+ */
+export function useStateValue<T>(path: string): T | undefined {
+  const { state } = useStateStore();
+  return getByPath(state, path) as T | undefined;
+}
+
+/**
+ * Hook to get and set a value from the state model (like useState).
+ *
+ * @deprecated Use {@link useBoundProp} with `$bindState` expressions instead.
+ * `useStateBinding` takes a raw state path string, while `useBoundProp` works
+ * with the renderer's `bindings` map and supports both `$bindState` and
+ * `$bindItem` expressions.
+ */
+export function useStateBinding<T>(
+  path: string,
+): [T | undefined, (value: T) => void] {
+  const { state, set } = useStateStore();
+  const value = getByPath(state, path) as T | undefined;
+  const setValue = useCallback(
+    (newValue: T) => set(path, newValue),
+    [path, set],
+  );
+  return [value, setValue];
+}

+ 9 - 0
packages/react-state/tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "extends": "@internal/typescript-config/react-library.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src"
+  },
+  "include": ["src"],
+  "exclude": ["node_modules", "dist"]
+}

+ 10 - 0
packages/react-state/tsup.config.ts

@@ -0,0 +1,10 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+  entry: ["src/index.tsx"],
+  format: ["cjs", "esm"],
+  dts: true,
+  sourcemap: true,
+  clean: true,
+  external: ["@json-render/core", "@json-render/core/store-utils", "react"],
+});

+ 16 - 0
packages/react-state/vitest.config.ts

@@ -0,0 +1,16 @@
+import { defineConfig } from "vitest/config";
+import path from "path";
+
+export default defineConfig({
+  resolve: {
+    alias: {
+      react: path.resolve(__dirname, "../../node_modules/react"),
+      "react-dom": path.resolve(__dirname, "../../node_modules/react-dom"),
+    },
+  },
+  test: {
+    globals: true,
+    environment: "jsdom",
+    include: ["src/**/*.test.tsx"],
+  },
+});

+ 31 - 0
packages/react/README.md

@@ -171,6 +171,35 @@ const name = get("/user/name");  // "John"
 set("/user/age", 25);
 ```
 
+#### External Store (Controlled Mode)
+
+For full control over state, pass a `StateStore` to bypass the internal state and wire json-render to any state management library (Redux, Zustand, XState, etc.):
+
+```tsx
+import { createStateStore, type StateStore } from "@json-render/react";
+
+// Option 1: Use the built-in store outside of React
+const store = createStateStore({ count: 0 });
+
+<StateProvider store={store}>
+  {children}
+</StateProvider>
+
+// Mutate from anywhere — React will re-render automatically:
+store.set("/count", 1);
+
+// Option 2: Implement the StateStore interface with your own backend
+const zustandStore: StateStore = {
+  get: (path) => getByPath(useStore.getState(), path),
+  set: (path, value) => useStore.setState(prev => { /* ... */ }),
+  update: (updates) => useStore.setState(prev => { /* ... */ }),
+  getSnapshot: () => useStore.getState(),
+  subscribe: (listener) => useStore.subscribe(listener),
+};
+```
+
+When `store` is provided, `initialState` and `onStateChange` are ignored. The store is the single source of truth. The same `store` prop is available on `createRenderer`, `JSONUIProvider`, and `StateProvider`.
+
 ### ActionProvider
 
 Handle actions from components:
@@ -428,6 +457,7 @@ function App() {
 | `useActions` | Access actions context |
 | `useAction` | Get a single action dispatch function |
 | `useUIStream` | Stream specs from an API endpoint |
+| `createStateStore` | Create a framework-agnostic in-memory `StateStore` |
 
 ### Types
 
@@ -439,3 +469,4 @@ function App() {
 | `ComponentFn` | Component render function type |
 | `SetState` | State setter type |
 | `StateModel` | State model type |
+| `StateStore` | Interface for plugging in external state management |

+ 3 - 2
packages/react/package.json

@@ -50,10 +50,11 @@
     "typecheck": "tsc --noEmit"
   },
   "dependencies": {
-    "@json-render/core": "workspace:*"
+    "@json-render/core": "workspace:*",
+    "@internal/react-state": "workspace:*"
   },
   "devDependencies": {
-    "@repo/typescript-config": "workspace:*",
+    "@internal/typescript-config": "workspace:*",
     "@types/react": "19.2.3",
     "tsup": "^8.0.2",
     "typescript": "^5.4.5"

+ 14 - 146
packages/react/src/contexts/state.test.tsx

@@ -1,4 +1,4 @@
-import { describe, it, expect, vi } from "vitest";
+import { describe, it, expect } from "vitest";
 import React from "react";
 import { renderHook, act } from "@testing-library/react";
 import {
@@ -8,174 +8,42 @@ import {
   useStateBinding,
 } from "./state";
 
-describe("StateProvider", () => {
-  it("provides initial state to children", () => {
+describe("state re-exports (smoke test)", () => {
+  it("StateProvider + useStateStore round-trip", () => {
     const wrapper = ({ children }: { children: React.ReactNode }) => (
-      <StateProvider initialState={{ user: { name: "John" } }}>
-        {children}
-      </StateProvider>
-    );
-
-    const { result } = renderHook(() => useStateStore(), { wrapper });
-
-    expect(result.current.state).toEqual({ user: { name: "John" } });
-  });
-
-  it("provides empty object when no initial state", () => {
-    const wrapper = ({ children }: { children: React.ReactNode }) => (
-      <StateProvider>{children}</StateProvider>
-    );
-
-    const { result } = renderHook(() => useStateStore(), { wrapper });
-
-    expect(result.current.state).toEqual({});
-  });
-});
-
-describe("useStateStore", () => {
-  it("provides get function to retrieve values", () => {
-    const wrapper = ({ children }: { children: React.ReactNode }) => (
-      <StateProvider initialState={{ user: { name: "John" } }}>
-        {children}
-      </StateProvider>
-    );
-
-    const { result } = renderHook(() => useStateStore(), { wrapper });
-
-    expect(result.current.get("/user/name")).toBe("John");
-  });
-
-  it("allows setting state at path with set function", () => {
-    const wrapper = ({ children }: { children: React.ReactNode }) => (
-      <StateProvider initialState={{}}>{children}</StateProvider>
+      <StateProvider initialState={{ count: 0 }}>{children}</StateProvider>
     );
 
     const { result } = renderHook(() => useStateStore(), { wrapper });
 
-    act(() => {
-      result.current.set("/user/name", "Alice");
-    });
-
-    expect((result.current.state.user as Record<string, unknown>).name).toBe(
-      "Alice",
-    );
-  });
-
-  it("calls onStateChange callback when state changes", () => {
-    const onStateChange = vi.fn();
-    const wrapper = ({ children }: { children: React.ReactNode }) => (
-      <StateProvider initialState={{}} onStateChange={onStateChange}>
-        {children}
-      </StateProvider>
-    );
-
-    const { result } = renderHook(() => useStateStore(), { wrapper });
+    expect(result.current.get("/count")).toBe(0);
 
     act(() => {
       result.current.set("/count", 42);
     });
 
-    expect(onStateChange).toHaveBeenCalledWith("/count", 42);
-  });
-
-  it("allows updating multiple values with update function", () => {
-    const wrapper = ({ children }: { children: React.ReactNode }) => (
-      <StateProvider initialState={{}}>{children}</StateProvider>
-    );
-
-    const { result } = renderHook(() => useStateStore(), { wrapper });
-
-    act(() => {
-      result.current.update({
-        "/name": "John",
-        "/age": 30,
-      });
-    });
-
-    expect(result.current.state.name).toBe("John");
-    expect(result.current.state.age).toBe(30);
+    expect(result.current.state.count).toBe(42);
   });
-});
 
-describe("useStateValue", () => {
-  it("returns value at specified path", () => {
+  it("useStateValue reads from state", () => {
     const wrapper = ({ children }: { children: React.ReactNode }) => (
-      <StateProvider initialState={{ user: { name: "John", age: 30 } }}>
-        {children}
-      </StateProvider>
+      <StateProvider initialState={{ name: "Alice" }}>{children}</StateProvider>
     );
 
-    const { result } = renderHook(() => useStateValue("/user/name"), {
-      wrapper,
-    });
+    const { result } = renderHook(() => useStateValue("/name"), { wrapper });
 
-    expect(result.current).toBe("John");
+    expect(result.current).toBe("Alice");
   });
 
-  it("returns undefined for missing path", () => {
+  it("useStateBinding returns value and setter", () => {
     const wrapper = ({ children }: { children: React.ReactNode }) => (
-      <StateProvider initialState={{}}>{children}</StateProvider>
+      <StateProvider initialState={{ x: 1 }}>{children}</StateProvider>
     );
 
-    const { result } = renderHook(() => useStateValue("/missing"), { wrapper });
-
-    expect(result.current).toBeUndefined();
-  });
-
-  it("updates when state changes", () => {
-    const wrapper = ({ children }: { children: React.ReactNode }) => (
-      <StateProvider initialState={{ count: 0 }}>{children}</StateProvider>
-    );
-
-    // Use a single hook that returns both values
-    const { result, rerender } = renderHook(
-      () => ({
-        store: useStateStore(),
-        value: useStateValue<number>("/count"),
-      }),
-      { wrapper },
-    );
-
-    expect(result.current.value).toBe(0);
-
-    act(() => {
-      result.current.store.set("/count", 5);
-    });
-
-    rerender();
-    expect(result.current.value).toBe(5);
-  });
-});
-
-describe("useStateBinding", () => {
-  it("returns tuple with value and setter for path", () => {
-    const wrapper = ({ children }: { children: React.ReactNode }) => (
-      <StateProvider initialState={{ name: "John" }}>{children}</StateProvider>
-    );
-
-    const { result } = renderHook(() => useStateBinding("/name"), { wrapper });
+    const { result } = renderHook(() => useStateBinding("/x"), { wrapper });
 
     const [value, setValue] = result.current;
-    expect(value).toBe("John");
+    expect(value).toBe(1);
     expect(typeof setValue).toBe("function");
   });
-
-  it("setter updates the value", () => {
-    const wrapper = ({ children }: { children: React.ReactNode }) => (
-      <StateProvider initialState={{ name: "John" }}>{children}</StateProvider>
-    );
-
-    const { result, rerender } = renderHook(() => useStateBinding("/name"), {
-      wrapper,
-    });
-
-    act(() => {
-      const [, setValue] = result.current;
-      setValue("Alice");
-    });
-
-    rerender();
-    const [value] = result.current;
-    expect(value).toBe("Alice");
-  });
 });

+ 8 - 158
packages/react/src/contexts/state.tsx

@@ -1,160 +1,10 @@
 "use client";
 
-import React, {
-  createContext,
-  useContext,
-  useState,
-  useCallback,
-  useMemo,
-  useEffect,
-  useRef,
-  type ReactNode,
-} from "react";
-import { getByPath, setByPath, type StateModel } from "@json-render/core";
-
-/**
- * State context value
- */
-export interface StateContextValue {
-  /** The current state model */
-  state: StateModel;
-  /** Get a value by path */
-  get: (path: string) => unknown;
-  /** Set a value by path */
-  set: (path: string, value: unknown) => void;
-  /** Update multiple values at once */
-  update: (updates: Record<string, unknown>) => void;
-}
-
-const StateContext = createContext<StateContextValue | null>(null);
-
-/**
- * Props for StateProvider
- */
-export interface StateProviderProps {
-  /** Initial state model */
-  initialState?: StateModel;
-  /** Callback when state changes */
-  onStateChange?: (path: string, value: unknown) => void;
-  children: ReactNode;
-}
-
-/**
- * Provider for state model context
- */
-export function StateProvider({
-  initialState = {},
-  onStateChange,
-  children,
-}: StateProviderProps) {
-  const [state, setStateInternal] = useState<StateModel>(initialState);
-
-  // Keep a ref to the latest state so `get` doesn't change on every update.
-  const stateRef = useRef(state);
-  stateRef.current = state;
-
-  // Track the serialized initialState to detect actual value changes (not just reference changes)
-  const initialStateJsonRef = useRef<string>(JSON.stringify(initialState));
-
-  // Sync external state changes with internal state - only when values actually change
-  useEffect(() => {
-    const newJson = JSON.stringify(initialState);
-    if (newJson !== initialStateJsonRef.current) {
-      initialStateJsonRef.current = newJson;
-      if (initialState && Object.keys(initialState).length > 0) {
-        setStateInternal((prev) => ({ ...prev, ...initialState }));
-      }
-    }
-  }, [initialState]);
-
-  // `get` uses a ref so it never changes identity — consumers that only
-  // need `get` won't re-render on every state change.
-  const get = useCallback(
-    (path: string) => getByPath(stateRef.current, path),
-    [],
-  );
-
-  const set = useCallback(
-    (path: string, value: unknown) => {
-      setStateInternal((prev) => {
-        const next = { ...prev };
-        setByPath(next, path, value);
-        return next;
-      });
-      // Side effect after the state update
-      onStateChange?.(path, value);
-    },
-    [onStateChange],
-  );
-
-  const update = useCallback(
-    (updates: Record<string, unknown>) => {
-      const entries = Object.entries(updates);
-      setStateInternal((prev) => {
-        const next = { ...prev };
-        for (const [path, value] of entries) {
-          setByPath(next, path, value);
-        }
-        return next;
-      });
-      // Side effects after the state update
-      for (const [path, value] of entries) {
-        onStateChange?.(path, value);
-      }
-    },
-    [onStateChange],
-  );
-
-  const value = useMemo<StateContextValue>(
-    () => ({
-      state,
-      get,
-      set,
-      update,
-    }),
-    [state, get, set, update],
-  );
-
-  return (
-    <StateContext.Provider value={value}>{children}</StateContext.Provider>
-  );
-}
-
-/**
- * Hook to access the state context
- */
-export function useStateStore(): StateContextValue {
-  const ctx = useContext(StateContext);
-  if (!ctx) {
-    throw new Error("useStateStore must be used within a StateProvider");
-  }
-  return ctx;
-}
-
-/**
- * Hook to get a value from the state model
- */
-export function useStateValue<T>(path: string): T | undefined {
-  const { state } = useStateStore();
-  return getByPath(state, path) as T | undefined;
-}
-
-/**
- * Hook to get and set a value from the state model (like useState).
- *
- * @deprecated Use {@link useBoundProp} with `$bindState` expressions instead.
- * `useStateBinding` takes a raw state path string, while `useBoundProp` works
- * with the renderer's `bindings` map and supports both `$bindState` and
- * `$bindItem` expressions.
- */
-export function useStateBinding<T>(
-  path: string,
-): [T | undefined, (value: T) => void] {
-  const { state, set } = useStateStore();
-  const value = getByPath(state, path) as T | undefined;
-  const setValue = useCallback(
-    (newValue: T) => set(path, newValue),
-    [path, set],
-  );
-  return [value, setValue];
-}
+export {
+  StateProvider,
+  useStateStore,
+  useStateValue,
+  useStateBinding,
+  type StateContextValue,
+  type StateProviderProps,
+} from "@internal/react-state";

+ 2 - 1
packages/react/src/index.ts

@@ -54,7 +54,8 @@ export {
 } from "./schema";
 
 // Core types (re-exported for convenience)
-export type { Spec } from "@json-render/core";
+export type { Spec, StateStore } from "@json-render/core";
+export { createStateStore } from "@json-render/core";
 
 // Catalog-aware types for React
 export type {

+ 29 - 8
packages/react/src/renderer.tsx

@@ -13,6 +13,7 @@ import type {
   ActionBinding,
   Catalog,
   SchemaDefinition,
+  StateStore,
 } from "@json-render/core";
 import {
   resolveElementProps,
@@ -394,7 +395,12 @@ export function Renderer({ spec, registry, loading, fallback }: RendererProps) {
 export interface JSONUIProviderProps {
   /** Component registry */
   registry: ComponentRegistry;
-  /** Initial state model */
+  /**
+   * External store (controlled mode). When provided, `initialState` and
+   * `onStateChange` are ignored.
+   */
+  store?: StateStore;
+  /** Initial state model (uncontrolled mode) */
   initialState?: Record<string, unknown>;
   /** Action handlers */
   handlers?: Record<
@@ -408,8 +414,8 @@ export interface JSONUIProviderProps {
     string,
     (value: unknown, args?: Record<string, unknown>) => boolean
   >;
-  /** Callback when state changes */
-  onStateChange?: (path: string, value: unknown) => void;
+  /** Callback when state changes (uncontrolled mode) */
+  onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
   children: ReactNode;
 }
 
@@ -418,6 +424,7 @@ export interface JSONUIProviderProps {
  */
 export function JSONUIProvider({
   registry,
+  store,
   initialState,
   handlers,
   navigate,
@@ -426,7 +433,11 @@ export function JSONUIProvider({
   children,
 }: JSONUIProviderProps) {
   return (
-    <StateProvider initialState={initialState} onStateChange={onStateChange}>
+    <StateProvider
+      store={store}
+      initialState={initialState}
+      onStateChange={onStateChange}
+    >
       <VisibilityProvider>
         <ActionProvider handlers={handlers} navigate={navigate}>
           <ValidationProvider customFunctions={validationFunctions}>
@@ -623,12 +634,17 @@ type DefineRegistryActionFn = (
 export interface CreateRendererProps {
   /** The spec to render (AI-generated JSON) */
   spec: Spec | null;
-  /** State context for dynamic values */
+  /**
+   * External store (controlled mode). When provided, `state` and
+   * `onStateChange` are ignored.
+   */
+  store?: StateStore;
+  /** State context for dynamic values (uncontrolled mode) */
   state?: Record<string, unknown>;
   /** Action handler */
   onAction?: (actionName: string, params?: Record<string, unknown>) => void;
-  /** Callback when state changes (e.g., from form inputs) */
-  onStateChange?: (path: string, value: unknown) => void;
+  /** Callback when state changes (uncontrolled mode) */
+  onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
   /** Whether the spec is currently loading/streaming */
   loading?: boolean;
   /** Fallback component for unknown types */
@@ -678,6 +694,7 @@ export function createRenderer<
   // Return the renderer component
   return function CatalogRenderer({
     spec,
+    store,
     state,
     onAction,
     onStateChange,
@@ -702,7 +719,11 @@ export function createRenderer<
       : undefined;
 
     return (
-      <StateProvider initialState={state} onStateChange={onStateChange}>
+      <StateProvider
+        store={store}
+        initialState={state}
+        onStateChange={onStateChange}
+      >
         <VisibilityProvider>
           <ActionProvider handlers={actionHandlers}>
             <ValidationProvider>

+ 1 - 1
packages/react/tsconfig.json

@@ -1,5 +1,5 @@
 {
-  "extends": "@repo/typescript-config/react-library.json",
+  "extends": "@internal/typescript-config/react-library.json",
   "compilerOptions": {
     "outDir": "dist",
     "rootDir": "src"

+ 61 - 0
packages/redux/README.md

@@ -0,0 +1,61 @@
+# @json-render/redux
+
+Redux adapter for json-render's `StateStore` interface. Wire a Redux store (or Redux Toolkit slice) as the state backend for json-render.
+
+## Installation
+
+```bash
+npm install @json-render/redux @json-render/core @json-render/react redux
+# or with Redux Toolkit (recommended):
+npm install @json-render/redux @json-render/core @json-render/react @reduxjs/toolkit
+```
+
+## Usage
+
+```ts
+import { configureStore, createSlice } from "@reduxjs/toolkit";
+import { reduxStateStore } from "@json-render/redux";
+import { StateProvider } from "@json-render/react";
+
+// 1. Define a slice for json-render state
+const uiSlice = createSlice({
+  name: "ui",
+  initialState: { count: 0 } as Record<string, unknown>,
+  reducers: {
+    replaceUiState: (_state, action) => action.payload,
+  },
+});
+
+// 2. Create the Redux store
+const reduxStore = configureStore({
+  reducer: { ui: uiSlice.reducer },
+});
+
+// 3. Create the json-render StateStore adapter
+const store = reduxStateStore({
+  store: reduxStore,
+  selector: (state) => state.ui,
+  dispatch: (next, s) => s.dispatch(uiSlice.actions.replaceUiState(next)),
+});
+
+// 4. Use it
+<StateProvider store={store}>
+  {/* json-render reads/writes go through Redux */}
+</StateProvider>
+```
+
+## API
+
+### `reduxStateStore(options)`
+
+Creates a `StateStore` backed by a Redux store.
+
+#### Options
+
+| Option | Type | Required | Description |
+|--------|------|----------|-------------|
+| `store` | `Store` | Yes | The Redux store instance |
+| `selector` | `(state) => StateModel` | Yes | Select the json-render slice from the Redux state tree. For a simple store where the entire state is the model, use `(s) => s`. |
+| `dispatch` | `(nextState, store) => void` | Yes | Dispatch an action that replaces the selected slice with the next state |
+
+The `dispatch` callback receives the full next state model and the Redux store, so you can dispatch any action shape your reducers expect.

+ 64 - 0
packages/redux/package.json

@@ -0,0 +1,64 @@
+{
+  "name": "@json-render/redux",
+  "version": "0.8.0",
+  "license": "Apache-2.0",
+  "description": "Redux adapter for json-render StateStore",
+  "keywords": [
+    "json-render",
+    "redux",
+    "state-management",
+    "adapter"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/vercel-labs/json-render.git",
+    "directory": "packages/redux"
+  },
+  "homepage": "https://github.com/vercel-labs/json-render#readme",
+  "bugs": {
+    "url": "https://github.com/vercel-labs/json-render/issues"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "main": "./dist/index.js",
+  "module": "./dist/index.mjs",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
+      "import": "./dist/index.mjs",
+      "require": "./dist/index.js"
+    }
+  },
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "tsup",
+    "dev": "tsup --watch",
+    "check-types": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@json-render/core": "workspace:*"
+  },
+  "peerDependencies": {
+    "@reduxjs/toolkit": ">=2.0.0",
+    "redux": ">=5.0.0"
+  },
+  "peerDependenciesMeta": {
+    "@reduxjs/toolkit": {
+      "optional": true
+    },
+    "redux": {
+      "optional": true
+    }
+  },
+  "devDependencies": {
+    "@reduxjs/toolkit": "^2.11.2",
+    "@internal/typescript-config": "workspace:*",
+    "redux": "^5.0.1",
+    "tsup": "^8.0.2",
+    "typescript": "^5.4.5"
+  }
+}

+ 230 - 0
packages/redux/src/index.test.ts

@@ -0,0 +1,230 @@
+import { describe, it, expect, vi } from "vitest";
+import { configureStore, createSlice } from "@reduxjs/toolkit";
+import { reduxStateStore } from "./index";
+
+function createTestStore(initial: Record<string, unknown> = {}) {
+  const uiSlice = createSlice({
+    name: "ui",
+    initialState: initial as Record<string, unknown>,
+    reducers: {
+      replace: (_state, action) => action.payload,
+    },
+  });
+
+  const reduxStore = configureStore({
+    reducer: { ui: uiSlice.reducer },
+  });
+
+  const store = reduxStateStore({
+    store: reduxStore,
+    selector: (state) => state.ui as Record<string, unknown>,
+    dispatch: (next, s) => s.dispatch(uiSlice.actions.replace(next)),
+  });
+
+  return { reduxStore, store, uiSlice };
+}
+
+describe("reduxStateStore", () => {
+  it("get/set round-trip", () => {
+    const { store } = createTestStore({ count: 0 });
+
+    expect(store.get("/count")).toBe(0);
+
+    store.set("/count", 42);
+
+    expect(store.get("/count")).toBe(42);
+    expect(store.getSnapshot().count).toBe(42);
+  });
+
+  it("update round-trip with multiple values", () => {
+    const { store } = createTestStore({});
+
+    store.update({ "/a": 1, "/b": "hello" });
+
+    expect(store.get("/a")).toBe(1);
+    expect(store.get("/b")).toBe("hello");
+    expect(store.getSnapshot()).toEqual({ a: 1, b: "hello" });
+  });
+
+  it("subscribe fires on set", () => {
+    const { store } = createTestStore({});
+    const listener = vi.fn();
+    store.subscribe(listener);
+
+    store.set("/x", 1);
+
+    expect(listener).toHaveBeenCalledTimes(1);
+  });
+
+  it("subscribe fires on update", () => {
+    const { store } = createTestStore({});
+    const listener = vi.fn();
+    store.subscribe(listener);
+
+    store.update({ "/a": 1, "/b": 2 });
+
+    expect(listener).toHaveBeenCalledTimes(1);
+  });
+
+  it("unsubscribe stops notifications", () => {
+    const { store } = createTestStore({});
+    const listener = vi.fn();
+    const unsub = store.subscribe(listener);
+
+    store.set("/x", 1);
+    expect(listener).toHaveBeenCalledTimes(1);
+
+    unsub();
+    store.set("/x", 2);
+    expect(listener).toHaveBeenCalledTimes(1);
+  });
+
+  it("getSnapshot immutability -- previous snapshot is not mutated", () => {
+    const { store } = createTestStore({ user: { name: "Alice", age: 30 } });
+    const snap1 = store.getSnapshot();
+
+    store.set("/user/name", "Bob");
+    const snap2 = store.getSnapshot();
+
+    expect(snap1.user).toEqual({ name: "Alice", age: 30 });
+    expect((snap2.user as Record<string, unknown>).name).toBe("Bob");
+    expect(snap1.user).not.toBe(snap2.user);
+  });
+
+  it("structural sharing -- untouched branches keep references", () => {
+    const { store } = createTestStore({
+      a: { x: 1 },
+      b: { y: 2 },
+    });
+    const snap1 = store.getSnapshot();
+
+    store.set("/a/x", 99);
+    const snap2 = store.getSnapshot();
+
+    expect(snap2.b).toBe(snap1.b);
+    expect(snap2.a).not.toBe(snap1.a);
+  });
+
+  it("getServerSnapshot returns same as getSnapshot", () => {
+    const { store } = createTestStore({ x: 1 });
+
+    expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
+
+    store.set("/x", 2);
+    expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
+  });
+
+  it("subscribe does NOT fire when unrelated slice changes", () => {
+    const uiSlice = createSlice({
+      name: "ui",
+      initialState: { count: 0 } as Record<string, unknown>,
+      reducers: {
+        replace: (_state, action) => action.payload,
+      },
+    });
+
+    const otherSlice = createSlice({
+      name: "other",
+      initialState: { value: "a" },
+      reducers: {
+        update: (state, action) => {
+          state.value = action.payload;
+        },
+      },
+    });
+
+    const reduxStore = configureStore({
+      reducer: {
+        ui: uiSlice.reducer,
+        other: otherSlice.reducer,
+      },
+    });
+
+    const store = reduxStateStore({
+      store: reduxStore,
+      selector: (state) => state.ui as Record<string, unknown>,
+      dispatch: (next, s) => s.dispatch(uiSlice.actions.replace(next)),
+    });
+
+    const listener = vi.fn();
+    store.subscribe(listener);
+
+    reduxStore.dispatch(otherSlice.actions.update("b"));
+
+    expect(listener).not.toHaveBeenCalled();
+    expect(store.get("/count")).toBe(0);
+  });
+
+  it("set skips dispatch when value is unchanged", () => {
+    const { store } = createTestStore({ x: 1 });
+    const snap1 = store.getSnapshot();
+    const listener = vi.fn();
+    store.subscribe(listener);
+
+    store.set("/x", 1);
+
+    expect(listener).not.toHaveBeenCalled();
+    expect(store.getSnapshot()).toBe(snap1);
+  });
+
+  it("update skips dispatch when no values changed", () => {
+    const { store } = createTestStore({ a: 1, b: 2 });
+    const snap1 = store.getSnapshot();
+    const listener = vi.fn();
+    store.subscribe(listener);
+
+    store.update({ "/a": 1, "/b": 2 });
+
+    expect(listener).not.toHaveBeenCalled();
+    expect(store.getSnapshot()).toBe(snap1);
+  });
+
+  it("works with identity selector (entire state is the model)", () => {
+    const slice = createSlice({
+      name: "root",
+      initialState: { x: 1 } as Record<string, unknown>,
+      reducers: {
+        replace: (_state, action) => action.payload,
+      },
+    });
+
+    const reduxStore = configureStore({
+      reducer: slice.reducer,
+    });
+
+    const store = reduxStateStore({
+      store: reduxStore,
+      selector: (s) => s as Record<string, unknown>,
+      dispatch: (next, s) => s.dispatch(slice.actions.replace(next)),
+    });
+
+    expect(store.get("/x")).toBe(1);
+
+    store.set("/x", 2);
+    expect(store.get("/x")).toBe(2);
+  });
+
+  it("defaults selector to identity when omitted", () => {
+    const slice = createSlice({
+      name: "root",
+      initialState: { x: 1 } as Record<string, unknown>,
+      reducers: {
+        replace: (_state, action) => action.payload,
+      },
+    });
+
+    const reduxStore = configureStore({
+      reducer: slice.reducer,
+    });
+
+    const store = reduxStateStore({
+      store: reduxStore,
+      dispatch: (next, s) => s.dispatch(slice.actions.replace(next)),
+    });
+
+    expect(store.get("/x")).toBe(1);
+
+    store.set("/x", 2);
+    expect(store.get("/x")).toBe(2);
+  });
+});

+ 88 - 0
packages/redux/src/index.ts

@@ -0,0 +1,88 @@
+import type { StateModel, StateStore } from "@json-render/core";
+import { createStoreAdapter } from "@json-render/core/store-utils";
+import type { Store, Action, UnknownAction } from "redux";
+
+export type { StateStore } from "@json-render/core";
+
+/**
+ * Options for {@link reduxStateStore}.
+ */
+export interface ReduxStateStoreOptions<
+  S extends StateModel = StateModel,
+  A extends Action = UnknownAction,
+> {
+  /** The Redux store instance. */
+  store: Store<S, A>;
+  /**
+   * Select the json-render state slice from the Redux state tree.
+   * Defaults to `(state) => state` (the entire store is the state model).
+   */
+  selector?: (state: S) => StateModel;
+  /**
+   * Dispatch a state change back to the Redux store.
+   *
+   * Called for every `set` / `update` with the full next state model.
+   * You must dispatch an action that replaces the selected slice.
+   *
+   * @example
+   * ```ts
+   * dispatch: (nextState, reduxStore) =>
+   *   reduxStore.dispatch(replaceState(nextState))
+   * ```
+   */
+  dispatch: (nextState: StateModel, store: Store<S, A>) => void;
+}
+
+/**
+ * Create a {@link StateStore} backed by a Redux store.
+ *
+ * @example
+ * ```ts
+ * import { configureStore, createSlice } from "@reduxjs/toolkit";
+ * import { reduxStateStore } from "@json-render/redux";
+ *
+ * const uiSlice = createSlice({
+ *   name: "ui",
+ *   initialState: { count: 0 } as Record<string, unknown>,
+ *   reducers: {
+ *     replaceUiState: (_state, action) => action.payload,
+ *   },
+ * });
+ *
+ * const reduxStore = configureStore({
+ *   reducer: { ui: uiSlice.reducer },
+ * });
+ *
+ * const store = reduxStateStore({
+ *   store: reduxStore,
+ *   selector: (state) => state.ui,
+ *   dispatch: (next, s) => s.dispatch(uiSlice.actions.replaceUiState(next)),
+ * });
+ *
+ * <StateProvider store={store}>...</StateProvider>
+ * ```
+ */
+export function reduxStateStore<
+  S extends StateModel = StateModel,
+  A extends Action = UnknownAction,
+>(options: ReduxStateStoreOptions<S, A>): StateStore {
+  const { store, selector = (s: S) => s as StateModel, dispatch } = options;
+
+  return createStoreAdapter({
+    getSnapshot: () => selector(store.getState()),
+    setSnapshot: (next) => dispatch(next, store),
+    subscribe(listener) {
+      let prev = selector(store.getState());
+      return store.subscribe(() => {
+        const current = selector(store.getState());
+        if (current !== prev) {
+          prev = current;
+          listener();
+          // Re-read after listener in case it triggered a synchronous dispatch;
+          // absorb that change so it doesn't fire a duplicate notification.
+          prev = selector(store.getState());
+        }
+      });
+    },
+  });
+}

+ 9 - 0
packages/redux/tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "extends": "@internal/typescript-config/base.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src"
+  },
+  "include": ["src"],
+  "exclude": ["node_modules", "dist"]
+}

+ 15 - 0
packages/redux/tsup.config.ts

@@ -0,0 +1,15 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+  entry: ["src/index.ts"],
+  format: ["cjs", "esm"],
+  dts: true,
+  sourcemap: true,
+  clean: true,
+  external: [
+    "@json-render/core",
+    "@json-render/core/store-utils",
+    "redux",
+    "@reduxjs/toolkit",
+  ],
+});

+ 1 - 1
packages/remotion/package.json

@@ -54,7 +54,7 @@
     "@json-render/core": "workspace:*"
   },
   "devDependencies": {
-    "@repo/typescript-config": "workspace:*",
+    "@internal/typescript-config": "workspace:*",
     "@types/react": "19.2.3",
     "remotion": "4.0.418",
     "tsup": "^8.0.2",

+ 1 - 1
packages/remotion/tsconfig.json

@@ -1,5 +1,5 @@
 {
-  "extends": "@repo/typescript-config/react-library.json",
+  "extends": "@internal/typescript-config/react-library.json",
   "compilerOptions": {
     "outDir": "dist",
     "rootDir": "src"

+ 1 - 1
packages/shadcn/package.json

@@ -65,7 +65,7 @@
     "vaul": "^1.1.2"
   },
   "devDependencies": {
-    "@repo/typescript-config": "workspace:*",
+    "@internal/typescript-config": "workspace:*",
     "@types/react": "19.2.3",
     "tsup": "^8.0.2",
     "typescript": "^5.4.5",

+ 1 - 1
packages/shadcn/tsconfig.json

@@ -1,5 +1,5 @@
 {
-  "extends": "@repo/typescript-config/react-library.json",
+  "extends": "@internal/typescript-config/react-library.json",
   "compilerOptions": {
     "outDir": "dist",
     "rootDir": "src"

+ 1 - 1
packages/typescript-config/package.json

@@ -1,5 +1,5 @@
 {
-  "name": "@repo/typescript-config",
+  "name": "@internal/typescript-config",
   "version": "0.0.0",
   "private": true,
   "license": "Apache-2.0",

+ 1 - 1
packages/ui/eslint.config.mjs

@@ -1,4 +1,4 @@
-import { config } from "@repo/eslint-config/react-internal";
+import { config } from "@internal/eslint-config/react-internal";
 
 /** @type {import("eslint").Linter.Config} */
 export default config;

+ 3 - 3
packages/ui/package.json

@@ -1,5 +1,5 @@
 {
-  "name": "@repo/ui",
+  "name": "@internal/ui",
   "version": "0.0.0",
   "private": true,
   "license": "Apache-2.0",
@@ -12,8 +12,8 @@
     "check-types": "tsc --noEmit"
   },
   "devDependencies": {
-    "@repo/eslint-config": "workspace:*",
-    "@repo/typescript-config": "workspace:*",
+    "@internal/eslint-config": "workspace:*",
+    "@internal/typescript-config": "workspace:*",
     "@types/node": "^22.15.3",
     "@types/react": "19.2.3",
     "@types/react-dom": "19.2.3",

+ 1 - 1
packages/ui/tsconfig.json

@@ -1,5 +1,5 @@
 {
-  "extends": "@repo/typescript-config/react-library.json",
+  "extends": "@internal/typescript-config/react-library.json",
   "compilerOptions": {
     "outDir": "dist"
   },

+ 63 - 0
packages/zustand/README.md

@@ -0,0 +1,63 @@
+# @json-render/zustand
+
+Zustand adapter for json-render's `StateStore` interface. Wire a Zustand vanilla store as the state backend for json-render.
+
+## Installation
+
+```bash
+npm install @json-render/zustand @json-render/core @json-render/react zustand
+```
+
+> **Note:** This adapter requires Zustand v5+. Zustand v4 is not supported due to
+> breaking API changes in the vanilla store interface (`createStore`, `StoreApi`).
+
+## Usage
+
+```ts
+import { createStore } from "zustand/vanilla";
+import { zustandStateStore } from "@json-render/zustand";
+import { StateProvider } from "@json-render/react";
+
+// 1. Create a Zustand vanilla store
+const bearStore = createStore(() => ({
+  count: 0,
+  name: "Bear",
+}));
+
+// 2. Create the json-render StateStore adapter
+const store = zustandStateStore({ store: bearStore });
+
+// 3. Use it
+<StateProvider store={store}>
+  {/* json-render reads/writes go through Zustand */}
+</StateProvider>
+```
+
+### With a nested slice
+
+```ts
+const appStore = createStore(() => ({
+  ui: { count: 0 },
+  auth: { token: null },
+}));
+
+const store = zustandStateStore({
+  store: appStore,
+  selector: (s) => s.ui,
+  updater: (next, s) => s.setState({ ui: next }),
+});
+```
+
+## API
+
+### `zustandStateStore(options)`
+
+Creates a `StateStore` backed by a Zustand store.
+
+#### Options
+
+| Option | Type | Required | Description |
+|--------|------|----------|-------------|
+| `store` | `StoreApi<S>` | Yes | A Zustand vanilla store (from `createStore` in `zustand/vanilla`) |
+| `selector` | `(state) => StateModel` | No | Select the json-render slice from the store state. Defaults to the entire state. |
+| `updater` | `(nextState, store) => void` | No | Apply the next state back to the Zustand store. Defaults to a shallow merge so that keys outside the json-render model are preserved. Override for nested slices, or pass `(next, s) => s.setState(next, true)` for full replacement. |

+ 54 - 0
packages/zustand/package.json

@@ -0,0 +1,54 @@
+{
+  "name": "@json-render/zustand",
+  "version": "0.8.0",
+  "license": "Apache-2.0",
+  "description": "Zustand adapter for json-render StateStore",
+  "keywords": [
+    "json-render",
+    "zustand",
+    "state-management",
+    "adapter"
+  ],
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/vercel-labs/json-render.git",
+    "directory": "packages/zustand"
+  },
+  "homepage": "https://github.com/vercel-labs/json-render#readme",
+  "bugs": {
+    "url": "https://github.com/vercel-labs/json-render/issues"
+  },
+  "publishConfig": {
+    "access": "public"
+  },
+  "main": "./dist/index.js",
+  "module": "./dist/index.mjs",
+  "types": "./dist/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./dist/index.d.ts",
+      "import": "./dist/index.mjs",
+      "require": "./dist/index.js"
+    }
+  },
+  "files": [
+    "dist"
+  ],
+  "scripts": {
+    "build": "tsup",
+    "dev": "tsup --watch",
+    "check-types": "tsc --noEmit"
+  },
+  "dependencies": {
+    "@json-render/core": "workspace:*"
+  },
+  "peerDependencies": {
+    "zustand": ">=5.0.0"
+  },
+  "devDependencies": {
+    "@internal/typescript-config": "workspace:*",
+    "tsup": "^8.0.2",
+    "typescript": "^5.4.5",
+    "zustand": "^5.0.11"
+  }
+}

+ 198 - 0
packages/zustand/src/index.test.ts

@@ -0,0 +1,198 @@
+import { describe, it, expect, vi } from "vitest";
+import { createStore } from "zustand/vanilla";
+import { zustandStateStore } from "./index";
+
+describe("zustandStateStore", () => {
+  it("get/set round-trip", () => {
+    const zStore = createStore<Record<string, unknown>>()(() => ({ count: 0 }));
+    const store = zustandStateStore({ store: zStore });
+
+    expect(store.get("/count")).toBe(0);
+
+    store.set("/count", 42);
+
+    expect(store.get("/count")).toBe(42);
+    expect(store.getSnapshot().count).toBe(42);
+  });
+
+  it("update round-trip with multiple values", () => {
+    const zStore = createStore<Record<string, unknown>>()(() => ({}));
+    const store = zustandStateStore({ store: zStore });
+
+    store.update({ "/a": 1, "/b": "hello" });
+
+    expect(store.get("/a")).toBe(1);
+    expect(store.get("/b")).toBe("hello");
+    expect(store.getSnapshot()).toEqual({ a: 1, b: "hello" });
+  });
+
+  it("subscribe fires on set", () => {
+    const zStore = createStore<Record<string, unknown>>()(() => ({}));
+    const store = zustandStateStore({ store: zStore });
+    const listener = vi.fn();
+    store.subscribe(listener);
+
+    store.set("/x", 1);
+
+    expect(listener).toHaveBeenCalledTimes(1);
+  });
+
+  it("subscribe fires on update", () => {
+    const zStore = createStore<Record<string, unknown>>()(() => ({}));
+    const store = zustandStateStore({ store: zStore });
+    const listener = vi.fn();
+    store.subscribe(listener);
+
+    store.update({ "/a": 1, "/b": 2 });
+
+    expect(listener).toHaveBeenCalledTimes(1);
+  });
+
+  it("unsubscribe stops notifications", () => {
+    const zStore = createStore<Record<string, unknown>>()(() => ({}));
+    const store = zustandStateStore({ store: zStore });
+    const listener = vi.fn();
+    const unsub = store.subscribe(listener);
+
+    store.set("/x", 1);
+    expect(listener).toHaveBeenCalledTimes(1);
+
+    unsub();
+    store.set("/x", 2);
+    expect(listener).toHaveBeenCalledTimes(1);
+  });
+
+  it("getSnapshot immutability -- previous snapshot is not mutated", () => {
+    const zStore = createStore<Record<string, unknown>>()(() => ({
+      user: { name: "Alice", age: 30 },
+    }));
+    const store = zustandStateStore({ store: zStore });
+    const snap1 = store.getSnapshot();
+
+    store.set("/user/name", "Bob");
+    const snap2 = store.getSnapshot();
+
+    expect(snap1.user).toEqual({ name: "Alice", age: 30 });
+    expect((snap2.user as Record<string, unknown>).name).toBe("Bob");
+    expect(snap1.user).not.toBe(snap2.user);
+  });
+
+  it("structural sharing -- untouched branches keep references", () => {
+    const zStore = createStore<Record<string, unknown>>()(() => ({
+      a: { x: 1 },
+      b: { y: 2 },
+    }));
+    const store = zustandStateStore({ store: zStore });
+    const snap1 = store.getSnapshot();
+
+    store.set("/a/x", 99);
+    const snap2 = store.getSnapshot();
+
+    expect(snap2.b).toBe(snap1.b);
+    expect(snap2.a).not.toBe(snap1.a);
+  });
+
+  it("getServerSnapshot returns same as getSnapshot", () => {
+    const zStore = createStore<Record<string, unknown>>()(() => ({ x: 1 }));
+    const store = zustandStateStore({ store: zStore });
+
+    expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
+
+    store.set("/x", 2);
+    expect(store.getServerSnapshot!()).toBe(store.getSnapshot());
+  });
+
+  it("set skips update when value is unchanged", () => {
+    const zStore = createStore<Record<string, unknown>>()(() => ({ x: 1 }));
+    const store = zustandStateStore({ store: zStore });
+    const snap1 = store.getSnapshot();
+    const listener = vi.fn();
+    store.subscribe(listener);
+
+    store.set("/x", 1);
+
+    expect(listener).not.toHaveBeenCalled();
+    expect(store.getSnapshot()).toBe(snap1);
+  });
+
+  it("update skips update when no values changed", () => {
+    const zStore = createStore<Record<string, unknown>>()(() => ({
+      a: 1,
+      b: 2,
+    }));
+    const store = zustandStateStore({ store: zStore });
+    const snap1 = store.getSnapshot();
+    const listener = vi.fn();
+    store.subscribe(listener);
+
+    store.update({ "/a": 1, "/b": 2 });
+
+    expect(listener).not.toHaveBeenCalled();
+    expect(store.getSnapshot()).toBe(snap1);
+  });
+
+  it("subscribe does NOT fire when unrelated slice changes", () => {
+    interface AppState extends Record<string, unknown> {
+      ui: Record<string, unknown>;
+      other: { value: string };
+    }
+
+    const zStore = createStore<AppState>()(() => ({
+      ui: { count: 0 },
+      other: { value: "a" },
+    }));
+
+    const store = zustandStateStore({
+      store: zStore,
+      selector: (s) => s.ui,
+      updater: (next, s) => s.setState({ ui: next }),
+    });
+
+    const listener = vi.fn();
+    store.subscribe(listener);
+
+    zStore.setState({ other: { value: "b" } });
+
+    expect(listener).not.toHaveBeenCalled();
+    expect(store.get("/count")).toBe(0);
+  });
+
+  it("default updater preserves keys outside the json-render model", () => {
+    interface AppState extends Record<string, unknown> {
+      count: number;
+      theme: string;
+    }
+
+    const zStore = createStore<AppState>()(() => ({
+      count: 0,
+      theme: "dark",
+    }));
+    const store = zustandStateStore({ store: zStore });
+
+    store.set("/count", 5);
+
+    expect(store.get("/count")).toBe(5);
+    expect(zStore.getState().theme).toBe("dark");
+  });
+
+  it("works with custom selector and updater", () => {
+    interface AppState extends Record<string, unknown> {
+      ui: Record<string, unknown>;
+    }
+
+    const zStore = createStore<AppState>()(() => ({
+      ui: { count: 0 },
+    }));
+
+    const store = zustandStateStore({
+      store: zStore,
+      selector: (s) => s.ui,
+      updater: (next, s) => s.setState({ ui: next }),
+    });
+
+    store.set("/count", 5);
+
+    expect(store.get("/count")).toBe(5);
+    expect(zStore.getState().ui.count).toBe(5);
+  });
+});

+ 83 - 0
packages/zustand/src/index.ts

@@ -0,0 +1,83 @@
+import type { StateModel, StateStore } from "@json-render/core";
+import { createStoreAdapter } from "@json-render/core/store-utils";
+import type { StoreApi } from "zustand";
+
+export type { StateStore } from "@json-render/core";
+
+/**
+ * Options for {@link zustandStateStore}.
+ */
+export interface ZustandStateStoreOptions<S extends StateModel = StateModel> {
+  /** A Zustand vanilla store (created with `createStore` from `zustand/vanilla`). */
+  store: StoreApi<S>;
+  /**
+   * Select the json-render state slice from the Zustand store state.
+   * Defaults to `(state) => state` (the entire store is the state model).
+   */
+  selector?: (state: S) => StateModel;
+  /**
+   * Apply a state change back to the Zustand store.
+   * Defaults to a shallow merge (`store.setState(next)`) so that keys
+   * outside the json-render model are preserved. Override this if you use
+   * a selector and only want to update a nested slice, or pass
+   * `(next, s) => s.setState(next as S, true)` for full replacement.
+   */
+  updater?: (nextState: StateModel, store: StoreApi<S>) => void;
+}
+
+/**
+ * Create a {@link StateStore} backed by a Zustand store.
+ *
+ * @example
+ * ```ts
+ * import { createStore } from "zustand/vanilla";
+ * import { zustandStateStore } from "@json-render/zustand";
+ *
+ * const bearStore = createStore(() => ({ count: 0, name: "Bear" }));
+ *
+ * const store = zustandStateStore({ store: bearStore });
+ *
+ * <StateProvider store={store}>...</StateProvider>
+ * ```
+ *
+ * @example Using a selector for a nested slice:
+ * ```ts
+ * const appStore = createStore(() => ({
+ *   ui: { count: 0 },
+ *   auth: { token: null },
+ * }));
+ *
+ * const store = zustandStateStore({
+ *   store: appStore,
+ *   selector: (s) => s.ui,
+ *   updater: (next, s) => s.setState({ ui: next }),
+ * });
+ * ```
+ */
+export function zustandStateStore<S extends StateModel = StateModel>(
+  options: ZustandStateStoreOptions<S>,
+): StateStore {
+  const {
+    store,
+    selector = (s: S) => s as StateModel,
+    updater = (next, s) => s.setState(next as Partial<S>),
+  } = options;
+
+  return createStoreAdapter({
+    getSnapshot: () => selector(store.getState()),
+    setSnapshot: (next) => updater(next, store),
+    subscribe(listener) {
+      let prev = selector(store.getState());
+      return store.subscribe(() => {
+        const current = selector(store.getState());
+        if (current !== prev) {
+          prev = current;
+          listener();
+          // Re-read after listener in case it triggered a synchronous dispatch;
+          // absorb that change so it doesn't fire a duplicate notification.
+          prev = selector(store.getState());
+        }
+      });
+    },
+  });
+}

+ 9 - 0
packages/zustand/tsconfig.json

@@ -0,0 +1,9 @@
+{
+  "extends": "@internal/typescript-config/base.json",
+  "compilerOptions": {
+    "outDir": "dist",
+    "rootDir": "src"
+  },
+  "include": ["src"],
+  "exclude": ["node_modules", "dist"]
+}

+ 10 - 0
packages/zustand/tsup.config.ts

@@ -0,0 +1,10 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+  entry: ["src/index.ts"],
+  format: ["cjs", "esm"],
+  dts: true,
+  sourcemap: true,
+  clean: true,
+  external: ["@json-render/core", "@json-render/core/store-utils", "zustand"],
+});

+ 303 - 93
pnpm-lock.yaml

@@ -168,10 +168,10 @@ importers:
         specifier: ^4.0.0
         version: 4.3.5
     devDependencies:
-      '@repo/eslint-config':
+      '@internal/eslint-config':
         specifier: workspace:*
         version: link:../../packages/eslint-config
-      '@repo/typescript-config':
+      '@internal/typescript-config':
         specifier: workspace:*
         version: link:../../packages/typescript-config
       '@tailwindcss/postcss':
@@ -224,10 +224,10 @@ importers:
         version: link:../../packages/shadcn
       '@react-three/drei':
         specifier: ^10.7.7
-        version: 10.7.7(@react-three/fiber@9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.182.0))(@types/react@19.2.3)(@types/three@0.182.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.182.0)
+        version: 10.7.7(@react-three/fiber@9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.182.0))(@types/react@19.2.3)(@types/three@0.182.0)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.182.0)
       '@react-three/fiber':
         specifier: ^9.5.0
-        version: 9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.182.0)
+        version: 9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.182.0)
       '@streamdown/code':
         specifier: ^1.0.2
         version: 1.0.2(react@19.2.4)
@@ -277,7 +277,7 @@ importers:
         specifier: 4.3.5
         version: 4.3.5
     devDependencies:
-      '@repo/eslint-config':
+      '@internal/eslint-config':
         specifier: workspace:*
         version: link:../../packages/eslint-config
       '@tailwindcss/postcss':
@@ -386,7 +386,7 @@ importers:
         specifier: ^4.0.0
         version: 4.3.5
     devDependencies:
-      '@repo/eslint-config':
+      '@internal/eslint-config':
         specifier: workspace:*
         version: link:../../packages/eslint-config
       '@tailwindcss/postcss':
@@ -468,7 +468,7 @@ importers:
         specifier: 4.3.5
         version: 4.3.5
     devDependencies:
-      '@repo/eslint-config':
+      '@internal/eslint-config':
         specifier: workspace:*
         version: link:../../packages/eslint-config
       '@tailwindcss/postcss':
@@ -611,7 +611,7 @@ importers:
         specifier: 4.3.5
         version: 4.3.5
     devDependencies:
-      '@repo/typescript-config':
+      '@internal/typescript-config':
         specifier: workspace:*
         version: link:../../packages/typescript-config
       '@types/node':
@@ -687,7 +687,7 @@ importers:
         specifier: ^4.0.0
         version: 4.3.5
     devDependencies:
-      '@repo/eslint-config':
+      '@internal/eslint-config':
         specifier: workspace:*
         version: link:../../packages/eslint-config
       '@tailwindcss/postcss':
@@ -758,7 +758,7 @@ importers:
         specifier: ^4.0.0
         version: 4.3.5
     devDependencies:
-      '@repo/eslint-config':
+      '@internal/eslint-config':
         specifier: workspace:*
         version: link:../../../packages/eslint-config
       '@stripe/ui-extension-tools':
@@ -786,7 +786,7 @@ importers:
         specifier: ^4.0.0
         version: 4.3.5
     devDependencies:
-      '@repo/eslint-config':
+      '@internal/eslint-config':
         specifier: workspace:*
         version: link:../../../packages/eslint-config
       '@stripe/ui-extension-tools':
@@ -802,7 +802,7 @@ importers:
         specifier: workspace:*
         version: link:../core
     devDependencies:
-      '@repo/typescript-config':
+      '@internal/typescript-config':
         specifier: workspace:*
         version: link:../typescript-config
       tsup:
@@ -818,7 +818,7 @@ importers:
         specifier: ^4.0.0
         version: 4.3.5
     devDependencies:
-      '@repo/typescript-config':
+      '@internal/typescript-config':
         specifier: workspace:*
         version: link:../typescript-config
       tsup:
@@ -864,8 +864,30 @@ importers:
         specifier: ^8.50.0
         version: 8.53.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.2)
 
+  packages/jotai:
+    dependencies:
+      '@json-render/core':
+        specifier: workspace:*
+        version: link:../core
+    devDependencies:
+      '@internal/typescript-config':
+        specifier: workspace:*
+        version: link:../typescript-config
+      jotai:
+        specifier: ^2.18.0
+        version: 2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.3)(react@19.2.4)
+      tsup:
+        specifier: ^8.0.2
+        version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.2)(yaml@2.8.2)
+      typescript:
+        specifier: ^5.4.5
+        version: 5.9.2
+
   packages/react:
     dependencies:
+      '@internal/react-state':
+        specifier: workspace:*
+        version: link:../react-state
       '@json-render/core':
         specifier: workspace:*
         version: link:../core
@@ -873,7 +895,7 @@ importers:
         specifier: ^19.2.3
         version: 19.2.3
     devDependencies:
-      '@repo/typescript-config':
+      '@internal/typescript-config':
         specifier: workspace:*
         version: link:../typescript-config
       '@types/react':
@@ -888,14 +910,17 @@ importers:
 
   packages/react-native:
     dependencies:
+      '@internal/react-state':
+        specifier: workspace:*
+        version: link:../react-state
       '@json-render/core':
         specifier: workspace:*
         version: link:../core
       react:
-        specifier: ^19.0.0
-        version: 19.2.3
+        specifier: ^18.0.0 || ^19.0.0
+        version: 19.2.4
     devDependencies:
-      '@repo/typescript-config':
+      '@internal/typescript-config':
         specifier: workspace:*
         version: link:../typescript-config
       '@types/react':
@@ -903,7 +928,7 @@ importers:
         version: 19.2.3
       react-native:
         specifier: 0.83.1
-        version: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.3)
+        version: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
       tsup:
         specifier: ^8.0.2
         version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.2)(yaml@2.8.2)
@@ -916,6 +941,9 @@ importers:
 
   packages/react-pdf:
     dependencies:
+      '@internal/react-state':
+        specifier: workspace:*
+        version: link:../react-state
       '@json-render/core':
         specifier: workspace:*
         version: link:../core
@@ -923,10 +951,10 @@ importers:
         specifier: ^4.3.2
         version: 4.3.2(react@19.2.4)
       react:
-        specifier: ^19.0.0
+        specifier: ^18.0.0 || ^19.0.0
         version: 19.2.4
     devDependencies:
-      '@repo/typescript-config':
+      '@internal/typescript-config':
         specifier: workspace:*
         version: link:../typescript-config
       '@types/react':
@@ -942,6 +970,56 @@ importers:
         specifier: ^4.0.0
         version: 4.3.5
 
+  packages/react-state:
+    dependencies:
+      '@json-render/core':
+        specifier: workspace:*
+        version: link:../core
+    devDependencies:
+      '@internal/typescript-config':
+        specifier: workspace:*
+        version: link:../typescript-config
+      '@testing-library/react':
+        specifier: ^16.3.2
+        version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
+      '@types/react':
+        specifier: 19.2.3
+        version: 19.2.3
+      react:
+        specifier: ^19.0.0
+        version: 19.2.4
+      react-dom:
+        specifier: ^19.0.0
+        version: 19.2.4(react@19.2.4)
+      tsup:
+        specifier: ^8.0.2
+        version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.2)(yaml@2.8.2)
+      typescript:
+        specifier: ^5.4.5
+        version: 5.9.2
+
+  packages/redux:
+    dependencies:
+      '@json-render/core':
+        specifier: workspace:*
+        version: link:../core
+    devDependencies:
+      '@internal/typescript-config':
+        specifier: workspace:*
+        version: link:../typescript-config
+      '@reduxjs/toolkit':
+        specifier: ^2.11.2
+        version: 2.11.2(react@19.2.4)
+      redux:
+        specifier: ^5.0.1
+        version: 5.0.1
+      tsup:
+        specifier: ^8.0.2
+        version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.2)(yaml@2.8.2)
+      typescript:
+        specifier: ^5.4.5
+        version: 5.9.2
+
   packages/remotion:
     dependencies:
       '@json-render/core':
@@ -951,7 +1029,7 @@ importers:
         specifier: ^19.2.3
         version: 19.2.3
     devDependencies:
-      '@repo/typescript-config':
+      '@internal/typescript-config':
         specifier: workspace:*
         version: link:../typescript-config
       '@types/react':
@@ -1009,7 +1087,7 @@ importers:
         specifier: ^1.1.2
         version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
     devDependencies:
-      '@repo/typescript-config':
+      '@internal/typescript-config':
         specifier: workspace:*
         version: link:../typescript-config
       '@types/react':
@@ -1036,10 +1114,10 @@ importers:
         specifier: 19.2.3
         version: 19.2.3(react@19.2.3)
     devDependencies:
-      '@repo/eslint-config':
+      '@internal/eslint-config':
         specifier: workspace:*
         version: link:../eslint-config
-      '@repo/typescript-config':
+      '@internal/typescript-config':
         specifier: workspace:*
         version: link:../typescript-config
       '@types/node':
@@ -1058,6 +1136,67 @@ importers:
         specifier: 5.9.2
         version: 5.9.2
 
+  packages/zustand:
+    dependencies:
+      '@json-render/core':
+        specifier: workspace:*
+        version: link:../core
+    devDependencies:
+      '@internal/typescript-config':
+        specifier: workspace:*
+        version: link:../typescript-config
+      tsup:
+        specifier: ^8.0.2
+        version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.2)(yaml@2.8.2)
+      typescript:
+        specifier: ^5.4.5
+        version: 5.9.2
+      zustand:
+        specifier: ^5.0.11
+        version: 5.0.11(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
+
+  tests/e2e:
+    devDependencies:
+      '@ai-sdk/gateway':
+        specifier: ^3.0.53
+        version: 3.0.53(zod@4.3.5)
+      '@json-render/core':
+        specifier: workspace:*
+        version: link:../../packages/core
+      '@json-render/jotai':
+        specifier: workspace:*
+        version: link:../../packages/jotai
+      '@json-render/react':
+        specifier: workspace:*
+        version: link:../../packages/react
+      '@json-render/redux':
+        specifier: workspace:*
+        version: link:../../packages/redux
+      '@json-render/zustand':
+        specifier: workspace:*
+        version: link:../../packages/zustand
+      '@reduxjs/toolkit':
+        specifier: ^2.11.2
+        version: 2.11.2(react@19.2.4)
+      ai:
+        specifier: ^6.0.97
+        version: 6.0.97(zod@4.3.5)
+      jotai:
+        specifier: ^2.18.0
+        version: 2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.3)(react@19.2.4)
+      redux:
+        specifier: ^5.0.1
+        version: 5.0.1
+      vitest:
+        specifier: ^4.0.17
+        version: 4.0.17(@opentelemetry/api@1.9.0)(@types/node@22.19.6)(jiti@2.6.1)(jsdom@27.4.0)(lightningcss@1.30.2)(msw@2.12.10(@types/node@22.19.6)(typescript@5.9.2))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
+      zod:
+        specifier: ^4.3.5
+        version: 4.3.5
+      zustand:
+        specifier: ^5.0.11
+        version: 5.0.11(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
+
 packages:
 
   '@0no-co/graphql.web@1.2.0':
@@ -1107,6 +1246,12 @@ packages:
     peerDependencies:
       zod: ^3.25.76 || ^4.1.8
 
+  '@ai-sdk/gateway@3.0.53':
+    resolution: {integrity: sha512-QT3FEoNARMRlk8JJVR7L98exiK9C8AGfrEJVbRxBT1yIXKs/N19o/+PsjTRVsARgDJNcy9JbJp1FspKucEat0Q==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      zod: ^3.25.76 || ^4.1.8
+
   '@ai-sdk/provider-utils@4.0.13':
     resolution: {integrity: sha512-HHG72BN4d+OWTcq2NwTxOm/2qvk1duYsnhCDtsbYwn/h/4zeqURu1S0+Cn0nY2Ysq9a9HGKvrYuMn9bgFhR2Og==}
     engines: {node: '>=18'}
@@ -4175,6 +4320,17 @@ packages:
       react-native:
         optional: true
 
+  '@reduxjs/toolkit@2.11.2':
+    resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
+    peerDependencies:
+      react: ^16.9.0 || ^17.0.0 || ^18 || ^19
+      react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0
+    peerDependenciesMeta:
+      react:
+        optional: true
+      react-redux:
+        optional: true
+
   '@remote-ui/async-subscription@2.1.18':
     resolution: {integrity: sha512-O+76qOiTGN6iJZBFncELNIBnhCbHAIYrsrG0n3SjmwXE9Gy6nl4xZoa5FqIcw5VPz7EAdwKBkFsRE5uyQB7FOw==}
 
@@ -4484,6 +4640,9 @@ packages:
   '@standard-schema/spec@1.1.0':
     resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
 
+  '@standard-schema/utils@0.3.0':
+    resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
+
   '@streamdown/code@1.0.2':
     resolution: {integrity: sha512-QKLS3sC8no5y0YvhGLA+ZjtNhznWU09IvFcjRKgSA35ulckMLw3b5T1ha+o1DaW8BS8l0zceLPFZa3/X9+agWQ==}
     peerDependencies:
@@ -4616,6 +4775,21 @@ packages:
       '@types/react-dom':
         optional: true
 
+  '@testing-library/react@16.3.2':
+    resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      '@testing-library/dom': ^10.0.0
+      '@types/react': ^18.0.0 || ^19.0.0
+      '@types/react-dom': ^18.0.0 || ^19.0.0
+      react: ^18.0.0 || ^19.0.0
+      react-dom: ^18.0.0 || ^19.0.0
+    peerDependenciesMeta:
+      '@types/react':
+        optional: true
+      '@types/react-dom':
+        optional: true
+
   '@tokenizer/inflate@0.4.1':
     resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==}
     engines: {node: '>=18'}
@@ -5195,6 +5369,12 @@ packages:
     peerDependencies:
       zod: ^3.25.76 || ^4.1.8
 
+  ai@6.0.97:
+    resolution: {integrity: sha512-eZIAcBymwGhBwncRH/v9pillZNMeRCDkc4BwcvwXerXd7sxjVxRis3ZNCNCpP02pVH4NLs81ljm4cElC4vbNcQ==}
+    engines: {node: '>=18'}
+    peerDependencies:
+      zod: ^3.25.76 || ^4.1.8
+
   ajv-formats@2.1.1:
     resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
     peerDependencies:
@@ -7278,6 +7458,9 @@ packages:
   immediate@3.0.6:
     resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
 
+  immer@11.1.4:
+    resolution: {integrity: sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==}
+
   import-fresh@3.3.1:
     resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
     engines: {node: '>=6'}
@@ -7811,6 +7994,24 @@ packages:
   jose@6.1.3:
     resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
 
+  jotai@2.18.0:
+    resolution: {integrity: sha512-XI38kGWAvtxAZ+cwHcTgJsd+kJOJGf3OfL4XYaXWZMZ7IIY8e53abpIHvtVn1eAgJ5dlgwlGFnP4psrZ/vZbtA==}
+    engines: {node: '>=12.20.0'}
+    peerDependencies:
+      '@babel/core': '>=7.0.0'
+      '@babel/template': '>=7.0.0'
+      '@types/react': '>=17.0.0'
+      react: '>=17.0.0'
+    peerDependenciesMeta:
+      '@babel/core':
+        optional: true
+      '@babel/template':
+        optional: true
+      '@types/react':
+        optional: true
+      react:
+        optional: true
+
   joycon@3.1.1:
     resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==}
     engines: {node: '>=10'}
@@ -9370,6 +9571,14 @@ packages:
   recma-stringify@1.0.0:
     resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==}
 
+  redux-thunk@3.1.0:
+    resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
+    peerDependencies:
+      redux: ^5.0.0
+
+  redux@5.0.1:
+    resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==}
+
   reflect.getprototypeof@1.0.10:
     resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
     engines: {node: '>= 0.4'}
@@ -9462,6 +9671,9 @@ packages:
   requires-port@1.0.0:
     resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==}
 
+  reselect@5.1.1:
+    resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==}
+
   resolve-cwd@3.0.0:
     resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==}
     engines: {node: '>=8'}
@@ -11058,6 +11270,13 @@ snapshots:
       '@vercel/oidc': 3.1.0
       zod: 4.3.5
 
+  '@ai-sdk/gateway@3.0.53(zod@4.3.5)':
+    dependencies:
+      '@ai-sdk/provider': 3.0.8
+      '@ai-sdk/provider-utils': 4.0.15(zod@4.3.5)
+      '@vercel/oidc': 3.1.0
+      zod: 4.3.5
+
   '@ai-sdk/provider-utils@4.0.13(zod@4.3.5)':
     dependencies:
       '@ai-sdk/provider': 3.0.7
@@ -15374,15 +15593,6 @@ snapshots:
     optionalDependencies:
       '@types/react': 19.1.17
 
-  '@react-native/virtualized-lists@0.83.1(@types/react@19.2.3)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.3))(react@19.2.3)':
-    dependencies:
-      invariant: 2.2.4
-      nullthrows: 1.1.1
-      react: 19.2.3
-      react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.3)
-    optionalDependencies:
-      '@types/react': 19.2.3
-
   '@react-native/virtualized-lists@0.83.1(@types/react@19.2.3)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)':
     dependencies:
       invariant: 2.2.4
@@ -15391,7 +15601,6 @@ snapshots:
       react-native: 0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)
     optionalDependencies:
       '@types/react': 19.2.3
-    optional: true
 
   '@react-navigation/bottom-tabs@7.12.0(@react-navigation/native@7.1.28(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.4.0(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-screens@4.11.1(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
     dependencies:
@@ -15621,12 +15830,12 @@ snapshots:
       '@react-pdf/primitives': 4.1.1
       '@react-pdf/stylesheet': 6.1.2
 
-  '@react-three/drei@10.7.7(@react-three/fiber@9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.182.0))(@types/react@19.2.3)(@types/three@0.182.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.182.0)':
+  '@react-three/drei@10.7.7(@react-three/fiber@9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.182.0))(@types/react@19.2.3)(@types/three@0.182.0)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(three@0.182.0)':
     dependencies:
       '@babel/runtime': 7.28.6
       '@mediapipe/tasks-vision': 0.10.17
       '@monogrid/gainmap-js': 3.4.0(three@0.182.0)
-      '@react-three/fiber': 9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.182.0)
+      '@react-three/fiber': 9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.182.0)
       '@use-gesture/react': 10.3.1(react@19.2.4)
       camera-controls: 3.1.2(three@0.182.0)
       cross-env: 7.0.3
@@ -15643,10 +15852,10 @@ snapshots:
       three-mesh-bvh: 0.8.3(three@0.182.0)
       three-stdlib: 2.36.1(three@0.182.0)
       troika-three-text: 0.52.4(three@0.182.0)
-      tunnel-rat: 0.1.2(@types/react@19.2.3)(react@19.2.4)
+      tunnel-rat: 0.1.2(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4)
       use-sync-external-store: 1.6.0(react@19.2.4)
       utility-types: 3.11.0
-      zustand: 5.0.11(@types/react@19.2.3)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
+      zustand: 5.0.11(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
     optionalDependencies:
       react-dom: 19.2.4(react@19.2.4)
     transitivePeerDependencies:
@@ -15654,7 +15863,7 @@ snapshots:
       - '@types/three'
       - immer
 
-  '@react-three/fiber@9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.182.0)':
+  '@react-three/fiber@9.5.0(@types/react@19.2.3)(expo-asset@12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4))(expo-file-system@19.0.21(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4)))(expo@54.0.33)(immer@11.1.4)(react-dom@19.2.4(react@19.2.4))(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)(three@0.182.0)':
     dependencies:
       '@babel/runtime': 7.28.6
       '@types/webxr': 0.5.24
@@ -15667,7 +15876,7 @@ snapshots:
       suspend-react: 0.1.3(react@19.2.4)
       three: 0.182.0
       use-sync-external-store: 1.6.0(react@19.2.4)
-      zustand: 5.0.11(@types/react@19.2.3)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
+      zustand: 5.0.11(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))
     optionalDependencies:
       expo: 54.0.33(@babel/core@7.29.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.23)(graphql@16.12.0)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
       expo-asset: 12.0.12(expo@54.0.33)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4))(react@19.2.4)
@@ -15678,6 +15887,17 @@ snapshots:
       - '@types/react'
       - immer
 
+  '@reduxjs/toolkit@2.11.2(react@19.2.4)':
+    dependencies:
+      '@standard-schema/spec': 1.1.0
+      '@standard-schema/utils': 0.3.0
+      immer: 11.1.4
+      redux: 5.0.1
+      redux-thunk: 3.1.0(redux@5.0.1)
+      reselect: 5.1.1
+    optionalDependencies:
+      react: 19.2.4
+
   '@remote-ui/async-subscription@2.1.18':
     dependencies:
       '@remote-ui/rpc': 1.4.7
@@ -15980,6 +16200,8 @@ snapshots:
 
   '@standard-schema/spec@1.1.0': {}
 
+  '@standard-schema/utils@0.3.0': {}
+
   '@streamdown/code@1.0.2(react@19.2.4)':
     dependencies:
       react: 19.2.4
@@ -16126,6 +16348,16 @@ snapshots:
       '@types/react': 19.2.3
       '@types/react-dom': 19.2.3(@types/react@19.2.3)
 
+  '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.3))(@types/react@19.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
+    dependencies:
+      '@babel/runtime': 7.28.6
+      '@testing-library/dom': 10.4.1
+      react: 19.2.4
+      react-dom: 19.2.4(react@19.2.4)
+    optionalDependencies:
+      '@types/react': 19.2.3
+      '@types/react-dom': 19.2.3(@types/react@19.2.3)
+
   '@tokenizer/inflate@0.4.1':
     dependencies:
       debug: 4.4.3
@@ -16790,6 +17022,14 @@ snapshots:
       '@opentelemetry/api': 1.9.0
       zod: 4.3.5
 
+  ai@6.0.97(zod@4.3.5):
+    dependencies:
+      '@ai-sdk/gateway': 3.0.53(zod@4.3.5)
+      '@ai-sdk/provider': 3.0.8
+      '@ai-sdk/provider-utils': 4.0.15(zod@4.3.5)
+      '@opentelemetry/api': 1.9.0
+      zod: 4.3.5
+
   ajv-formats@2.1.1(ajv@8.17.1):
     optionalDependencies:
       ajv: 8.17.1
@@ -19360,6 +19600,8 @@ snapshots:
 
   immediate@3.0.6: {}
 
+  immer@11.1.4: {}
+
   import-fresh@3.3.1:
     dependencies:
       parent-module: 1.0.1
@@ -20135,6 +20377,13 @@ snapshots:
 
   jose@6.1.3: {}
 
+  jotai@2.18.0(@babel/core@7.29.0)(@babel/template@7.28.6)(@types/react@19.2.3)(react@19.2.4):
+    optionalDependencies:
+      '@babel/core': 7.29.0
+      '@babel/template': 7.28.6
+      '@types/react': 19.2.3
+      react: 19.2.4
+
   joycon@3.1.1: {}
 
   js-tokens@4.0.0: {}
@@ -22205,54 +22454,6 @@ snapshots:
       - supports-color
       - utf-8-validate
 
-  react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.3):
-    dependencies:
-      '@jest/create-cache-key-function': 29.7.0
-      '@react-native/assets-registry': 0.83.1
-      '@react-native/codegen': 0.83.1(@babel/core@7.29.0)
-      '@react-native/community-cli-plugin': 0.83.1
-      '@react-native/gradle-plugin': 0.83.1
-      '@react-native/js-polyfills': 0.83.1
-      '@react-native/normalize-colors': 0.83.1
-      '@react-native/virtualized-lists': 0.83.1(@types/react@19.2.3)(react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.3))(react@19.2.3)
-      abort-controller: 3.0.0
-      anser: 1.4.10
-      ansi-regex: 5.0.1
-      babel-jest: 29.7.0(@babel/core@7.29.0)
-      babel-plugin-syntax-hermes-parser: 0.32.0
-      base64-js: 1.5.1
-      commander: 12.1.0
-      flow-enums-runtime: 0.0.6
-      glob: 7.2.3
-      hermes-compiler: 0.14.0
-      invariant: 2.2.4
-      jest-environment-node: 29.7.0
-      memoize-one: 5.2.1
-      metro-runtime: 0.83.3
-      metro-source-map: 0.83.3
-      nullthrows: 1.1.1
-      pretty-format: 29.7.0
-      promise: 8.3.0
-      react: 19.2.3
-      react-devtools-core: 6.1.5
-      react-refresh: 0.14.2
-      regenerator-runtime: 0.13.11
-      scheduler: 0.27.0
-      semver: 7.7.3
-      stacktrace-parser: 0.1.11
-      whatwg-fetch: 3.6.20
-      ws: 7.5.10
-      yargs: 17.7.2
-    optionalDependencies:
-      '@types/react': 19.2.3
-    transitivePeerDependencies:
-      - '@babel/core'
-      - '@react-native-community/cli'
-      - '@react-native/metro-config'
-      - bufferutil
-      - supports-color
-      - utf-8-validate
-
   react-native@0.83.1(@babel/core@7.29.0)(@types/react@19.2.3)(react@19.2.4):
     dependencies:
       '@jest/create-cache-key-function': 29.7.0
@@ -22300,7 +22501,6 @@ snapshots:
       - bufferutil
       - supports-color
       - utf-8-validate
-    optional: true
 
   react-reconciler@0.29.0(react@18.3.1):
     dependencies:
@@ -22536,6 +22736,12 @@ snapshots:
       unified: 11.0.5
       vfile: 6.0.3
 
+  redux-thunk@3.1.0(redux@5.0.1):
+    dependencies:
+      redux: 5.0.1
+
+  redux@5.0.1: {}
+
   reflect.getprototypeof@1.0.10:
     dependencies:
       call-bind: 1.0.8
@@ -22679,6 +22885,8 @@ snapshots:
 
   requires-port@1.0.0: {}
 
+  reselect@5.1.1: {}
+
   resolve-cwd@3.0.0:
     dependencies:
       resolve-from: 5.0.0
@@ -23703,9 +23911,9 @@ snapshots:
       safe-buffer: 5.2.1
     optional: true
 
-  tunnel-rat@0.1.2(@types/react@19.2.3)(react@19.2.4):
+  tunnel-rat@0.1.2(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4):
     dependencies:
-      zustand: 4.5.7(@types/react@19.2.3)(react@19.2.4)
+      zustand: 4.5.7(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4)
     transitivePeerDependencies:
       - '@types/react'
       - immer
@@ -24456,16 +24664,18 @@ snapshots:
 
   zod@4.3.5: {}
 
-  zustand@4.5.7(@types/react@19.2.3)(react@19.2.4):
+  zustand@4.5.7(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4):
     dependencies:
       use-sync-external-store: 1.6.0(react@19.2.4)
     optionalDependencies:
       '@types/react': 19.2.3
+      immer: 11.1.4
       react: 19.2.4
 
-  zustand@5.0.11(@types/react@19.2.3)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
+  zustand@5.0.11(@types/react@19.2.3)(immer@11.1.4)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)):
     optionalDependencies:
       '@types/react': 19.2.3
+      immer: 11.1.4
       react: 19.2.4
       use-sync-external-store: 1.6.0(react@19.2.4)
 

+ 1 - 0
pnpm-workspace.yaml

@@ -3,3 +3,4 @@ packages:
   - "examples/*"
   - "examples/stripe-app/*"
   - "packages/*"
+  - "tests/*"

+ 22 - 0
skills/json-render-core/SKILL.md

@@ -171,12 +171,33 @@ const schema = defineSchema(builder, {
 
 These appear in prompts as `[built-in]` and don't require handlers in `defineRegistry`.
 
+## StateStore
+
+The `StateStore` interface allows external state management libraries (Redux, Zustand, XState, etc.) to be plugged into json-render renderers. The `createStateStore` factory creates a simple in-memory implementation:
+
+```typescript
+import { createStateStore, type StateStore } from "@json-render/core";
+
+const store = createStateStore({ count: 0 });
+
+store.get("/count");         // 0
+store.set("/count", 1);      // updates and notifies subscribers
+store.update({ "/a": 1, "/b": 2 }); // batch update
+
+store.subscribe(() => {
+  console.log(store.getSnapshot()); // { count: 1 }
+});
+```
+
+The `StateStore` interface: `get(path)`, `set(path, value)`, `update(updates)`, `getSnapshot()`, `subscribe(listener)`.
+
 ## Key Exports
 
 | Export | Purpose |
 |--------|---------|
 | `defineSchema` | Create a new schema |
 | `defineCatalog` | Create a catalog from schema |
+| `createStateStore` | Create a framework-agnostic in-memory `StateStore` |
 | `resolvePropValue` | Resolve a single prop expression against data |
 | `resolveElementProps` | Resolve all prop expressions in an element |
 | `buildUserPrompt` | Build user prompts with refinement and state context |
@@ -186,5 +207,6 @@ These appear in prompts as `[built-in]` and don't require handlers in `defineReg
 | `createJsonRenderTransform` | TransformStream separating text from JSONL in mixed streams |
 | `parseSpecStreamLine` | Parse single JSONL line |
 | `applySpecStreamPatch` | Apply patch to object |
+| `StateStore` | Interface for plugging in external state management |
 | `BuiltInAction` | Type for built-in action definitions (`name` + `description`) |
 | `ActionBinding` | Action binding type (includes `preventDefault` field) |

+ 19 - 1
skills/json-render-react-native/SKILL.md

@@ -143,11 +143,27 @@ The `setState` action is handled automatically by `ActionProvider` and updates t
 
 | Provider | Purpose |
 |----------|---------|
-| `StateProvider` | Share state across components (JSON Pointer paths) |
+| `StateProvider` | Share state across components (JSON Pointer paths). Accepts optional `store` prop for controlled mode. |
 | `ActionProvider` | Handle actions dispatched from components |
 | `VisibilityProvider` | Enable conditional rendering based on state |
 | `ValidationProvider` | Form field validation |
 
+### External Store (Controlled Mode)
+
+Pass a `StateStore` to `StateProvider` (or `JSONUIProvider` / `createRenderer`) to use external state management:
+
+```tsx
+import { createStateStore, type StateStore } from "@json-render/react-native";
+
+const store = createStateStore({ count: 0 });
+
+<StateProvider store={store}>{children}</StateProvider>
+
+store.set("/count", 1); // React re-renders automatically
+```
+
+When `store` is provided, `initialState` and `onStateChange` are ignored.
+
 ## Key Exports
 
 | Export | Purpose |
@@ -166,3 +182,5 @@ The `setState` action is handled automatically by `ActionProvider` and updates t
 | `useActions` | Access actions context |
 | `useAction` | Get a single action dispatch function |
 | `useUIStream` | Stream specs from an API endpoint |
+| `createStateStore` | Create a framework-agnostic in-memory `StateStore` |
+| `StateStore` | Interface for plugging in external state management |

+ 20 - 1
skills/json-render-react/SKILL.md

@@ -88,11 +88,28 @@ Use `visible` on elements to show/hide based on state. New syntax: `{ "$state":
 
 | Provider | Purpose |
 |----------|---------|
-| `StateProvider` | Share state across components (JSON Pointer paths) |
+| `StateProvider` | Share state across components (JSON Pointer paths). Accepts optional `store` prop for controlled mode. |
 | `ActionProvider` | Handle actions dispatched via the event system |
 | `VisibilityProvider` | Enable conditional rendering based on state |
 | `ValidationProvider` | Form field validation |
 
+### External Store (Controlled Mode)
+
+Pass a `StateStore` to `StateProvider` (or `JSONUIProvider` / `createRenderer`) to use external state management (Redux, Zustand, XState, etc.):
+
+```tsx
+import { createStateStore, type StateStore } from "@json-render/react";
+
+const store = createStateStore({ count: 0 });
+
+<StateProvider store={store}>{children}</StateProvider>
+
+// Mutate from anywhere — React re-renders automatically:
+store.set("/count", 1);
+```
+
+When `store` is provided, `initialState` and `onStateChange` are ignored.
+
 ## Dynamic Prop Expressions
 
 Any prop value can be a data-driven expression resolved by the renderer before components receive props:
@@ -212,6 +229,8 @@ const Card = ({ props, children }: BaseComponentProps<{ title?: string }>) => (
 | `useActions` | Access actions context |
 | `useAction` | Get a single action dispatch function |
 | `useUIStream` | Stream specs from an API endpoint |
+| `createStateStore` | Create a framework-agnostic in-memory `StateStore` |
+| `StateStore` | Interface for plugging in external state management |
 | `BaseComponentProps` | Catalog-agnostic base type for reusable component libraries |
 | `EventHandle` | Event handle type (`emit`, `shouldPreventDefault`, `bound`) |
 | `ComponentContext` | Typed component context (catalog-aware) |

+ 22 - 0
tests/e2e/package.json

@@ -0,0 +1,22 @@
+{
+  "name": "e2e-tests",
+  "private": true,
+  "scripts": {
+    "test": "vitest run --config vitest.config.ts"
+  },
+  "devDependencies": {
+    "@ai-sdk/gateway": "^3.0.53",
+    "@json-render/core": "workspace:*",
+    "@json-render/jotai": "workspace:*",
+    "@json-render/react": "workspace:*",
+    "@json-render/redux": "workspace:*",
+    "@json-render/zustand": "workspace:*",
+    "@reduxjs/toolkit": "^2.11.2",
+    "ai": "^6.0.97",
+    "jotai": "^2.18.0",
+    "redux": "^5.0.1",
+    "vitest": "^4.0.17",
+    "zod": "^4.3.5",
+    "zustand": "^5.0.11"
+  }
+}

+ 221 - 0
tests/e2e/state-store-e2e.test.ts

@@ -0,0 +1,221 @@
+import { describe, it, expect } from "vitest";
+import { streamText } from "ai";
+import { z } from "zod";
+import {
+  defineCatalog,
+  buildUserPrompt,
+  createStateStore,
+  createSpecStreamCompiler,
+  type Spec,
+  type StateStore,
+} from "@json-render/core";
+import { schema } from "@json-render/react/schema";
+import { reduxStateStore } from "@json-render/redux";
+import { zustandStateStore } from "@json-render/zustand";
+import { jotaiStateStore } from "@json-render/jotai";
+import { configureStore, createSlice } from "@reduxjs/toolkit";
+import { createStore as createZustandStore } from "zustand/vanilla";
+import { atom } from "jotai";
+import { createStore as createJotaiStore } from "jotai/vanilla";
+
+const HAS_API_KEY = !!process.env.AI_GATEWAY_API_KEY;
+
+const catalog = defineCatalog(schema, {
+  components: {
+    Text: {
+      props: z.object({ content: z.string() }),
+      description: "Text content",
+    },
+    Input: {
+      props: z.object({
+        label: z.string().nullable(),
+        value: z.string().nullable(),
+        placeholder: z.string().nullable(),
+      }),
+      description:
+        "Text input. Use value with $bindState for two-way state binding.",
+      example: {
+        label: "Name",
+        value: { $bindState: "/form/name" },
+        placeholder: "Enter name",
+      },
+    },
+    Button: {
+      props: z.object({
+        label: z.string(),
+        action: z.string().nullable(),
+      }),
+      description: "Clickable button",
+    },
+    Stack: {
+      props: z.object({
+        direction: z.enum(["horizontal", "vertical"]).nullable(),
+      }),
+      slots: ["default"],
+      description: "Layout container",
+    },
+  },
+  actions: {},
+});
+
+async function generateSpec(): Promise<Spec> {
+  const prompt = buildUserPrompt({
+    prompt:
+      "Create a simple form with two text inputs bound to state: one for /form/name and one for /form/email. Include initial state with name set to 'Alice' and email set to 'alice@example.com'. Use a vertical Stack as the container.",
+  });
+
+  const result = streamText({
+    model: "anthropic/claude-haiku-4.5",
+    system: catalog.prompt(),
+    prompt,
+    temperature: 0,
+  });
+
+  const compiler = createSpecStreamCompiler<Spec>();
+
+  for await (const chunk of result.textStream) {
+    compiler.push(chunk);
+  }
+
+  return compiler.getResult();
+}
+
+function extractStatePaths(spec: Spec): string[] {
+  const paths: string[] = [];
+
+  function walk(obj: unknown) {
+    if (obj === null || obj === undefined) return;
+    if (typeof obj !== "object") return;
+    if (Array.isArray(obj)) {
+      for (const item of obj) walk(item);
+      return;
+    }
+    const record = obj as Record<string, unknown>;
+    if ("$bindState" in record && typeof record.$bindState === "string") {
+      paths.push(record.$bindState);
+    }
+    if ("$state" in record && typeof record.$state === "string") {
+      paths.push(record.$state);
+    }
+    for (const value of Object.values(record)) {
+      walk(value);
+    }
+  }
+
+  if (spec.elements) walk(spec.elements);
+  return [...new Set(paths)];
+}
+
+function flattenState(
+  obj: Record<string, unknown>,
+  prefix = "",
+): Record<string, unknown> {
+  const result: Record<string, unknown> = {};
+  for (const [key, value] of Object.entries(obj)) {
+    const pointer = `${prefix}/${key}`;
+    if (
+      value !== null &&
+      typeof value === "object" &&
+      !Array.isArray(value) &&
+      Object.getPrototypeOf(value) === Object.prototype
+    ) {
+      Object.assign(
+        result,
+        flattenState(value as Record<string, unknown>, pointer),
+      );
+    } else {
+      result[pointer] = value;
+    }
+  }
+  return result;
+}
+
+function verifyStoreRoundTrip(store: StateStore, spec: Spec) {
+  if (spec.state) {
+    const flat = flattenState(spec.state);
+    store.update(flat);
+  }
+
+  const paths = extractStatePaths(spec);
+  expect(paths.length).toBeGreaterThan(0);
+
+  for (const path of paths) {
+    const original = store.get(path);
+    const testValue = `test-${Date.now()}`;
+
+    store.set(path, testValue);
+    expect(store.get(path)).toBe(testValue);
+
+    store.set(path, original);
+    expect(store.get(path)).toBe(original);
+  }
+
+  const batchUpdate: Record<string, unknown> = {};
+  for (const path of paths) {
+    batchUpdate[path] = `batch-${Date.now()}`;
+  }
+  store.update(batchUpdate);
+  for (const path of paths) {
+    expect(store.get(path)).toBe(batchUpdate[path]);
+  }
+}
+
+describe.skipIf(!HAS_API_KEY)(
+  "e2e: Claude Haiku 4.5 + StateStore adapters",
+  () => {
+    let spec: Spec;
+
+    it("generates a valid spec with state bindings from Claude Haiku 4.5", async () => {
+      spec = await generateSpec();
+
+      expect(spec.root).toBeTruthy();
+      expect(spec.elements).toBeTruthy();
+      expect(Object.keys(spec.elements).length).toBeGreaterThan(0);
+
+      const paths = extractStatePaths(spec);
+      expect(paths.length).toBeGreaterThan(0);
+    });
+
+    it("works with createStateStore (built-in)", () => {
+      const store = createStateStore(spec.state ?? {});
+      verifyStoreRoundTrip(store, spec);
+    });
+
+    it("works with reduxStateStore (Redux Toolkit)", () => {
+      const uiSlice = createSlice({
+        name: "ui",
+        initialState: (spec.state ?? {}) as Record<string, unknown>,
+        reducers: {
+          replace: (_state, action) => action.payload,
+        },
+      });
+
+      const reduxStore = configureStore({
+        reducer: { ui: uiSlice.reducer },
+      });
+
+      const store = reduxStateStore({
+        store: reduxStore,
+        selector: (state) => state.ui as Record<string, unknown>,
+        dispatch: (next, s) => s.dispatch(uiSlice.actions.replace(next)),
+      });
+
+      verifyStoreRoundTrip(store, spec);
+    });
+
+    it("works with zustandStateStore (Zustand)", () => {
+      const zStore = createZustandStore<Record<string, unknown>>()(
+        () => spec.state ?? {},
+      );
+      const store = zustandStateStore({ store: zStore });
+      verifyStoreRoundTrip(store, spec);
+    });
+
+    it("works with jotaiStateStore (Jotai)", () => {
+      const stateAtom = atom<Record<string, unknown>>(spec.state ?? {});
+      const jStore = createJotaiStore();
+      const store = jotaiStateStore({ atom: stateAtom, store: jStore });
+      verifyStoreRoundTrip(store, spec);
+    });
+  },
+);

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است