Ver Fonte

add dynamic forms support: computed values, watchers, cross-field validation (#156)

* add dynamic forms support: computed values, watchers, cross-field validation

- `$computed` and `$template` prop expressions for derived values and string interpolation
- Element-level `watch` field for cascading state dependencies
- Cross-field validators (`lessThan`, `greaterThan`, `equalTo`, `requiredIf`) with deep arg resolution
- `validateForm` built-in action for form-level validation

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* fixes

* tests

* fixes

* update turbo

* tests

* fixes

* fixes

* fixes
Chris Tate há 4 meses atrás
pai
commit
ea47b66dfc

+ 119 - 0
apps/web/app/(main)/docs/computed-values/page.mdx

@@ -0,0 +1,119 @@
+import { pageMetadata } from "@/lib/page-metadata"
+export const metadata = pageMetadata("docs/computed-values")
+
+# Computed Values
+
+Derive dynamic prop values using registered functions or string templates.
+
+## `$template` — String Interpolation
+
+Use `{ "$template": "..." }` to embed state values into a string. References use `${/path}` syntax where the path is a JSON Pointer:
+
+```json
+{
+  "type": "Text",
+  "props": {
+    "text": { "$template": "Hello, ${/user/name}! You have ${/inbox/count} messages." }
+  },
+  "children": []
+}
+```
+
+If state is `{ "user": { "name": "Alice" }, "inbox": { "count": 3 } }`, the text renders as "Hello, Alice! You have 3 messages."
+
+Missing paths resolve to an empty string.
+
+## `$computed` — Registered Functions
+
+Use `{ "$computed": "<name>", "args": { ... } }` to call a named function registered in your catalog. Each arg can be a literal value or any prop expression (`$state`, `$item`, `$cond`, etc.):
+
+```json
+{
+  "type": "Text",
+  "props": {
+    "text": {
+      "$computed": "fullName",
+      "args": {
+        "first": { "$state": "/form/firstName" },
+        "last": { "$state": "/form/lastName" }
+      }
+    }
+  },
+  "children": []
+}
+```
+
+### Registering Functions
+
+Functions are registered in the catalog and provided at runtime.
+
+**Catalog definition (for AI prompt generation):**
+
+```typescript
+import { defineCatalog } from '@json-render/core';
+import { schema } from '@json-render/react/schema';
+
+const catalog = defineCatalog(schema, {
+  components: { /* ... */ },
+  functions: {
+    fullName: {
+      description: 'Combines first and last name into a full name',
+    },
+    formatCurrency: {
+      description: 'Formats a number as currency',
+    },
+  },
+});
+```
+
+**Runtime implementation:**
+
+```tsx
+import { JSONUIProvider } from '@json-render/react';
+
+const functions = {
+  fullName: (args) => `${args.first ?? ''} ${args.last ?? ''}`.trim(),
+  formatCurrency: (args) => {
+    const value = Number(args.value ?? 0);
+    return new Intl.NumberFormat('en-US', {
+      style: 'currency',
+      currency: (args.currency as string) ?? 'USD',
+    }).format(value);
+  },
+};
+
+<JSONUIProvider registry={registry} functions={functions}>
+  <Renderer spec={spec} registry={registry} />
+</JSONUIProvider>
+```
+
+### Using with `createRenderer`
+
+```tsx
+const MyRenderer = createRenderer(catalog, components);
+
+<MyRenderer
+  spec={spec}
+  functions={functions}
+/>
+```
+
+## Combining Expressions
+
+`$computed` args can use any expression type. This example computes a total from repeat item fields:
+
+```json
+{
+  "$computed": "lineTotal",
+  "args": {
+    "price": { "$item": "price" },
+    "quantity": { "$item": "quantity" }
+  }
+}
+```
+
+## Next
+
+- [Watchers](/docs/watchers) — react to state changes with cascading actions
+- [Data Binding](/docs/data-binding) — all expression types
+- [Validation](/docs/validation) — validate form inputs

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

@@ -213,6 +213,22 @@ Use `$cond` / `$then` / `$else` to pick a prop value based on a condition:
 
 The condition uses the same [visibility](/docs/visibility) expression format.
 
+## Template Strings
+
+Use `{ "$template": "..." }` to interpolate state values into a string using `${/path}` syntax:
+
+```json
+{
+  "type": "Text",
+  "props": {
+    "text": { "$template": "Welcome back, ${/user/name}!" }
+  },
+  "children": []
+}
+```
+
+See [Computed Values](/docs/computed-values) for details on `$template` and `$computed` expressions.
+
 ## Quick Reference
 
 <div className="my-6 overflow-x-auto">
@@ -255,6 +271,16 @@ The condition uses the same [visibility](/docs/visibility) expression format.
         <td><code>{'{ "$bindItem": "field" }'}</code></td>
         <td>Form components inside repeat</td>
       </tr>
+      <tr>
+        <td><code>{"$template"}</code></td>
+        <td><code>{'{ "$template": "Hello, ${/name}!" }'}</code></td>
+        <td>Anywhere (string props)</td>
+      </tr>
+      <tr>
+        <td><code>{"$computed"}</code></td>
+        <td><code>{'{ "$computed": "fn", "args": { ... } }'}</code></td>
+        <td>Anywhere (requires registered function)</td>
+      </tr>
     </tbody>
   </table>
 </div>

+ 114 - 7
apps/web/app/(main)/docs/validation/page.mdx

@@ -11,11 +11,18 @@ json-render includes common validation functions:
 
 - `required` — Value must be non-empty
 - `email` — Valid email format
-- `minLength` — Minimum string length
-- `maxLength` — Maximum string length
-- `pattern` — Match a regex pattern
-- `min` — Minimum numeric value
-- `max` — Maximum numeric value
+- `minLength` — Minimum string length (args: `{ "min": N }`)
+- `maxLength` — Maximum string length (args: `{ "max": N }`)
+- `pattern` — Match a regex pattern (args: `{ "pattern": "regex" }`)
+- `min` — Minimum numeric value (args: `{ "min": N }`)
+- `max` — Maximum numeric value (args: `{ "max": N }`)
+- `numeric` — Value must be a number
+- `url` — Valid URL format
+- `matches` — Must equal another field (args: `{ "other": { "$state": "/path" } }`)
+- `equalTo` — Alias for matches (args: `{ "other": { "$state": "/path" } }`)
+- `lessThan` — Value must be less than another field (args: `{ "other": { "$state": "/path" } }`)
+- `greaterThan` — Value must be greater than another field (args: `{ "other": { "$state": "/path" } }`)
+- `requiredIf` — Required only when another field is truthy (args: `{ "field": { "$state": "/path" } }`)
 
 ## Using Validation in JSON
 
@@ -143,14 +150,114 @@ function TextField({ props, bindings }) {
 
 See the [@json-render/react API reference](/docs/api/react) for full `ValidationProvider` and `useFieldValidation` documentation.
 
+## Cross-Field Validation
+
+Validation args support `{ "$state": "/path" }` references to compare against other fields. This enables cross-field rules like "confirm password must match password":
+
+```json
+{
+  "type": "Input",
+  "props": {
+    "label": "Confirm Password",
+    "value": { "$bindState": "/form/confirmPassword" },
+    "checks": [
+      { "type": "required", "message": "Please confirm your password" },
+      {
+        "type": "matches",
+        "args": { "other": { "$state": "/form/password" } },
+        "message": "Passwords must match"
+      }
+    ]
+  }
+}
+```
+
+Other cross-field examples:
+
+```json
+{
+  "checks": [
+    {
+      "type": "greaterThan",
+      "args": { "other": { "$state": "/form/startDate" } },
+      "message": "End date must be after start date"
+    }
+  ]
+}
+```
+
+```json
+{
+  "checks": [
+    {
+      "type": "requiredIf",
+      "args": { "field": { "$state": "/form/enableNotifications" } },
+      "message": "Email is required when notifications are enabled"
+    }
+  ]
+}
+```
+
+## Conditional Validation
+
+Use the `enabled` field in the validation config to only run checks when a condition is met:
+
+```json
+{
+  "type": "Input",
+  "props": {
+    "label": "Company Name",
+    "value": { "$bindState": "/form/company" },
+    "checks": [
+      { "type": "required", "message": "Company name is required" }
+    ]
+  }
+}
+```
+
+In the component implementation, you can pass `enabled` to `useFieldValidation`:
+
+```typescript
+useFieldValidation(bindings?.value ?? "", {
+  checks: props.checks ?? [],
+  enabled: { "$state": "/form/accountType", eq: "business" },
+});
+```
+
+This only validates the company name when the account type is "business".
+
 ## Validation Timing
 
 Control when validation runs with `validateOn`:
 
 - `change` — Validate on every input change
-- `blur` — Validate when field loses focus
+- `blur` — Validate when field loses focus (default for Input, Textarea)
 - `submit` — Validate only on form submission
 
+## Form-Level Validation
+
+Use the built-in `validateForm` action to validate all registered fields at once. This is useful for a "Submit" button that should validate the entire form before proceeding:
+
+```json
+{
+  "type": "Button",
+  "props": { "label": "Submit" },
+  "on": {
+    "press": [
+      { "action": "validateForm", "params": { "statePath": "/formResult" } },
+      { "action": "submitForm" }
+    ]
+  },
+  "children": []
+}
+```
+
+The `validateForm` action runs `validateAll()` and writes `{ valid: boolean }` to the specified state path (defaults to `/formValidation`). Your submit handler can then check `{ "$state": "/formResult/valid" }` to decide whether to proceed.
+
+> **Note:** Actions in a list execute sequentially, but `submitForm` does not automatically gate on validation. Guard submission with a `$cond` visibility condition on the button or check `{ "$state": "/formResult/valid" }` inside your action handler to skip submission when the form is invalid.
+
 ## Next
 
-Learn about [generation modes](/docs/generation-modes).
+- [Computed Values](/docs/computed-values) — derive dynamic prop values
+- [Watchers](/docs/watchers) — react to state changes
+- [Generation Modes](/docs/generation-modes) — how AI generates specs

+ 150 - 0
apps/web/app/(main)/docs/watchers/page.mdx

@@ -0,0 +1,150 @@
+import { pageMetadata } from "@/lib/page-metadata"
+export const metadata = pageMetadata("docs/watchers")
+
+# Watchers
+
+React to state changes by triggering actions when watched paths update.
+
+## The `watch` Field
+
+Elements can have an optional `watch` field that maps state paths to action bindings. When the value at a watched path changes, the bound actions fire automatically.
+
+`watch` is a **top-level field** on the element (sibling of `type`, `props`, `children`) — not inside `props`.
+
+```json
+{
+  "type": "Select",
+  "props": {
+    "label": "Country",
+    "value": { "$bindState": "/form/country" },
+    "options": ["US", "Canada", "UK"]
+  },
+  "watch": {
+    "/form/country": {
+      "action": "loadCities",
+      "params": { "country": { "$state": "/form/country" } }
+    }
+  },
+  "children": []
+}
+```
+
+When the user selects a different country, the `loadCities` action fires with the new country value. The action handler can fetch city data and update state, causing a dependent city Select to re-render with new options.
+
+## Cascading Selects
+
+A common pattern is cascading dropdowns where selecting a value in one field loads options for another:
+
+```json
+{
+  "root": "form",
+  "elements": {
+    "form": {
+      "type": "Stack",
+      "props": { "direction": "vertical", "gap": "md" },
+      "children": ["country-select", "city-select"]
+    },
+    "country-select": {
+      "type": "Select",
+      "props": {
+        "label": "Country",
+        "value": { "$bindState": "/form/country" },
+        "options": ["US", "Canada", "UK"]
+      },
+      "watch": {
+        "/form/country": [
+          { "action": "loadCities", "params": { "country": { "$state": "/form/country" } } },
+          { "action": "setState", "params": { "statePath": "/form/city", "value": "" } }
+        ]
+      },
+      "children": []
+    },
+    "city-select": {
+      "type": "Select",
+      "props": {
+        "label": "City",
+        "value": { "$bindState": "/form/city" },
+        "options": { "$state": "/availableCities" },
+        "placeholder": "Select a city"
+      },
+      "children": []
+    }
+  },
+  "state": {
+    "form": { "country": "", "city": "" },
+    "availableCities": []
+  }
+}
+```
+
+The watcher on `country-select` fires two actions when the country changes:
+1. `loadCities` — fetches and writes city options to `/availableCities`
+2. `setState` — resets the city selection
+
+The city Select reads its options from `{ "$state": "/availableCities" }`, so it automatically updates when the data is loaded.
+
+### Action Handler
+
+```typescript
+const handlers = {
+  loadCities: async (params) => {
+    const cities = await fetchCities(params.country);
+    // setState is called by the runtime to write the result
+    return cities;
+  },
+};
+```
+
+Or with `defineRegistry`:
+
+```typescript
+const { registry, handlers } = defineRegistry(catalog, {
+  components: { /* ... */ },
+  actions: {
+    loadCities: async (params, setState) => {
+      const response = await fetch(`/api/cities?country=${params.country}`);
+      const cities = await response.json();
+      setState('/availableCities', cities);
+    },
+  },
+});
+```
+
+## Multiple Watchers
+
+An element can watch multiple state paths. Each path maps to one or more action bindings:
+
+```json
+{
+  "watch": {
+    "/form/startDate": { "action": "validateDateRange" },
+    "/form/endDate": { "action": "validateDateRange" },
+    "/form/quantity": [
+      { "action": "recalculateTotal" },
+      { "action": "checkInventory", "params": { "qty": { "$state": "/form/quantity" } } }
+    ]
+  }
+}
+```
+
+## Behavior
+
+- Watchers only fire on **value changes**, not on the initial render
+- Comparison is by reference (`===`), not deep equality
+- Action params support the same expressions as event bindings (`$state`, `$item`, `$index`)
+- Multiple action bindings on the same path execute sequentially
+
+## When to Use `watch` vs `on`
+
+| Mechanism | Trigger | Use Case |
+|-----------|---------|----------|
+| `on` | User interaction (press, change, blur) | Button clicks, input changes, form submissions |
+| `watch` | State value change (any source) | Cascading data, derived state, cross-field sync |
+
+Use `on` when reacting to direct user actions. Use `watch` when a state change (from any source — user input, action handler, or external store update) should trigger side effects.
+
+## Next
+
+- [Data Binding](/docs/data-binding) — connect elements to state
+- [Computed Values](/docs/computed-values) — derive prop values
+- [Visibility](/docs/visibility) — conditionally show or hide elements

+ 2 - 0
apps/web/lib/docs-navigation.ts

@@ -27,7 +27,9 @@ export const docsNavigation: NavSection[] = [
       { title: "Schemas", href: "/docs/schemas" },
       { title: "Catalog", href: "/docs/catalog" },
       { title: "Data Binding", href: "/docs/data-binding" },
+      { title: "Computed Values", href: "/docs/computed-values" },
       { title: "Visibility", href: "/docs/visibility" },
+      { title: "Watchers", href: "/docs/watchers" },
       { title: "Validation", href: "/docs/validation" },
     ],
   },

+ 59 - 41
examples/no-ai/app/page.tsx

@@ -2,33 +2,31 @@
 
 import { useState, useEffect, useCallback, type ReactNode } from "react";
 import ConfettiExplosion from "react-confetti-explosion";
-import {
-  Renderer,
-  StateProvider,
-  VisibilityProvider,
-  ActionProvider,
-  ValidationProvider,
-} from "@json-render/react";
+import { JSONUIProvider, Renderer } from "@json-render/react";
 import type { Spec } from "@json-render/core";
-import { registry, actionHandlers, onConfetti } from "@/lib/render/registry";
+import {
+  registry,
+  actionHandlers,
+  computedFunctions,
+  onConfetti,
+} from "@/lib/render/registry";
 import { examples } from "@/lib/examples";
 
 function SpecRenderer({ spec }: { spec: Spec }): ReactNode {
   return (
-    <StateProvider initialState={spec.state ?? {}}>
-      <VisibilityProvider>
-        <ActionProvider handlers={actionHandlers}>
-          <ValidationProvider>
-            <Renderer spec={spec} registry={registry} />
-          </ValidationProvider>
-        </ActionProvider>
-      </VisibilityProvider>
-    </StateProvider>
+    <JSONUIProvider
+      registry={registry}
+      initialState={spec.state ?? {}}
+      handlers={actionHandlers}
+      functions={computedFunctions}
+    >
+      <Renderer spec={spec} registry={registry} />
+    </JSONUIProvider>
   );
 }
 
 export default function Page() {
-  const [selectedIndex] = useState(0);
+  const [selectedIndex, setSelectedIndex] = useState(0);
   const selected = examples[selectedIndex]!;
   const [confettiKey, setConfettiKey] = useState(0);
   const [confettiActive, setConfettiActive] = useState(false);
@@ -41,30 +39,50 @@ export default function Page() {
   useEffect(() => onConfetti(fireConfetti), [fireConfetti]);
 
   return (
-    <div className="h-screen flex items-center justify-center bg-muted/30">
-      <div
-        className="relative bg-background border rounded-lg shadow-sm"
-        style={{ width: 960, height: 1080 }}
-      >
-        {confettiActive && (
-          <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
-            <ConfettiExplosion
-              key={confettiKey}
-              portal={false}
-              force={0.8}
-              duration={3500}
-              particleCount={400}
-              particleSize={8}
-              colors={["#00F0FF", "#7B61FF", "#FF3DFF", "#00FF94", "#FFE14D"]}
-              width={1600}
-              height="200vh"
-              zIndex={1}
-              onComplete={() => setConfettiActive(false)}
-            />
+    <div className="h-screen flex flex-col bg-muted/30">
+      {/* Example selector */}
+      <nav className="flex gap-1 p-3 overflow-x-auto border-b bg-background shrink-0">
+        {examples.map((ex, i) => (
+          <button
+            key={ex.name}
+            onClick={() => setSelectedIndex(i)}
+            className={`px-3 py-1.5 text-sm rounded-md whitespace-nowrap transition-colors ${
+              i === selectedIndex
+                ? "bg-primary text-primary-foreground"
+                : "hover:bg-muted"
+            }`}
+          >
+            {ex.name}
+          </button>
+        ))}
+      </nav>
+
+      {/* Render area */}
+      <div className="flex-1 flex items-start justify-center overflow-auto p-6">
+        <div className="relative bg-background border rounded-lg shadow-sm w-full max-w-[960px]">
+          {confettiActive && (
+            <div className="absolute inset-0 flex items-center justify-center pointer-events-none">
+              <ConfettiExplosion
+                key={confettiKey}
+                portal={false}
+                force={0.8}
+                duration={3500}
+                particleCount={400}
+                particleSize={8}
+                colors={["#00F0FF", "#7B61FF", "#FF3DFF", "#00FF94", "#FFE14D"]}
+                width={1600}
+                height="200vh"
+                zIndex={1}
+                onComplete={() => setConfettiActive(false)}
+              />
+            </div>
+          )}
+          <div className="p-6 relative z-10">
+            <p className="text-xs text-muted-foreground mb-4">
+              {selected.description}
+            </p>
+            <SpecRenderer key={selectedIndex} spec={selected.spec} />
           </div>
-        )}
-        <div className="h-full overflow-auto p-6 flex items-center justify-center relative z-10">
-          <SpecRenderer key={selectedIndex} spec={selected.spec} />
         </div>
       </div>
     </div>

+ 349 - 0
examples/no-ai/lib/examples.ts

@@ -508,4 +508,353 @@ export const examples: Example[] = [
       },
     },
   },
+
+  // =========================================================================
+  // Advanced: Registration form with cross-field validation & $template
+  // =========================================================================
+  {
+    name: "Registration Form",
+    description:
+      "Cross-field validation, $template preview, and validateForm action",
+    spec: {
+      root: "card",
+      state: {
+        form: {
+          name: "",
+          email: "",
+          password: "",
+          confirmPassword: "",
+          accountType: "personal",
+          company: "",
+        },
+        result: null,
+      },
+      elements: {
+        card: {
+          type: "Card",
+          props: {
+            title: "Create Account",
+            description: "Fill out the form below to register",
+            maxWidth: "md",
+            centered: null,
+          },
+          children: ["formStack"],
+        },
+        formStack: {
+          type: "Stack",
+          props: {
+            direction: "vertical",
+            gap: "md",
+            align: null,
+            justify: null,
+          },
+          children: [
+            "preview",
+            "sep0",
+            "nameInput",
+            "emailInput",
+            "passwordInput",
+            "confirmInput",
+            "sep1",
+            "accountTypeRadio",
+            "companyInput",
+            "sep2",
+            "actions",
+            "statusText",
+          ],
+        },
+
+        // $template live preview
+        preview: {
+          type: "Text",
+          props: {
+            text: {
+              $template: "Welcome, ${/form/name}! Your email: ${/form/email}",
+            },
+            variant: "muted",
+          },
+          visible: { $state: "/form/name", neq: "" },
+          children: [],
+        },
+        sep0: { type: "Separator", props: { orientation: null }, children: [] },
+
+        nameInput: {
+          type: "Input",
+          props: {
+            label: "Full Name",
+            name: "name",
+            type: "text",
+            placeholder: "Jane Doe",
+            value: { $bindState: "/form/name" },
+            checks: [
+              { type: "required", message: "Name is required" },
+              {
+                type: "minLength",
+                args: { min: 2 },
+                message: "Name must be at least 2 characters",
+              },
+            ],
+            validateOn: "blur",
+          },
+          children: [],
+        },
+        emailInput: {
+          type: "Input",
+          props: {
+            label: "Email",
+            name: "email",
+            type: "email",
+            placeholder: "jane@example.com",
+            value: { $bindState: "/form/email" },
+            checks: [
+              { type: "required", message: "Email is required" },
+              { type: "email", message: "Enter a valid email address" },
+            ],
+            validateOn: "blur",
+          },
+          children: [],
+        },
+        passwordInput: {
+          type: "Input",
+          props: {
+            label: "Password",
+            name: "password",
+            type: "password",
+            placeholder: "At least 8 characters",
+            value: { $bindState: "/form/password" },
+            checks: [
+              { type: "required", message: "Password is required" },
+              {
+                type: "minLength",
+                args: { min: 8 },
+                message: "Password must be at least 8 characters",
+              },
+            ],
+            validateOn: "blur",
+          },
+          children: [],
+        },
+        confirmInput: {
+          type: "Input",
+          props: {
+            label: "Confirm Password",
+            name: "confirmPassword",
+            type: "password",
+            placeholder: "Re-enter your password",
+            value: { $bindState: "/form/confirmPassword" },
+            checks: [
+              { type: "required", message: "Please confirm your password" },
+              {
+                type: "matches",
+                args: { other: { $state: "/form/password" } },
+                message: "Passwords must match",
+              },
+            ],
+            validateOn: "blur",
+          },
+          children: [],
+        },
+        sep1: { type: "Separator", props: { orientation: null }, children: [] },
+
+        accountTypeRadio: {
+          type: "Radio",
+          props: {
+            label: "Account Type",
+            name: "accountType",
+            options: ["personal", "business"],
+            value: { $bindState: "/form/accountType" },
+            checks: null,
+            validateOn: null,
+          },
+          children: [],
+        },
+        companyInput: {
+          type: "Input",
+          props: {
+            label: "Company Name",
+            name: "company",
+            type: "text",
+            placeholder: "Acme Inc.",
+            value: { $bindState: "/form/company" },
+            checks: [
+              {
+                type: "requiredIf",
+                args: { field: { $state: "/form/accountType" } },
+                message: "Company name is required for business accounts",
+              },
+            ],
+            validateOn: "blur",
+          },
+          visible: { $state: "/form/accountType", eq: "business" },
+          children: [],
+        },
+        sep2: { type: "Separator", props: { orientation: null }, children: [] },
+
+        actions: {
+          type: "Stack",
+          props: {
+            direction: "horizontal",
+            gap: "sm",
+            align: null,
+            justify: "end",
+          },
+          children: ["submitBtn"],
+        },
+        submitBtn: {
+          type: "Button",
+          props: { label: "Register", variant: "primary", disabled: null },
+          on: {
+            press: [
+              {
+                action: "validateForm",
+                params: { statePath: "/result" },
+              },
+            ],
+          },
+          children: [],
+        },
+
+        // Validation result
+        statusText: {
+          type: "Alert",
+          props: {
+            title: "Validation Result",
+            message: {
+              $cond: { $state: "/result/valid", eq: true },
+              $then: "All fields are valid -- ready to submit!",
+              $else: "Please fix the errors above before submitting.",
+            },
+            type: {
+              $cond: { $state: "/result/valid", eq: true },
+              $then: "success",
+              $else: "error",
+            },
+          },
+          visible: { $state: "/result", neq: null },
+          children: [],
+        },
+      },
+    },
+  },
+
+  // =========================================================================
+  // Advanced: Cascading selects with watchers & $computed
+  // =========================================================================
+  {
+    name: "Cascading Selects",
+    description:
+      "Watchers reset dependent fields, $computed derives display values",
+    spec: {
+      root: "card",
+      state: {
+        form: { country: "", city: "" },
+        availableCities: [],
+      },
+      elements: {
+        card: {
+          type: "Card",
+          props: {
+            title: "Shipping Address",
+            description: "Select your country to load available cities",
+            maxWidth: "md",
+            centered: null,
+          },
+          children: ["formStack"],
+        },
+        formStack: {
+          type: "Stack",
+          props: {
+            direction: "vertical",
+            gap: "md",
+            align: null,
+            justify: null,
+          },
+          children: [
+            "countrySelect",
+            "citySelect",
+            "sep",
+            "addressPreview",
+            "templatePreview",
+          ],
+        },
+
+        countrySelect: {
+          type: "Select",
+          props: {
+            label: "Country",
+            name: "country",
+            options: ["US", "Canada", "UK", "Germany", "Japan"],
+            placeholder: "Choose a country",
+            value: { $bindState: "/form/country" },
+            checks: [{ type: "required", message: "Country is required" }],
+            validateOn: "change",
+          },
+          watch: {
+            "/form/country": [
+              {
+                action: "setState",
+                params: {
+                  statePath: "/availableCities",
+                  value: {
+                    $computed: "citiesForCountry",
+                    args: { country: { $state: "/form/country" } },
+                  },
+                },
+              },
+              {
+                action: "setState",
+                params: { statePath: "/form/city", value: "" },
+              },
+            ],
+          },
+          children: [],
+        },
+
+        citySelect: {
+          type: "Select",
+          props: {
+            label: "City",
+            name: "city",
+            options: { $state: "/availableCities" },
+            placeholder: "Select a city",
+            value: { $bindState: "/form/city" },
+            checks: [{ type: "required", message: "City is required" }],
+            validateOn: "change",
+          },
+          children: [],
+        },
+        sep: { type: "Separator", props: { orientation: null }, children: [] },
+
+        // $computed formatted address
+        addressPreview: {
+          type: "Heading",
+          props: {
+            text: {
+              $computed: "formatAddress",
+              args: {
+                city: { $state: "/form/city" },
+                country: { $state: "/form/country" },
+              },
+            },
+            level: "h3",
+          },
+          children: [],
+        },
+
+        // $template string interpolation
+        templatePreview: {
+          type: "Text",
+          props: {
+            text: {
+              $template:
+                "Shipping to: ${/form/city} in ${/form/country}. Cities available: ${/availableCities}",
+            },
+            variant: "muted",
+          },
+          visible: { $state: "/form/country", neq: "" },
+          children: [],
+        },
+      },
+    },
+  },
 ];

+ 9 - 0
examples/no-ai/lib/render/catalog.ts

@@ -13,4 +13,13 @@ export const catalog = defineCatalog(schema, {
       description: "Fire confetti",
     },
   },
+  functions: {
+    formatAddress: {
+      description:
+        "Formats country and city into a single address string like 'City, Country'",
+    },
+    citiesForCountry: {
+      description: "Returns an array of city names for the given country code",
+    },
+  },
 });

+ 27 - 1
examples/no-ai/lib/render/registry.tsx

@@ -1,6 +1,7 @@
 "use client";
 
 import { defineRegistry } from "@json-render/react";
+import type { ComputedFunction } from "@json-render/core";
 import { shadcnComponents } from "@json-render/shadcn";
 import { catalog } from "./catalog";
 
@@ -13,6 +14,14 @@ export function onConfetti(cb: () => void) {
   };
 }
 
+const cityData: Record<string, string[]> = {
+  US: ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix"],
+  Canada: ["Toronto", "Vancouver", "Montreal", "Calgary", "Ottawa"],
+  UK: ["London", "Manchester", "Birmingham", "Edinburgh", "Bristol"],
+  Germany: ["Berlin", "Munich", "Hamburg", "Frankfurt", "Cologne"],
+  Japan: ["Tokyo", "Osaka", "Kyoto", "Yokohama", "Sapporo"],
+};
+
 export const { registry } = defineRegistry(catalog, {
   components: {
     ...shadcnComponents,
@@ -24,6 +33,23 @@ export const { registry } = defineRegistry(catalog, {
   },
 });
 
-export const actionHandlers: Record<string, () => void> = {
+export const actionHandlers: Record<
+  string,
+  (params: Record<string, unknown>) => void
+> = {
   confetti: () => confettiListener?.(),
 };
+
+export const computedFunctions: Record<string, ComputedFunction> = {
+  formatAddress: (args) => {
+    const city = (args.city as string) ?? "";
+    const country = (args.country as string) ?? "";
+    if (!city && !country) return "No location selected";
+    if (!city) return country;
+    return `${city}, ${country}`;
+  },
+  citiesForCountry: (args) => {
+    const country = (args.country as string) ?? "";
+    return cityData[country] ?? [];
+  },
+};

+ 1 - 1
package.json

@@ -12,7 +12,7 @@
   },
   "scripts": {
     "build": "turbo run build",
-    "dev": "turbo run dev --concurrency 15",
+    "dev": "turbo run dev --concurrency 20",
     "lint": "turbo run lint",
     "format": "prettier --write \"**/*.{ts,tsx}\"",
     "type-check": "turbo run check-types",

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

@@ -72,7 +72,11 @@ export {
 } from "./visibility";
 
 // Prop Expressions
-export type { PropExpression, PropResolutionContext } from "./props";
+export type {
+  PropExpression,
+  PropResolutionContext,
+  ComputedFunction,
+} from "./props";
 
 export {
   resolvePropValue,

+ 165 - 1
packages/core/src/props.test.ts

@@ -1,9 +1,11 @@
-import { describe, it, expect } from "vitest";
+import { describe, it, expect, vi } from "vitest";
 import {
   resolvePropValue,
   resolveElementProps,
   resolveBindings,
   resolveActionParam,
+  _resetWarnedComputedFns,
+  _resetWarnedTemplatePaths,
 } from "./props";
 import type { PropResolutionContext } from "./props";
 
@@ -498,3 +500,165 @@ describe("resolveActionParam", () => {
     expect(resolveActionParam(null, ctx)).toBeNull();
   });
 });
+
+// =============================================================================
+// $computed expressions
+// =============================================================================
+
+describe("$computed expressions", () => {
+  it("calls a registered function with resolved args", () => {
+    const ctx: PropResolutionContext = {
+      stateModel: { form: { firstName: "Jane", lastName: "Doe" } },
+      functions: {
+        fullName: (args) => `${args.first} ${args.last}`,
+      },
+    };
+    expect(
+      resolvePropValue(
+        {
+          $computed: "fullName",
+          args: {
+            first: { $state: "/form/firstName" },
+            last: { $state: "/form/lastName" },
+          },
+        },
+        ctx,
+      ),
+    ).toBe("Jane Doe");
+  });
+
+  it("calls function with no args", () => {
+    const ctx: PropResolutionContext = {
+      stateModel: {},
+      functions: {
+        timestamp: () => 1234567890,
+      },
+    };
+    expect(resolvePropValue({ $computed: "timestamp" }, ctx)).toBe(1234567890);
+  });
+
+  it("returns undefined for unknown function", () => {
+    const ctx: PropResolutionContext = {
+      stateModel: {},
+      functions: {},
+    };
+    expect(resolvePropValue({ $computed: "unknown" }, ctx)).toBeUndefined();
+  });
+
+  it("returns undefined when no functions in context", () => {
+    const ctx: PropResolutionContext = { stateModel: {} };
+    expect(resolvePropValue({ $computed: "any" }, ctx)).toBeUndefined();
+  });
+
+  it("deduplicates warnings for the same unknown function", () => {
+    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+    const ctx: PropResolutionContext = { stateModel: {}, functions: {} };
+    resolvePropValue({ $computed: "dedupTest" }, ctx);
+    resolvePropValue({ $computed: "dedupTest" }, ctx);
+    const calls = warnSpy.mock.calls.filter((c) =>
+      String(c[0]).includes("dedupTest"),
+    );
+    expect(calls).toHaveLength(1);
+    warnSpy.mockRestore();
+  });
+
+  it("resolves nested expressions in args", () => {
+    const ctx: PropResolutionContext = {
+      stateModel: { active: true, values: { a: 10, b: 20 } },
+      functions: {
+        conditionalSum: (args) => {
+          if (args.enabled) return (args.x as number) + (args.y as number);
+          return 0;
+        },
+      },
+    };
+    expect(
+      resolvePropValue(
+        {
+          $computed: "conditionalSum",
+          args: {
+            enabled: { $state: "/active" },
+            x: { $state: "/values/a" },
+            y: { $state: "/values/b" },
+          },
+        },
+        ctx,
+      ),
+    ).toBe(30);
+  });
+});
+
+// =============================================================================
+// $template expressions
+// =============================================================================
+
+describe("$template expressions", () => {
+  it("interpolates state values into a string", () => {
+    const ctx: PropResolutionContext = {
+      stateModel: { user: { name: "Alice" }, count: 3 },
+    };
+    expect(
+      resolvePropValue(
+        { $template: "Hello, ${/user/name}! You have ${/count} messages." },
+        ctx,
+      ),
+    ).toBe("Hello, Alice! You have 3 messages.");
+  });
+
+  it("replaces missing paths with empty string", () => {
+    const ctx: PropResolutionContext = { stateModel: {} };
+    expect(resolvePropValue({ $template: "Hi ${/name}!" }, ctx)).toBe("Hi !");
+  });
+
+  it("handles template with no interpolations", () => {
+    const ctx: PropResolutionContext = { stateModel: {} };
+    expect(resolvePropValue({ $template: "No variables here" }, ctx)).toBe(
+      "No variables here",
+    );
+  });
+
+  it("handles multiple references to the same path", () => {
+    const ctx: PropResolutionContext = {
+      stateModel: { x: "A" },
+    };
+    expect(resolvePropValue({ $template: "${/x} and ${/x}" }, ctx)).toBe(
+      "A and A",
+    );
+  });
+
+  it("converts non-string values to strings", () => {
+    const ctx: PropResolutionContext = {
+      stateModel: { num: 42, bool: true },
+    };
+    expect(resolvePropValue({ $template: "${/num} is ${/bool}" }, ctx)).toBe(
+      "42 is true",
+    );
+  });
+
+  it("warns when path does not start with /", () => {
+    _resetWarnedTemplatePaths();
+    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+    const ctx: PropResolutionContext = { stateModel: { name: "Bob" } };
+    const result = resolvePropValue({ $template: "Hi ${name}!" }, ctx);
+    expect(result).toBe("Hi Bob!");
+    expect(warnSpy).toHaveBeenCalledWith(
+      expect.stringContaining('$template path "name"'),
+    );
+    warnSpy.mockRestore();
+    _resetWarnedTemplatePaths();
+  });
+
+  it("deduplicates warnings for the same $template path", () => {
+    _resetWarnedTemplatePaths();
+    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+    const ctx: PropResolutionContext = { stateModel: { name: "Bob" } };
+    resolvePropValue({ $template: "Hi ${name}!" }, ctx);
+    resolvePropValue({ $template: "Hi ${name}!" }, ctx);
+    const calls = warnSpy.mock.calls.filter((c) =>
+      String(c[0]).includes('$template path "name"'),
+    );
+    expect(calls).toHaveLength(1);
+    warnSpy.mockRestore();
+    _resetWarnedTemplatePaths();
+  });
+});

+ 100 - 1
packages/core/src/props.ts

@@ -22,6 +22,10 @@ import { evaluateVisibility, type VisibilityContext } from "./visibility";
  *    repeat item — resolves via `repeatBasePath + path` and exposes the
  *    absolute state path for write-back.
  * - `{ $cond, $then, $else }` conditionally picks a value
+ * - `{ $computed: string, args?: Record<string, PropExpression> }` calls a
+ *    registered function with resolved args and returns the result
+ * - `{ $template: string }` interpolates `${/path}` references in the
+ *    string with values from the state model
  * - Any other value is a literal (passthrough)
  */
 export type PropExpression<T = unknown> =
@@ -35,7 +39,15 @@ export type PropExpression<T = unknown> =
       $cond: VisibilityCondition;
       $then: PropExpression<T>;
       $else: PropExpression<T>;
-    };
+    }
+  | { $computed: string; args?: Record<string, unknown> }
+  | { $template: string };
+
+/**
+ * Function signature for `$computed` expressions.
+ * Receives a record of resolved argument values and returns a computed result.
+ */
+export type ComputedFunction = (args: Record<string, unknown>) => unknown;
 
 /**
  * Context for resolving prop expressions.
@@ -45,6 +57,8 @@ export type PropExpression<T = unknown> =
 export interface PropResolutionContext extends VisibilityContext {
   /** Absolute state path to the current repeat item (e.g. "/todos/0"). Set inside repeat scopes. */
   repeatBasePath?: string;
+  /** Named functions available for `$computed` expressions. */
+  functions?: Record<string, ComputedFunction>;
 }
 
 // =============================================================================
@@ -110,6 +124,47 @@ function isCondExpression(
   );
 }
 
+function isComputedExpression(
+  value: unknown,
+): value is { $computed: string; args?: Record<string, unknown> } {
+  return (
+    typeof value === "object" &&
+    value !== null &&
+    "$computed" in value &&
+    typeof (value as Record<string, unknown>).$computed === "string"
+  );
+}
+
+function isTemplateExpression(value: unknown): value is { $template: string } {
+  return (
+    typeof value === "object" &&
+    value !== null &&
+    "$template" in value &&
+    typeof (value as Record<string, unknown>).$template === "string"
+  );
+}
+
+// Module-level set to avoid spamming console.warn on every render for the same
+// unknown $computed function name. Once the set reaches WARNED_COMPUTED_MAX,
+// new names are no longer deduplicated (warnings still fire) but the set stops
+// growing, preventing unbounded memory use in long-lived processes (e.g. SSR).
+const WARNED_COMPUTED_MAX = 100;
+const warnedComputedFns = new Set<string>();
+
+/** @internal Test-only: clear the deduplication set for $computed warnings. */
+export function _resetWarnedComputedFns(): void {
+  warnedComputedFns.clear();
+}
+
+// Same deduplication pattern for $template paths that don't start with "/".
+const WARNED_TEMPLATE_MAX = 100;
+const warnedTemplatePaths = new Set<string>();
+
+/** @internal Test-only: clear the deduplication set for $template warnings. */
+export function _resetWarnedTemplatePaths(): void {
+  warnedTemplatePaths.clear();
+}
+
 // =============================================================================
 // Prop Expression Resolution
 // =============================================================================
@@ -193,6 +248,50 @@ export function resolvePropValue(
     return resolvePropValue(result ? value.$then : value.$else, ctx);
   }
 
+  // $computed: call a registered function with resolved args
+  if (isComputedExpression(value)) {
+    const fn = ctx.functions?.[value.$computed];
+    if (!fn) {
+      if (!warnedComputedFns.has(value.$computed)) {
+        if (warnedComputedFns.size < WARNED_COMPUTED_MAX) {
+          warnedComputedFns.add(value.$computed);
+        }
+        console.warn(`Unknown $computed function: "${value.$computed}"`);
+      }
+      return undefined;
+    }
+    const resolvedArgs: Record<string, unknown> = {};
+    if (value.args) {
+      for (const [key, arg] of Object.entries(value.args)) {
+        resolvedArgs[key] = resolvePropValue(arg, ctx);
+      }
+    }
+    return fn(resolvedArgs);
+  }
+
+  // $template: interpolate ${/path} references with state values
+  if (isTemplateExpression(value)) {
+    return value.$template.replace(
+      /\$\{([^}]+)\}/g,
+      (_match, rawPath: string) => {
+        let path = rawPath;
+        if (!path.startsWith("/")) {
+          if (!warnedTemplatePaths.has(path)) {
+            if (warnedTemplatePaths.size < WARNED_TEMPLATE_MAX) {
+              warnedTemplatePaths.add(path);
+            }
+            console.warn(
+              `$template path "${path}" should be a JSON Pointer starting with "/". Automatically resolving as "/${path}".`,
+            );
+          }
+          path = "/" + path;
+        }
+        const resolved = getByPath(ctx.stateModel, path);
+        return resolved != null ? String(resolved) : "";
+      },
+    );
+  }
+
   // Arrays: resolve each element
   if (Array.isArray(value)) {
     return value.map((item) => resolvePropValue(item, ctx));

+ 65 - 1
packages/core/src/schema.ts

@@ -900,6 +900,31 @@ Note: state patches appear right after the elements that use them, so the UI fil
     "Use $bindState for form inputs (text fields, checkboxes, selects, sliders, etc.) and $state for read-only data display. Inside repeat scopes, use $bindItem for form inputs bound to the current item. Use dynamic props instead of duplicating elements with opposing visible conditions when only prop values differ.",
   );
   lines.push("");
+  lines.push(
+    '4. Template: `{ "$template": "Hello, ${/name}!" }` - interpolates `${/path}` references in the string with values from the state model.',
+  );
+  lines.push(
+    '   Example: `"label": { "$template": "Items: ${/cart/count} | Total: ${/cart/total}" }` renders "Items: 3 | Total: 42.00" when /cart/count is 3 and /cart/total is 42.00.',
+  );
+  lines.push("");
+
+  // $computed section — only emit when catalog defines functions
+  const catalogFunctions = (catalog.data as Record<string, unknown>).functions;
+  if (catalogFunctions && Object.keys(catalogFunctions).length > 0) {
+    lines.push(
+      '5. Computed: `{ "$computed": "<functionName>", "args": { "key": <expression> } }` - calls a registered function with resolved args and returns the result.',
+    );
+    lines.push(
+      '   Example: `"value": { "$computed": "fullName", "args": { "first": { "$state": "/form/firstName" }, "last": { "$state": "/form/lastName" } } }`',
+    );
+    lines.push("   Available functions:");
+    for (const name of Object.keys(
+      catalogFunctions as Record<string, unknown>,
+    )) {
+      lines.push(`   - ${name}`);
+    }
+    lines.push("");
+  }
 
   // Validation section — only emit when at least one component has a `checks` prop
   const hasChecksComponents = allComponents
@@ -930,7 +955,19 @@ Note: state patches appear right after the elements that use them, so the UI fil
     lines.push("  - numeric — value must be a number");
     lines.push("  - url — valid URL format");
     lines.push(
-      '  - matches — must equal another field (args: { "other": "value" })',
+      '  - matches — must equal another field (args: { "other": { "$state": "/path" } })',
+    );
+    lines.push(
+      '  - equalTo — alias for matches (args: { "other": { "$state": "/path" } })',
+    );
+    lines.push(
+      '  - lessThan — value must be less than another field (args: { "other": { "$state": "/path" } })',
+    );
+    lines.push(
+      '  - greaterThan — value must be greater than another field (args: { "other": { "$state": "/path" } })',
+    );
+    lines.push(
+      '  - requiredIf — required only when another field is truthy (args: { "field": { "$state": "/path" } })',
     );
     lines.push("");
     lines.push("Example:");
@@ -947,6 +984,33 @@ Note: state patches appear right after the elements that use them, so the UI fil
     lines.push("");
   }
 
+  // State watchers section — only emit when actions are available (watchers
+  // trigger actions, so the section is irrelevant without them).
+  if (hasCustomActions || hasBuiltInActions) {
+    lines.push("STATE WATCHERS:");
+    lines.push(
+      "Elements can have an optional `watch` field to react to state changes and trigger actions. The `watch` field is a top-level field on the element (sibling of type/props/children), NOT inside props.",
+    );
+    lines.push(
+      "Maps state paths (JSON Pointers) to action bindings. When the value at a watched path changes, the bound actions fire automatically.",
+    );
+    lines.push("");
+    lines.push(
+      "Example (cascading select — country changes trigger city loading):",
+    );
+    lines.push(
+      `  ${JSON.stringify({ type: "Select", props: { value: { $bindState: "/form/country" }, options: ["US", "Canada", "UK"] }, watch: { "/form/country": { action: "loadCities", params: { country: { $state: "/form/country" } } } }, children: [] })}`,
+    );
+    lines.push("");
+    lines.push(
+      "Use `watch` for cascading dependencies where changing one field should trigger side effects (loading data, resetting dependent fields, computing derived values).",
+    );
+    lines.push(
+      "IMPORTANT: `watch` is a top-level field on the element (sibling of type/props/children), NOT inside props. Watchers only fire when the value changes, not on initial render.",
+    );
+    lines.push("");
+  }
+
   // Rules
   lines.push("RULES:");
   const baseRules =

+ 250 - 0
packages/core/src/spec-validator.test.ts

@@ -0,0 +1,250 @@
+import { describe, it, expect } from "vitest";
+import type { Spec } from "./types";
+import { validateSpec, autoFixSpec } from "./spec-validator";
+
+// =============================================================================
+// validateSpec
+// =============================================================================
+
+describe("validateSpec", () => {
+  it("returns valid for a correct spec", () => {
+    const spec: Spec = {
+      root: "root",
+      elements: {
+        root: { type: "Stack", props: {}, children: ["child1"] },
+        child1: { type: "Text", props: { text: "hello" }, children: [] },
+      },
+    };
+    const result = validateSpec(spec);
+    expect(result.valid).toBe(true);
+    expect(result.issues).toHaveLength(0);
+  });
+
+  it("detects missing root", () => {
+    const spec = {
+      root: "",
+      elements: { a: { type: "T", props: {}, children: [] } },
+    } as Spec;
+    const result = validateSpec(spec);
+    expect(result.valid).toBe(false);
+    expect(result.issues.some((i) => i.code === "missing_root")).toBe(true);
+  });
+
+  it("detects root_not_found", () => {
+    const spec: Spec = {
+      root: "missing",
+      elements: { a: { type: "T", props: {}, children: [] } },
+    };
+    const result = validateSpec(spec);
+    expect(result.valid).toBe(false);
+    expect(result.issues.some((i) => i.code === "root_not_found")).toBe(true);
+  });
+
+  it("detects empty spec", () => {
+    const spec: Spec = { root: "r", elements: {} };
+    const result = validateSpec(spec);
+    expect(result.valid).toBe(false);
+    expect(result.issues.some((i) => i.code === "empty_spec")).toBe(true);
+  });
+
+  it("detects missing_child", () => {
+    const spec: Spec = {
+      root: "root",
+      elements: {
+        root: { type: "Stack", props: {}, children: ["nonexistent"] },
+      },
+    };
+    const result = validateSpec(spec);
+    expect(result.valid).toBe(false);
+    expect(result.issues.some((i) => i.code === "missing_child")).toBe(true);
+  });
+
+  it("detects visible_in_props", () => {
+    const spec: Spec = {
+      root: "root",
+      elements: {
+        root: {
+          type: "Text",
+          props: { visible: { $state: "/show" } },
+          children: [],
+        },
+      },
+    };
+    const result = validateSpec(spec);
+    expect(result.valid).toBe(false);
+    expect(result.issues.some((i) => i.code === "visible_in_props")).toBe(true);
+  });
+
+  it("detects on_in_props", () => {
+    const spec: Spec = {
+      root: "root",
+      elements: {
+        root: {
+          type: "Button",
+          props: { on: { press: { action: "doSomething" } } },
+          children: [],
+        },
+      },
+    };
+    const result = validateSpec(spec);
+    expect(result.valid).toBe(false);
+    expect(result.issues.some((i) => i.code === "on_in_props")).toBe(true);
+  });
+
+  it("detects repeat_in_props", () => {
+    const spec: Spec = {
+      root: "root",
+      elements: {
+        root: {
+          type: "Stack",
+          props: { repeat: { statePath: "/items" } },
+          children: [],
+        },
+      },
+    };
+    const result = validateSpec(spec);
+    expect(result.valid).toBe(false);
+    expect(result.issues.some((i) => i.code === "repeat_in_props")).toBe(true);
+  });
+
+  it("detects watch_in_props", () => {
+    const spec: Spec = {
+      root: "root",
+      elements: {
+        root: {
+          type: "Select",
+          props: {
+            watch: {
+              "/form/country": { action: "loadCities" },
+            },
+          },
+          children: [],
+        },
+      },
+    };
+    const result = validateSpec(spec);
+    expect(result.valid).toBe(false);
+    const watchIssue = result.issues.find((i) => i.code === "watch_in_props");
+    expect(watchIssue).toBeDefined();
+    expect(watchIssue!.elementKey).toBe("root");
+  });
+
+  it("detects orphaned elements when checkOrphans is true", () => {
+    const spec: Spec = {
+      root: "root",
+      elements: {
+        root: { type: "Stack", props: {}, children: [] },
+        orphan: { type: "Text", props: {}, children: [] },
+      },
+    };
+    const result = validateSpec(spec, { checkOrphans: true });
+    expect(result.valid).toBe(true);
+    expect(result.issues.some((i) => i.code === "orphaned_element")).toBe(true);
+  });
+});
+
+// =============================================================================
+// autoFixSpec
+// =============================================================================
+
+describe("autoFixSpec", () => {
+  it("moves visible from props to element level", () => {
+    const spec: Spec = {
+      root: "root",
+      elements: {
+        root: {
+          type: "Text",
+          props: { text: "hi", visible: { $state: "/show" } },
+          children: [],
+        },
+      },
+    };
+    const { spec: fixed, fixes } = autoFixSpec(spec);
+    expect(
+      (fixed.elements.root.props as Record<string, unknown>).visible,
+    ).toBeUndefined();
+    expect(fixed.elements.root.visible).toEqual({ $state: "/show" });
+    expect(fixes.some((f) => f.includes("visible"))).toBe(true);
+  });
+
+  it("moves on from props to element level", () => {
+    const spec: Spec = {
+      root: "root",
+      elements: {
+        root: {
+          type: "Button",
+          props: { label: "OK", on: { press: { action: "submit" } } },
+          children: [],
+        },
+      },
+    };
+    const { spec: fixed, fixes } = autoFixSpec(spec);
+    expect(
+      (fixed.elements.root.props as Record<string, unknown>).on,
+    ).toBeUndefined();
+    expect(fixed.elements.root.on).toEqual({ press: { action: "submit" } });
+    expect(fixes.some((f) => f.includes('"on"'))).toBe(true);
+  });
+
+  it("moves repeat from props to element level", () => {
+    const spec: Spec = {
+      root: "root",
+      elements: {
+        root: {
+          type: "Stack",
+          props: { repeat: { statePath: "/items" } },
+          children: ["child"],
+        },
+        child: { type: "Text", props: {}, children: [] },
+      },
+    };
+    const { spec: fixed, fixes } = autoFixSpec(spec);
+    expect(
+      (fixed.elements.root.props as Record<string, unknown>).repeat,
+    ).toBeUndefined();
+    expect(fixed.elements.root.repeat).toEqual({ statePath: "/items" });
+    expect(fixes.some((f) => f.includes('"repeat"'))).toBe(true);
+  });
+
+  it("moves watch from props to element level", () => {
+    const spec: Spec = {
+      root: "root",
+      elements: {
+        root: {
+          type: "Select",
+          props: {
+            label: "Country",
+            watch: {
+              "/form/country": { action: "loadCities" },
+            },
+          },
+          children: [],
+        },
+      },
+    };
+    const { spec: fixed, fixes } = autoFixSpec(spec);
+    expect(
+      (fixed.elements.root.props as Record<string, unknown>).watch,
+    ).toBeUndefined();
+    expect(fixed.elements.root.watch).toEqual({
+      "/form/country": { action: "loadCities" },
+    });
+    expect(fixes.some((f) => f.includes('"watch"'))).toBe(true);
+  });
+
+  it("returns no fixes for a correct spec", () => {
+    const spec: Spec = {
+      root: "root",
+      elements: {
+        root: {
+          type: "Stack",
+          props: { direction: "vertical" },
+          children: [],
+          watch: { "/x": { action: "y" } },
+        },
+      },
+    };
+    const { fixes } = autoFixSpec(spec);
+    expect(fixes).toHaveLength(0);
+  });
+});

+ 27 - 1
packages/core/src/spec-validator.ts

@@ -28,7 +28,8 @@ export interface SpecIssue {
     | "orphaned_element"
     | "empty_spec"
     | "on_in_props"
-    | "repeat_in_props";
+    | "repeat_in_props"
+    | "watch_in_props";
 }
 
 /**
@@ -151,6 +152,16 @@ export function validateSpec(
         code: "repeat_in_props",
       });
     }
+
+    // 3e. `watch` inside props (should be a top-level field)
+    if (props && "watch" in props && props.watch !== undefined) {
+      issues.push({
+        severity: "error",
+        message: `Element "${key}" has "watch" inside "props". It should be a top-level field on the element (sibling of type/props/children).`,
+        elementKey: key,
+        code: "watch_in_props",
+      });
+    }
   }
 
   // 4. Orphaned elements (optional)
@@ -248,6 +259,21 @@ export function autoFixSpec(spec: Spec): {
       fixes.push(`Moved "repeat" from props to element level on "${key}".`);
     }
 
+    currentProps = fixed.props as Record<string, unknown> | undefined;
+    if (
+      currentProps &&
+      "watch" in currentProps &&
+      currentProps.watch !== undefined
+    ) {
+      const { watch, ...restProps } = currentProps;
+      fixed = {
+        ...fixed,
+        props: restProps,
+        watch: watch as UIElement["watch"],
+      };
+      fixes.push(`Moved "watch" from props to element level on "${key}".`);
+    }
+
     fixedElements[key] = fixed;
   }
 

+ 6 - 0
packages/core/src/types.ts

@@ -69,6 +69,12 @@ export interface UIElement<
   on?: Record<string, ActionBinding | ActionBinding[]>;
   /** Repeat children once per item in a state array */
   repeat?: { statePath: string; key?: string };
+  /**
+   * State watchers — maps JSON Pointer state paths to action bindings.
+   * When the value at a watched path changes, the bound actions fire.
+   * Useful for cascading dependencies (e.g. country → city option loading).
+   */
+  watch?: Record<string, ActionBinding | ActionBinding[]>;
 }
 
 /**

+ 341 - 0
packages/core/src/validation.test.ts

@@ -187,6 +187,226 @@ describe("builtInValidationFunctions", () => {
       ).toBe(false);
     });
   });
+
+  describe("equalTo", () => {
+    it("passes when values are equal", () => {
+      expect(builtInValidationFunctions.equalTo("abc", { other: "abc" })).toBe(
+        true,
+      );
+    });
+
+    it("fails when values differ", () => {
+      expect(builtInValidationFunctions.equalTo("abc", { other: "xyz" })).toBe(
+        false,
+      );
+    });
+  });
+
+  describe("lessThan", () => {
+    it("passes when value is less than other", () => {
+      expect(builtInValidationFunctions.lessThan(3, { other: 5 })).toBe(true);
+    });
+
+    it("fails when value equals other", () => {
+      expect(builtInValidationFunctions.lessThan(5, { other: 5 })).toBe(false);
+    });
+
+    it("fails when value is greater than other", () => {
+      expect(builtInValidationFunctions.lessThan(7, { other: 5 })).toBe(false);
+    });
+
+    it("coerces numeric string vs number", () => {
+      expect(builtInValidationFunctions.lessThan("3", { other: 5 })).toBe(true);
+    });
+
+    it("fails coercion when non-numeric string", () => {
+      expect(builtInValidationFunctions.lessThan("abc", { other: 5 })).toBe(
+        false,
+      );
+    });
+
+    it("passes for string comparison (ISO dates)", () => {
+      expect(
+        builtInValidationFunctions.lessThan("2026-01-01", {
+          other: "2026-06-15",
+        }),
+      ).toBe(true);
+    });
+
+    it("fails for equal strings", () => {
+      expect(
+        builtInValidationFunctions.lessThan("2026-01-01", {
+          other: "2026-01-01",
+        }),
+      ).toBe(false);
+    });
+
+    it("returns false when value is empty string", () => {
+      expect(builtInValidationFunctions.lessThan("", { other: 5 })).toBe(false);
+    });
+
+    it("returns false when other is empty string", () => {
+      expect(builtInValidationFunctions.lessThan(3, { other: "" })).toBe(false);
+    });
+
+    it("returns false when value is empty string vs non-empty string", () => {
+      expect(builtInValidationFunctions.lessThan("", { other: "abc" })).toBe(
+        false,
+      );
+    });
+
+    it("returns false when other is empty string vs non-empty string", () => {
+      expect(builtInValidationFunctions.lessThan("abc", { other: "" })).toBe(
+        false,
+      );
+    });
+
+    it("returns false when other is null", () => {
+      expect(builtInValidationFunctions.lessThan(3, { other: null })).toBe(
+        false,
+      );
+    });
+
+    it("returns false when value is null", () => {
+      expect(builtInValidationFunctions.lessThan(null, { other: 5 })).toBe(
+        false,
+      );
+    });
+
+    it("returns false when other is undefined", () => {
+      expect(builtInValidationFunctions.lessThan(3, { other: undefined })).toBe(
+        false,
+      );
+    });
+  });
+
+  describe("greaterThan", () => {
+    it("passes when value is greater than other", () => {
+      expect(builtInValidationFunctions.greaterThan(7, { other: 5 })).toBe(
+        true,
+      );
+    });
+
+    it("fails when value equals other", () => {
+      expect(builtInValidationFunctions.greaterThan(5, { other: 5 })).toBe(
+        false,
+      );
+    });
+
+    it("fails when value is less than other", () => {
+      expect(builtInValidationFunctions.greaterThan(3, { other: 5 })).toBe(
+        false,
+      );
+    });
+
+    it("coerces numeric string vs number", () => {
+      expect(builtInValidationFunctions.greaterThan("7", { other: 5 })).toBe(
+        true,
+      );
+    });
+
+    it("fails coercion when non-numeric string", () => {
+      expect(builtInValidationFunctions.greaterThan("abc", { other: 5 })).toBe(
+        false,
+      );
+    });
+
+    it("passes for string comparison (ISO dates)", () => {
+      expect(
+        builtInValidationFunctions.greaterThan("2026-06-15", {
+          other: "2026-01-01",
+        }),
+      ).toBe(true);
+    });
+
+    it("fails for lesser strings", () => {
+      expect(
+        builtInValidationFunctions.greaterThan("2026-01-01", {
+          other: "2026-06-15",
+        }),
+      ).toBe(false);
+    });
+
+    it("returns false when value is empty string", () => {
+      expect(builtInValidationFunctions.greaterThan("", { other: 5 })).toBe(
+        false,
+      );
+    });
+
+    it("returns false when other is empty string", () => {
+      expect(builtInValidationFunctions.greaterThan(3, { other: "" })).toBe(
+        false,
+      );
+    });
+
+    it("returns false when value is empty string vs non-empty string", () => {
+      expect(builtInValidationFunctions.greaterThan("", { other: "abc" })).toBe(
+        false,
+      );
+    });
+
+    it("returns false when other is empty string vs non-empty string", () => {
+      expect(builtInValidationFunctions.greaterThan("abc", { other: "" })).toBe(
+        false,
+      );
+    });
+
+    it("returns false when other is null", () => {
+      expect(builtInValidationFunctions.greaterThan(3, { other: null })).toBe(
+        false,
+      );
+    });
+
+    it("returns false when value is undefined", () => {
+      expect(
+        builtInValidationFunctions.greaterThan(undefined, { other: 5 }),
+      ).toBe(false);
+    });
+
+    it("returns false when value is null", () => {
+      expect(builtInValidationFunctions.greaterThan(null, { other: 5 })).toBe(
+        false,
+      );
+    });
+  });
+
+  describe("requiredIf", () => {
+    it("passes when condition is falsy (field not required)", () => {
+      expect(builtInValidationFunctions.requiredIf("", { field: false })).toBe(
+        true,
+      );
+      expect(builtInValidationFunctions.requiredIf("", { field: "" })).toBe(
+        true,
+      );
+      expect(builtInValidationFunctions.requiredIf("", { field: null })).toBe(
+        true,
+      );
+      expect(
+        builtInValidationFunctions.requiredIf("", { field: undefined }),
+      ).toBe(true);
+    });
+
+    it("fails when condition is truthy and value is empty", () => {
+      expect(builtInValidationFunctions.requiredIf("", { field: true })).toBe(
+        false,
+      );
+      expect(
+        builtInValidationFunctions.requiredIf(null, { field: "yes" }),
+      ).toBe(false);
+      expect(
+        builtInValidationFunctions.requiredIf(undefined, { field: 1 }),
+      ).toBe(false);
+    });
+
+    it("passes when condition is truthy and value is present", () => {
+      expect(
+        builtInValidationFunctions.requiredIf("hello", { field: true }),
+      ).toBe(true);
+      expect(builtInValidationFunctions.requiredIf(42, { field: true })).toBe(
+        true,
+      );
+    });
+  });
 });
 
 describe("runValidationCheck", () => {
@@ -391,6 +611,22 @@ describe("check helper", () => {
     });
   });
 
+  describe("numeric", () => {
+    it("creates numeric check with default message", () => {
+      const c = check.numeric();
+
+      expect(c.type).toBe("numeric");
+      expect(c.message).toBe("Must be a number");
+    });
+
+    it("creates numeric check with custom message", () => {
+      const c = check.numeric("Numbers only");
+
+      expect(c.type).toBe("numeric");
+      expect(c.message).toBe("Numbers only");
+    });
+  });
+
   describe("matches", () => {
     it("creates matches check with path reference", () => {
       const c = check.matches("/password", "Passwords must match");
@@ -400,4 +636,109 @@ describe("check helper", () => {
       expect(c.message).toBe("Passwords must match");
     });
   });
+
+  describe("equalTo", () => {
+    it("creates equalTo check with path reference", () => {
+      const c = check.equalTo("/email", "Emails must match");
+
+      expect(c.type).toBe("equalTo");
+      expect(c.args).toEqual({ other: { $state: "/email" } });
+      expect(c.message).toBe("Emails must match");
+    });
+  });
+
+  describe("lessThan", () => {
+    it("creates lessThan check with path reference", () => {
+      const c = check.lessThan("/maxValue", "Must be less");
+
+      expect(c.type).toBe("lessThan");
+      expect(c.args).toEqual({ other: { $state: "/maxValue" } });
+      expect(c.message).toBe("Must be less");
+    });
+  });
+
+  describe("greaterThan", () => {
+    it("creates greaterThan check with path reference", () => {
+      const c = check.greaterThan("/minValue");
+
+      expect(c.type).toBe("greaterThan");
+      expect(c.args).toEqual({ other: { $state: "/minValue" } });
+    });
+  });
+
+  describe("requiredIf", () => {
+    it("creates requiredIf check with path reference", () => {
+      const c = check.requiredIf("/toggle", "Required when toggle is on");
+
+      expect(c.type).toBe("requiredIf");
+      expect(c.args).toEqual({ field: { $state: "/toggle" } });
+      expect(c.message).toBe("Required when toggle is on");
+    });
+  });
+});
+
+// =============================================================================
+// Deep arg resolution in runValidationCheck
+// =============================================================================
+
+describe("deep arg resolution", () => {
+  it("resolves nested $state refs in validation args", () => {
+    const result = runValidationCheck(
+      {
+        type: "matches",
+        args: { other: { $state: "/form/password" } },
+        message: "Passwords must match",
+      },
+      {
+        value: "secret123",
+        stateModel: { form: { password: "secret123" } },
+      },
+    );
+    expect(result.valid).toBe(true);
+  });
+
+  it("resolves $state in cross-field lessThan check", () => {
+    const result = runValidationCheck(
+      {
+        type: "lessThan",
+        args: { other: { $state: "/form/maxPrice" } },
+        message: "Must be less than max price",
+      },
+      {
+        value: 50,
+        stateModel: { form: { maxPrice: 100 } },
+      },
+    );
+    expect(result.valid).toBe(true);
+  });
+
+  it("resolves $state in requiredIf check", () => {
+    const result = runValidationCheck(
+      {
+        type: "requiredIf",
+        args: { field: { $state: "/form/enableEmail" } },
+        message: "Email is required",
+      },
+      {
+        value: "",
+        stateModel: { form: { enableEmail: true } },
+      },
+    );
+    expect(result.valid).toBe(false);
+  });
+
+  it("passes requiredIf when condition is false", () => {
+    const result = runValidationCheck(
+      {
+        type: "requiredIf",
+        args: { field: { $state: "/form/enableEmail" } },
+        message: "Email is required",
+      },
+      {
+        value: "",
+        stateModel: { form: { enableEmail: false } },
+      },
+    );
+    expect(result.valid).toBe(true);
+  });
 });

+ 104 - 4
packages/core/src/validation.ts

@@ -2,6 +2,7 @@ import { z } from "zod";
 import type { DynamicValue, StateModel, VisibilityCondition } from "./types";
 import { DynamicValueSchema, resolveDynamicValue } from "./types";
 import { VisibilityConditionSchema, evaluateVisibility } from "./visibility";
+import { resolvePropValue } from "./props";
 
 /**
  * Validation check definition
@@ -63,6 +64,14 @@ export interface ValidationFunctionDefinition {
   description?: string;
 }
 
+const matchesImpl: ValidationFunction = (
+  value: unknown,
+  args?: Record<string, unknown>,
+) => {
+  const other = args?.other;
+  return value === other;
+};
+
 /**
  * Built-in validation functions
  */
@@ -164,9 +173,64 @@ export const builtInValidationFunctions: Record<string, ValidationFunction> = {
   /**
    * Check if value matches another field
    */
-  matches: (value: unknown, args?: Record<string, unknown>) => {
+  matches: matchesImpl,
+
+  /**
+   * Alias for matches with a more descriptive name for cross-field equality
+   */
+  equalTo: matchesImpl,
+
+  /**
+   * Check if value is less than another field's value.
+   * Supports numbers, strings (useful for ISO date comparison), and
+   * cross-type numeric coercion (e.g. string "3" vs number 5).
+   */
+  lessThan: (value: unknown, args?: Record<string, unknown>) => {
+    const other = args?.other;
+    if (value == null || other == null || value === "" || other === "")
+      return false;
+    if (typeof value === "number" && typeof other === "number")
+      return value < other;
+    if (typeof value === "string" && typeof other === "string")
+      return value < other;
+    const numVal = Number(value);
+    const numOther = Number(other);
+    if (!isNaN(numVal) && !isNaN(numOther)) return numVal < numOther;
+    return false;
+  },
+
+  /**
+   * Check if value is greater than another field's value.
+   * Supports numbers, strings (useful for ISO date comparison), and
+   * cross-type numeric coercion (e.g. string "7" vs number 5).
+   */
+  greaterThan: (value: unknown, args?: Record<string, unknown>) => {
     const other = args?.other;
-    return value === other;
+    if (value == null || other == null || value === "" || other === "")
+      return false;
+    if (typeof value === "number" && typeof other === "number")
+      return value > other;
+    if (typeof value === "string" && typeof other === "string")
+      return value > other;
+    const numVal = Number(value);
+    const numOther = Number(other);
+    if (!isNaN(numVal) && !isNaN(numOther)) return numVal > numOther;
+    return false;
+  },
+
+  /**
+   * Required only when a condition is met.
+   * Uses JS truthiness: 0, false, "", null, and undefined are all
+   * treated as "condition not met" (field not required), matching
+   * the visibility system's bare-condition semantics.
+   */
+  requiredIf: (value: unknown, args?: Record<string, unknown>) => {
+    const condition = args?.field;
+    if (!condition) return true;
+    if (value === null || value === undefined) return false;
+    if (typeof value === "string") return value.trim().length > 0;
+    if (Array.isArray(value)) return value.length > 0;
+    return true;
   },
 };
 
@@ -209,11 +273,12 @@ export function runValidationCheck(
 ): ValidationCheckResult {
   const { value, stateModel, customFunctions } = ctx;
 
-  // Resolve args
+  // Resolve args using resolvePropValue so nested $state refs (and any other
+  // prop expressions) are handled consistently with the rest of the system.
   const resolvedArgs: Record<string, unknown> = {};
   if (check.args) {
     for (const [key, argValue] of Object.entries(check.args)) {
-      resolvedArgs[key] = resolveDynamicValue(argValue, stateModel);
+      resolvedArgs[key] = resolvePropValue(argValue, { stateModel });
     }
   }
 
@@ -326,6 +391,11 @@ export const check = {
     message,
   }),
 
+  numeric: (message = "Must be a number"): ValidationCheck => ({
+    type: "numeric",
+    message,
+  }),
+
   matches: (
     otherPath: string,
     message = "Fields must match",
@@ -334,4 +404,34 @@ export const check = {
     args: { other: { $state: otherPath } },
     message,
   }),
+
+  equalTo: (
+    otherPath: string,
+    message = "Fields must match",
+  ): ValidationCheck => ({
+    type: "equalTo",
+    args: { other: { $state: otherPath } },
+    message,
+  }),
+
+  lessThan: (otherPath: string, message?: string): ValidationCheck => ({
+    type: "lessThan",
+    args: { other: { $state: otherPath } },
+    message: message ?? "Must be less than the compared field",
+  }),
+
+  greaterThan: (otherPath: string, message?: string): ValidationCheck => ({
+    type: "greaterThan",
+    args: { other: { $state: otherPath } },
+    message: message ?? "Must be greater than the compared field",
+  }),
+
+  requiredIf: (
+    fieldPath: string,
+    message = "This field is required",
+  ): ValidationCheck => ({
+    type: "requiredIf",
+    args: { field: { $state: fieldPath } },
+    message,
+  }),
 };

+ 25 - 2
packages/react/src/contexts/actions.tsx

@@ -17,6 +17,7 @@ import {
   type ResolvedAction,
 } from "@json-render/core";
 import { useStateStore } from "./state";
+import { useOptionalValidation } from "./validation";
 
 /**
  * Generate a unique ID for use with the "$id" token.
@@ -137,6 +138,7 @@ export function ActionProvider({
   children,
 }: ActionProviderProps) {
   const { get, set, getSnapshot } = useStateStore();
+  const validation = useOptionalValidation();
 
   const [handlers, setHandlers] =
     useState<Record<string, ActionHandler>>(initialHandlers);
@@ -227,13 +229,34 @@ export function ActionProvider({
           if (previousScreen) {
             set("/currentScreen", previousScreen);
           } else {
-            // Sentinel empty string = clear currentScreen (return to default)
             set("/currentScreen", undefined);
           }
         }
         return;
       }
 
+      // Built-in: validateForm triggers validateAll from the ValidationProvider
+      // and writes the result to a state path (default: /formValidation).
+      // IMPORTANT: validateAll() is synchronous — it runs all registered field
+      // validations and returns immediately. This guarantees that the next action
+      // in a sequential list (e.g. [validateForm, submitForm]) can read the
+      // validation result from state without awaiting an extra tick.
+      if (resolved.action === "validateForm") {
+        const validateAll = validation?.validateAll;
+        if (!validateAll) {
+          console.warn(
+            "validateForm action was dispatched but no ValidationProvider is connected. " +
+              "Ensure ValidationProvider is rendered inside the provider tree.",
+          );
+          return;
+        }
+        const valid = validateAll();
+        const statePath =
+          (resolved.params?.statePath as string) || "/formValidation";
+        set(statePath, { valid });
+        return;
+      }
+
       const handler = handlers[resolved.action];
 
       if (!handler) {
@@ -300,7 +323,7 @@ export function ActionProvider({
         });
       }
     },
-    [handlers, get, set, getSnapshot, navigate],
+    [handlers, get, set, getSnapshot, navigate, validation],
   );
 
   const confirm = useCallback(() => {

+ 14 - 9
packages/react/src/contexts/validation.tsx

@@ -6,7 +6,6 @@ import React, {
   useState,
   useCallback,
   useMemo,
-  useRef,
   type ReactNode,
 } from "react";
 import {
@@ -130,11 +129,7 @@ export function ValidationProvider({
   customFunctions = {},
   children,
 }: ValidationProviderProps) {
-  const { state } = useStateStore();
-  // Keep a ref to the latest state so `validate` doesn't change on every
-  // state update — preventing the entire validation context from churning.
-  const stateRef = useRef(state);
-  stateRef.current = state;
+  const { state, getSnapshot } = useStateStore();
 
   const [fieldStates, setFieldStates] = useState<
     Record<string, FieldValidationState>
@@ -160,8 +155,10 @@ export function ValidationProvider({
 
   const validate = useCallback(
     (path: string, config: ValidationConfig): ValidationResult => {
-      // Walk the nested state object using JSON Pointer segments
-      const currentState = stateRef.current;
+      // Read from the store directly so validation sees values written in the
+      // same synchronous handler (e.g. setValue then validate in onChange).
+      // Using React state would return the stale pre-render snapshot.
+      const currentState = getSnapshot();
       const segments = path.split("/").filter(Boolean);
       let value: unknown = currentState;
       for (const seg of segments) {
@@ -189,7 +186,7 @@ export function ValidationProvider({
 
       return result;
     },
-    [customFunctions],
+    [customFunctions, getSnapshot],
   );
 
   const touch = useCallback((path: string) => {
@@ -263,6 +260,14 @@ export function useValidation(): ValidationContextValue {
   return ctx;
 }
 
+/**
+ * Non-throwing variant of useValidation.
+ * Returns null when no ValidationProvider is present.
+ */
+export function useOptionalValidation(): ValidationContextValue | null {
+  return useContext(ValidationContext);
+}
+
 /**
  * Hook to get validation state for a field
  */

+ 770 - 0
packages/react/src/dynamic-forms.test.tsx

@@ -0,0 +1,770 @@
+import { describe, it, expect, vi } from "vitest";
+import React, { useState, useCallback, useMemo } from "react";
+import { render, act, fireEvent, screen } from "@testing-library/react";
+import type { Spec } from "@json-render/core";
+import {
+  JSONUIProvider,
+  Renderer,
+  type ComponentRenderProps,
+} from "./renderer";
+import { useStateStore } from "./contexts/state";
+import { useFieldValidation } from "./contexts/validation";
+import { useBoundProp } from "./hooks";
+
+// =============================================================================
+// Stub components
+// =============================================================================
+
+function Button({ element, emit }: ComponentRenderProps<{ label: string }>) {
+  return (
+    <button data-testid="btn" onClick={() => emit("press")}>
+      {element.props.label}
+    </button>
+  );
+}
+
+function Text({ element }: ComponentRenderProps<{ text: unknown }>) {
+  const value = element.props.text;
+  return (
+    <span data-testid="text">
+      {value == null
+        ? ""
+        : typeof value === "string"
+          ? value
+          : JSON.stringify(value)}
+    </span>
+  );
+}
+
+function InputField({
+  element,
+  bindings,
+}: ComponentRenderProps<{
+  label?: string;
+  value?: string;
+  checks?: Array<{
+    type: string;
+    message: string;
+    args?: Record<string, unknown>;
+  }>;
+}>) {
+  const props = element.props;
+  const [boundValue, setBoundValue] = useBoundProp<string>(
+    props.value as string | undefined,
+    bindings?.value,
+  );
+  const [localValue, setLocalValue] = useState("");
+  const isBound = !!bindings?.value;
+  const value = isBound ? (boundValue ?? "") : localValue;
+  const setValue = isBound ? setBoundValue : setLocalValue;
+
+  const hasValidation = !!(bindings?.value && props.checks?.length);
+  const config = useMemo(
+    () => (hasValidation ? { checks: props.checks ?? [] } : undefined),
+    [hasValidation, props.checks],
+  );
+  const { errors } = useFieldValidation(bindings?.value ?? "", config);
+
+  return (
+    <div>
+      {props.label && <label>{props.label}</label>}
+      <input
+        data-testid="input"
+        value={value}
+        onChange={(e) => setValue(e.target.value)}
+      />
+      {errors.length > 0 && <span data-testid="input-error">{errors[0]}</span>}
+    </div>
+  );
+}
+
+function SelectField({
+  element,
+  bindings,
+}: ComponentRenderProps<{ label?: string; value?: string }>) {
+  const props = element.props;
+  const [boundValue] = useBoundProp<string>(
+    props.value as string | undefined,
+    bindings?.value,
+  );
+  return <span data-testid="select-value">{boundValue ?? ""}</span>;
+}
+
+/**
+ * Select stub that mirrors the real shadcn Select validation behavior:
+ * calls setValue then validate() synchronously in onValueChange.
+ */
+function ValidatedSelect({
+  element,
+  bindings,
+  emit,
+}: ComponentRenderProps<{
+  label?: string;
+  name?: string;
+  options?: string[];
+  placeholder?: string;
+  value?: string;
+  checks?: Array<{
+    type: string;
+    message: string;
+    args?: Record<string, unknown>;
+  }>;
+  validateOn?: "change" | "blur" | "submit";
+}>) {
+  const props = element.props;
+  const [boundValue, setBoundValue] = useBoundProp<string>(
+    props.value as string | undefined,
+    bindings?.value,
+  );
+  const [localValue, setLocalValue] = useState("");
+  const isBound = !!bindings?.value;
+  const value = isBound ? (boundValue ?? "") : localValue;
+  const setValue = isBound ? setBoundValue : setLocalValue;
+  const validateOn = props.validateOn ?? "change";
+
+  const hasValidation = !!(bindings?.value && props.checks?.length);
+  const config = useMemo(
+    () =>
+      hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
+    [hasValidation, props.checks, validateOn],
+  );
+  const { errors, validate } = useFieldValidation(
+    bindings?.value ?? "",
+    config,
+  );
+
+  const options = props.options ?? [];
+
+  return (
+    <div>
+      {props.label && <label>{props.label}</label>}
+      <select
+        data-testid={`select-${props.name ?? "default"}`}
+        value={value}
+        onChange={(e) => {
+          setValue(e.target.value);
+          if (hasValidation && validateOn === "change") validate();
+          emit("change");
+        }}
+      >
+        <option value="">{props.placeholder ?? "Select..."}</option>
+        {options.map((opt) => (
+          <option key={opt} value={opt}>
+            {opt}
+          </option>
+        ))}
+      </select>
+      {errors.length > 0 && (
+        <span data-testid={`select-error-${props.name ?? "default"}`}>
+          {errors[0]}
+        </span>
+      )}
+    </div>
+  );
+}
+
+function StateProbe() {
+  const { state } = useStateStore();
+  return <pre data-testid="state-probe">{JSON.stringify(state)}</pre>;
+}
+
+const registry = { Button, Text, Input: InputField, Select: SelectField };
+
+function getState(): Record<string, unknown> {
+  return JSON.parse(screen.getByTestId("state-probe").textContent!);
+}
+
+// =============================================================================
+// $computed expressions in rendering
+// =============================================================================
+
+describe("$computed expressions in rendering", () => {
+  it("resolves a $computed prop using provided functions", async () => {
+    const spec: Spec = {
+      state: { first: "Jane", last: "Doe" },
+      root: "main",
+      elements: {
+        main: {
+          type: "Text",
+          props: {
+            text: {
+              $computed: "fullName",
+              args: {
+                first: { $state: "/first" },
+                last: { $state: "/last" },
+              },
+            },
+          },
+          children: [],
+        },
+      },
+    };
+
+    const functions = {
+      fullName: (args: Record<string, unknown>) => `${args.first} ${args.last}`,
+    };
+
+    render(
+      <JSONUIProvider
+        registry={registry}
+        initialState={spec.state}
+        functions={functions}
+      >
+        <Renderer spec={spec} registry={registry} />
+      </JSONUIProvider>,
+    );
+
+    expect(screen.getByTestId("text").textContent).toBe("Jane Doe");
+  });
+
+  it("renders gracefully when functions prop is omitted", async () => {
+    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
+    const spec: Spec = {
+      state: {},
+      root: "main",
+      elements: {
+        main: {
+          type: "Text",
+          props: {
+            text: { $computed: "missing" },
+          },
+          children: [],
+        },
+      },
+    };
+
+    render(
+      <JSONUIProvider registry={registry} initialState={spec.state}>
+        <Renderer spec={spec} registry={registry} />
+      </JSONUIProvider>,
+    );
+
+    expect(screen.getByTestId("text").textContent).toBe("");
+    warnSpy.mockRestore();
+  });
+});
+
+// =============================================================================
+// $template expressions in rendering
+// =============================================================================
+
+describe("$template expressions in rendering", () => {
+  it("interpolates state values into a template string", () => {
+    const spec: Spec = {
+      state: { user: { name: "Alice" }, count: 3 },
+      root: "main",
+      elements: {
+        main: {
+          type: "Text",
+          props: {
+            text: {
+              $template: "Hello, ${/user/name}! You have ${/count} messages.",
+            },
+          },
+          children: [],
+        },
+      },
+    };
+
+    render(
+      <JSONUIProvider registry={registry} initialState={spec.state}>
+        <Renderer spec={spec} registry={registry} />
+      </JSONUIProvider>,
+    );
+
+    expect(screen.getByTestId("text").textContent).toBe(
+      "Hello, Alice! You have 3 messages.",
+    );
+  });
+
+  it("resolves missing paths to empty string", () => {
+    const spec: Spec = {
+      state: {},
+      root: "main",
+      elements: {
+        main: {
+          type: "Text",
+          props: {
+            text: { $template: "Hi ${/name}!" },
+          },
+          children: [],
+        },
+      },
+    };
+
+    render(
+      <JSONUIProvider registry={registry} initialState={spec.state}>
+        <Renderer spec={spec} registry={registry} />
+      </JSONUIProvider>,
+    );
+
+    expect(screen.getByTestId("text").textContent).toBe("Hi !");
+  });
+});
+
+// =============================================================================
+// Watchers
+// =============================================================================
+
+describe("watchers (watch field)", () => {
+  it("does not fire on initial render, fires when watched state changes", async () => {
+    const loadCities = vi.fn(async (params: Record<string, unknown>) => {
+      // no-op, just tracking the call
+    });
+
+    const spec: Spec = {
+      state: { form: { country: "" }, citiesLoaded: false },
+      root: "main",
+      elements: {
+        main: {
+          type: "Button",
+          props: { label: "Set Country" },
+          on: {
+            press: [
+              {
+                action: "setState",
+                params: { statePath: "/form/country", value: "US" },
+              },
+            ],
+          },
+          children: [],
+        },
+        watcher: {
+          type: "Select",
+          props: { value: { $state: "/form/country" } },
+          watch: {
+            "/form/country": {
+              action: "loadCities",
+              params: { country: { $state: "/form/country" } },
+            },
+          },
+          children: [],
+        },
+      },
+    };
+
+    // Add watcher as a child of a wrapper so both render
+    const wrapperSpec: Spec = {
+      ...spec,
+      root: "wrapper",
+      elements: {
+        ...spec.elements,
+        wrapper: {
+          type: "Button",
+          props: { label: "wrapper" },
+          children: ["main", "watcher"],
+        },
+      },
+    };
+
+    // Use a Stack-like wrapper -- but since we only have Button/Text/Select
+    // stubs, we need a container. Let's add a simple Stack stub.
+    const Stack = ({
+      children,
+    }: ComponentRenderProps<Record<string, unknown>>) => {
+      return <div data-testid="stack">{children}</div>;
+    };
+    const reg = { ...registry, Stack };
+
+    const stackSpec: Spec = {
+      state: { form: { country: "" }, citiesLoaded: false },
+      root: "wrapper",
+      elements: {
+        wrapper: {
+          type: "Stack",
+          props: {},
+          children: ["btn", "watcher"],
+        },
+        btn: {
+          type: "Button",
+          props: { label: "Set Country" },
+          on: {
+            press: [
+              {
+                action: "setState",
+                params: { statePath: "/form/country", value: "US" },
+              },
+            ],
+          },
+          children: [],
+        },
+        watcher: {
+          type: "Select",
+          props: { value: { $state: "/form/country" } },
+          watch: {
+            "/form/country": {
+              action: "loadCities",
+              params: { country: { $state: "/form/country" } },
+            },
+          },
+          children: [],
+        },
+      },
+    };
+
+    render(
+      <JSONUIProvider
+        registry={reg}
+        initialState={stackSpec.state}
+        handlers={{ loadCities }}
+      >
+        <Renderer spec={stackSpec} registry={reg} />
+        <StateProbe />
+      </JSONUIProvider>,
+    );
+
+    // Not called on initial render
+    expect(loadCities).not.toHaveBeenCalled();
+
+    // Change the watched state path
+    await act(async () => {
+      fireEvent.click(screen.getByTestId("btn"));
+    });
+
+    expect(loadCities).toHaveBeenCalledTimes(1);
+    expect(loadCities).toHaveBeenCalledWith(
+      expect.objectContaining({ country: "US" }),
+    );
+  });
+
+  it("fires multiple action bindings on the same watch path", async () => {
+    const action1 = vi.fn();
+    const action2 = vi.fn();
+
+    const Stack = ({
+      children,
+    }: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
+    const reg = { ...registry, Stack };
+
+    const spec: Spec = {
+      state: { value: "a" },
+      root: "wrapper",
+      elements: {
+        wrapper: {
+          type: "Stack",
+          props: {},
+          children: ["btn", "watcher"],
+        },
+        btn: {
+          type: "Button",
+          props: { label: "Change" },
+          on: {
+            press: [
+              {
+                action: "setState",
+                params: { statePath: "/value", value: "b" },
+              },
+            ],
+          },
+          children: [],
+        },
+        watcher: {
+          type: "Text",
+          props: { text: { $state: "/value" } },
+          watch: {
+            "/value": [{ action: "action1" }, { action: "action2" }],
+          },
+          children: [],
+        },
+      },
+    };
+
+    render(
+      <JSONUIProvider
+        registry={reg}
+        initialState={spec.state}
+        handlers={{ action1, action2 }}
+      >
+        <Renderer spec={spec} registry={reg} />
+      </JSONUIProvider>,
+    );
+
+    await act(async () => {
+      fireEvent.click(screen.getByTestId("btn"));
+    });
+
+    expect(action1).toHaveBeenCalledTimes(1);
+    expect(action2).toHaveBeenCalledTimes(1);
+  });
+});
+
+// =============================================================================
+// validateForm action
+// =============================================================================
+
+describe("validateForm action", () => {
+  it("writes { valid: false } when a required field is empty", async () => {
+    const Stack = ({
+      children,
+    }: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
+    const reg = { ...registry, Stack };
+
+    const spec: Spec = {
+      state: { form: { email: "" }, result: null },
+      root: "wrapper",
+      elements: {
+        wrapper: {
+          type: "Stack",
+          props: {},
+          children: ["emailInput", "submitBtn"],
+        },
+        emailInput: {
+          type: "Input",
+          props: {
+            label: "Email",
+            value: { $bindState: "/form/email" },
+            checks: [{ type: "required", message: "Email is required" }],
+          },
+          children: [],
+        },
+        submitBtn: {
+          type: "Button",
+          props: { label: "Submit" },
+          on: {
+            press: [
+              {
+                action: "validateForm",
+                params: { statePath: "/result" },
+              },
+            ],
+          },
+          children: [],
+        },
+      },
+    };
+
+    render(
+      <JSONUIProvider registry={reg} initialState={spec.state}>
+        <Renderer spec={spec} registry={reg} />
+        <StateProbe />
+      </JSONUIProvider>,
+    );
+
+    // Click submit with empty email
+    await act(async () => {
+      fireEvent.click(screen.getByTestId("btn"));
+    });
+
+    const state = getState();
+    expect(state.result).toEqual({ valid: false });
+  });
+
+  it("writes { valid: true } when all fields pass validation", async () => {
+    const Stack = ({
+      children,
+    }: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
+    const reg = { ...registry, Stack };
+
+    const spec: Spec = {
+      state: { form: { email: "test@example.com" }, result: null },
+      root: "wrapper",
+      elements: {
+        wrapper: {
+          type: "Stack",
+          props: {},
+          children: ["emailInput", "submitBtn"],
+        },
+        emailInput: {
+          type: "Input",
+          props: {
+            label: "Email",
+            value: { $bindState: "/form/email" },
+            checks: [{ type: "required", message: "Email is required" }],
+          },
+          children: [],
+        },
+        submitBtn: {
+          type: "Button",
+          props: { label: "Submit" },
+          on: {
+            press: [
+              {
+                action: "validateForm",
+                params: { statePath: "/result" },
+              },
+            ],
+          },
+          children: [],
+        },
+      },
+    };
+
+    render(
+      <JSONUIProvider registry={reg} initialState={spec.state}>
+        <Renderer spec={spec} registry={reg} />
+        <StateProbe />
+      </JSONUIProvider>,
+    );
+
+    await act(async () => {
+      fireEvent.click(screen.getByTestId("btn"));
+    });
+
+    const state = getState();
+    expect(state.result).toEqual({ valid: true });
+  });
+
+  it("defaults to /formValidation when no statePath is provided", async () => {
+    const Stack = ({
+      children,
+    }: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
+    const reg = { ...registry, Stack };
+
+    const spec: Spec = {
+      state: { form: { name: "filled" } },
+      root: "wrapper",
+      elements: {
+        wrapper: {
+          type: "Stack",
+          props: {},
+          children: ["nameInput", "submitBtn"],
+        },
+        nameInput: {
+          type: "Input",
+          props: {
+            label: "Name",
+            value: { $bindState: "/form/name" },
+            checks: [{ type: "required", message: "Required" }],
+          },
+          children: [],
+        },
+        submitBtn: {
+          type: "Button",
+          props: { label: "Submit" },
+          on: {
+            press: [{ action: "validateForm" }],
+          },
+          children: [],
+        },
+      },
+    };
+
+    render(
+      <JSONUIProvider registry={reg} initialState={spec.state}>
+        <Renderer spec={spec} registry={reg} />
+        <StateProbe />
+      </JSONUIProvider>,
+    );
+
+    await act(async () => {
+      fireEvent.click(screen.getByTestId("btn"));
+    });
+
+    const state = getState();
+    expect(state.formValidation).toEqual({ valid: true });
+  });
+});
+
+// =============================================================================
+// Select validate-on-change timing (#151)
+// =============================================================================
+
+describe("Select validate-on-change sees the new value, not the stale value", () => {
+  const Stack = ({
+    children,
+  }: ComponentRenderProps<Record<string, unknown>>) => <div>{children}</div>;
+
+  const regWithSelect = {
+    ...registry,
+    Stack,
+    Select: ValidatedSelect,
+  };
+
+  it("does not show 'required' error when selecting the first value", async () => {
+    const spec: Spec = {
+      state: { form: { country: "" } },
+      root: "wrapper",
+      elements: {
+        wrapper: {
+          type: "Stack",
+          props: {},
+          children: ["countrySelect"],
+        },
+        countrySelect: {
+          type: "Select",
+          props: {
+            label: "Country",
+            name: "country",
+            options: ["US", "Canada", "UK"],
+            placeholder: "Choose a country",
+            value: { $bindState: "/form/country" },
+            checks: [{ type: "required", message: "Country is required" }],
+            validateOn: "change",
+          },
+          children: [],
+        },
+      },
+    };
+
+    render(
+      <JSONUIProvider registry={regWithSelect} initialState={spec.state}>
+        <Renderer spec={spec} registry={regWithSelect} />
+        <StateProbe />
+      </JSONUIProvider>,
+    );
+
+    // Select "US" for the first time (from empty)
+    await act(async () => {
+      fireEvent.change(screen.getByTestId("select-country"), {
+        target: { value: "US" },
+      });
+    });
+
+    // The value should be set in state
+    const state = getState();
+    expect((state.form as Record<string, unknown>).country).toBe("US");
+
+    // No validation error should appear -- "US" is non-empty
+    expect(screen.queryByTestId("select-error-country")).toBeNull();
+  });
+
+  it("does not show 'required' error when selecting the first city after country change resets it", async () => {
+    const spec: Spec = {
+      state: {
+        form: { country: "US", city: "" },
+        availableCities: ["New York", "Chicago"],
+      },
+      root: "wrapper",
+      elements: {
+        wrapper: {
+          type: "Stack",
+          props: {},
+          children: ["citySelect"],
+        },
+        citySelect: {
+          type: "Select",
+          props: {
+            label: "City",
+            name: "city",
+            options: ["New York", "Chicago"],
+            placeholder: "Select a city",
+            value: { $bindState: "/form/city" },
+            checks: [{ type: "required", message: "City is required" }],
+            validateOn: "change",
+          },
+          children: [],
+        },
+      },
+    };
+
+    render(
+      <JSONUIProvider registry={regWithSelect} initialState={spec.state}>
+        <Renderer spec={spec} registry={regWithSelect} />
+        <StateProbe />
+      </JSONUIProvider>,
+    );
+
+    // Select "New York" for the first time (from empty)
+    await act(async () => {
+      fireEvent.change(screen.getByTestId("select-city"), {
+        target: { value: "New York" },
+      });
+    });
+
+    const state = getState();
+    expect((state.form as Record<string, unknown>).city).toBe("New York");
+
+    // No validation error should appear
+    expect(screen.queryByTestId("select-error-city")).toBeNull();
+  });
+});

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

@@ -30,6 +30,7 @@ export {
 export {
   ValidationProvider,
   useValidation,
+  useOptionalValidation,
   useFieldValidation,
   type ValidationContextValue,
   type ValidationProviderProps,

+ 138 - 32
packages/react/src/renderer.tsx

@@ -5,7 +5,9 @@ import React, {
   type ErrorInfo,
   type ReactNode,
   useCallback,
+  useEffect,
   useMemo,
+  useRef,
 } from "react";
 import type {
   UIElement,
@@ -14,6 +16,7 @@ import type {
   Catalog,
   SchemaDefinition,
   StateStore,
+  ComputedFunction,
 } from "@json-render/core";
 import {
   resolveElementProps,
@@ -136,6 +139,19 @@ class ElementErrorBoundary extends React.Component<
   }
 }
 
+// ---------------------------------------------------------------------------
+// FunctionsContext – provides $computed functions to the element tree
+// ---------------------------------------------------------------------------
+
+const EMPTY_FUNCTIONS: Record<string, ComputedFunction> = {};
+
+const FunctionsContext =
+  React.createContext<Record<string, ComputedFunction>>(EMPTY_FUNCTIONS);
+
+function useFunctions(): Record<string, ComputedFunction> {
+  return React.useContext(FunctionsContext);
+}
+
 interface ElementRendererProps {
   element: UIElement;
   spec: Spec;
@@ -158,21 +174,22 @@ const ElementRenderer = React.memo(function ElementRenderer({
   const repeatScope = useRepeatScope();
   const { ctx } = useVisibility();
   const { execute } = useActions();
-  const { getSnapshot } = useStateStore();
-
-  // Build context with repeat scope (used for both visibility and props)
-  const fullCtx: PropResolutionContext = useMemo(
-    () =>
-      repeatScope
-        ? {
-            ...ctx,
-            repeatItem: repeatScope.item,
-            repeatIndex: repeatScope.index,
-            repeatBasePath: repeatScope.basePath,
-          }
-        : ctx,
-    [ctx, repeatScope],
-  );
+  const { getSnapshot, state: watchState } = useStateStore();
+  const functions = useFunctions();
+
+  // Build context with repeat scope and $computed functions
+  const fullCtx: PropResolutionContext = useMemo(() => {
+    const base: PropResolutionContext = repeatScope
+      ? {
+          ...ctx,
+          repeatItem: repeatScope.item,
+          repeatIndex: repeatScope.index,
+          repeatBasePath: repeatScope.basePath,
+        }
+      : { ...ctx };
+    base.functions = functions;
+    return base;
+  }, [ctx, repeatScope, functions]);
 
   // Evaluate visibility (now supports $item/$index inside repeat scopes)
   const isVisible =
@@ -227,6 +244,85 @@ const ElementRenderer = React.memo(function ElementRenderer({
     [onBindings, emit],
   );
 
+  // Watch effect: fire actions when watched state paths change.
+  // Must be called before any early return to satisfy Rules of Hooks.
+  //
+  // Two refs serve distinct roles:
+  // - `stableWatchRef` (useMemo): holds the last emitted values object so we
+  //   can return the same reference when watched values haven't changed,
+  //   preventing the downstream useEffect from firing on unrelated state updates.
+  // - `prevWatchValues` (useEffect): tracks the previous watched-values snapshot
+  //   for change detection. Starts as `null` to skip the initial mount.
+  const watchConfig = element.watch;
+  const prevWatchValues = useRef<Record<string, unknown> | null>(null);
+  const stableWatchRef = useRef<Record<string, unknown> | undefined>(undefined);
+
+  const watchedValues = useMemo(() => {
+    if (!watchConfig) return undefined;
+    const values: Record<string, unknown> = {};
+    for (const path of Object.keys(watchConfig)) {
+      values[path] = getByPath(watchState, path);
+    }
+    const prev = stableWatchRef.current;
+    if (prev) {
+      const keys = Object.keys(values);
+      if (
+        keys.length === Object.keys(prev).length &&
+        keys.every((k) => values[k] === prev[k])
+      ) {
+        return prev;
+      }
+    }
+    stableWatchRef.current = values;
+    return values;
+  }, [watchConfig, watchState]);
+
+  useEffect(() => {
+    if (!watchConfig || !watchedValues) return;
+    const paths = Object.keys(watchConfig);
+    if (paths.length === 0) return;
+
+    const prev = prevWatchValues.current;
+    prevWatchValues.current = watchedValues;
+
+    // Skip the initial mount — only fire on changes
+    if (prev === null) return;
+
+    let cancelled = false;
+    void (async () => {
+      for (const path of paths) {
+        if (cancelled) break;
+        if (watchedValues[path] !== prev[path]) {
+          const binding = watchConfig[path];
+          if (!binding) continue;
+          const bindings = Array.isArray(binding) ? binding : [binding];
+          for (const b of bindings) {
+            if (cancelled) break;
+            if (!b.params) {
+              await execute(b);
+              if (cancelled) break;
+              continue;
+            }
+            const liveCtx: PropResolutionContext = {
+              ...fullCtx,
+              stateModel: getSnapshot(),
+            };
+            const resolved: Record<string, unknown> = {};
+            for (const [key, val] of Object.entries(b.params)) {
+              resolved[key] = resolveActionParam(val, liveCtx);
+            }
+            await execute({ ...b, params: resolved });
+            if (cancelled) break;
+          }
+        }
+      }
+    })().catch(console.error);
+
+    return () => {
+      cancelled = true;
+    };
+  }, [watchConfig, watchedValues, execute, fullCtx, getSnapshot]);
+
   // Don't render if not visible
   if (!isVisible) {
     return null;
@@ -419,6 +515,8 @@ export interface JSONUIProviderProps {
     string,
     (value: unknown, args?: Record<string, unknown>) => boolean
   >;
+  /** Named functions for `$computed` expressions in props */
+  functions?: Record<string, ComputedFunction>;
   /** Callback when state changes (uncontrolled mode) */
   onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
   children: ReactNode;
@@ -434,6 +532,7 @@ export function JSONUIProvider({
   handlers,
   navigate,
   validationFunctions,
+  functions,
   onStateChange,
   children,
 }: JSONUIProviderProps) {
@@ -444,12 +543,14 @@ export function JSONUIProvider({
       onStateChange={onStateChange}
     >
       <VisibilityProvider>
-        <ActionProvider handlers={handlers} navigate={navigate}>
-          <ValidationProvider customFunctions={validationFunctions}>
-            {children}
-            <ConfirmationDialogManager />
-          </ValidationProvider>
-        </ActionProvider>
+        <ValidationProvider customFunctions={validationFunctions}>
+          <ActionProvider handlers={handlers} navigate={navigate}>
+            <FunctionsContext.Provider value={functions ?? EMPTY_FUNCTIONS}>
+              {children}
+              <ConfirmationDialogManager />
+            </FunctionsContext.Provider>
+          </ActionProvider>
+        </ValidationProvider>
       </VisibilityProvider>
     </StateProvider>
   );
@@ -650,6 +751,8 @@ export interface CreateRendererProps {
   onAction?: (actionName: string, params?: Record<string, unknown>) => void;
   /** Callback when state changes (uncontrolled mode) */
   onStateChange?: (changes: Array<{ path: string; value: unknown }>) => void;
+  /** Named functions for `$computed` expressions in props */
+  functions?: Record<string, ComputedFunction>;
   /** Whether the spec is currently loading/streaming */
   loading?: boolean;
   /** Fallback component for unknown types */
@@ -703,6 +806,7 @@ export function createRenderer<
     state,
     onAction,
     onStateChange,
+    functions,
     loading,
     fallback,
   }: CreateRendererProps) {
@@ -730,17 +834,19 @@ export function createRenderer<
         onStateChange={onStateChange}
       >
         <VisibilityProvider>
-          <ActionProvider handlers={actionHandlers}>
-            <ValidationProvider>
-              <Renderer
-                spec={spec}
-                registry={registry}
-                loading={loading}
-                fallback={fallback}
-              />
-              <ConfirmationDialogManager />
-            </ValidationProvider>
-          </ActionProvider>
+          <ValidationProvider>
+            <ActionProvider handlers={actionHandlers}>
+              <FunctionsContext.Provider value={functions ?? EMPTY_FUNCTIONS}>
+                <Renderer
+                  spec={spec}
+                  registry={registry}
+                  loading={loading}
+                  fallback={fallback}
+                />
+                <ConfirmationDialogManager />
+              </FunctionsContext.Provider>
+            </ActionProvider>
+          </ValidationProvider>
         </VisibilityProvider>
       </StateProvider>
     );

+ 5 - 0
packages/react/src/schema.ts

@@ -67,6 +67,11 @@ export const schema = defineSchema(
         description:
           "Remove an item from an array in state by index. Params: { statePath: string, index: number }",
       },
+      {
+        name: "validateForm",
+        description:
+          "Validate all registered form fields and write the result to state. Params: { statePath?: string }. Defaults to /formValidation. Result: { valid: boolean }.",
+      },
     ],
     defaultRules: [
       // Element integrity

+ 37 - 33
packages/shadcn/src/catalog.ts

@@ -1,5 +1,21 @@
 import { z } from "zod";
 
+// =============================================================================
+// Shared validation schemas used across form components
+// =============================================================================
+
+const validationCheckSchema = z
+  .array(
+    z.object({
+      type: z.string(),
+      message: z.string(),
+      args: z.record(z.string(), z.unknown()).optional(),
+    }),
+  )
+  .nullable();
+
+const validateOnSchema = z.enum(["change", "blur", "submit"]).nullable();
+
 // =============================================================================
 // shadcn/ui Component Definitions
 // =============================================================================
@@ -272,19 +288,12 @@ export const shadcnComponentDefinitions = {
       type: z.enum(["text", "email", "password", "number"]).nullable(),
       placeholder: z.string().nullable(),
       value: z.string().nullable(),
-      checks: z
-        .array(
-          z.object({
-            type: z.string(),
-            message: z.string(),
-            args: z.record(z.string(), z.unknown()).optional(),
-          }),
-        )
-        .nullable(),
+      checks: validationCheckSchema,
+      validateOn: validateOnSchema,
     }),
     events: ["submit", "focus", "blur"],
     description:
-      "Text input field. Use { $bindState } on value for two-way binding. Use checks for validation (e.g. required, email, minLength).",
+      "Text input field. Use { $bindState } on value for two-way binding. Use checks for validation (e.g. required, email, minLength). validateOn controls timing (default: blur).",
     example: {
       label: "Email",
       name: "email",
@@ -300,18 +309,11 @@ export const shadcnComponentDefinitions = {
       placeholder: z.string().nullable(),
       rows: z.number().nullable(),
       value: z.string().nullable(),
-      checks: z
-        .array(
-          z.object({
-            type: z.string(),
-            message: z.string(),
-            args: z.record(z.string(), z.unknown()).optional(),
-          }),
-        )
-        .nullable(),
+      checks: validationCheckSchema,
+      validateOn: validateOnSchema,
     }),
     description:
-      "Multi-line text input. Use { $bindState } on value for binding. Use checks for validation.",
+      "Multi-line text input. Use { $bindState } on value for binding. Use checks for validation. validateOn controls timing (default: blur).",
   },
 
   Select: {
@@ -321,19 +323,12 @@ export const shadcnComponentDefinitions = {
       options: z.array(z.string()),
       placeholder: z.string().nullable(),
       value: z.string().nullable(),
-      checks: z
-        .array(
-          z.object({
-            type: z.string(),
-            message: z.string(),
-            args: z.record(z.string(), z.unknown()).optional(),
-          }),
-        )
-        .nullable(),
+      checks: validationCheckSchema,
+      validateOn: validateOnSchema,
     }),
     events: ["change"],
     description:
-      "Dropdown select input. Use { $bindState } on value for binding. Use checks for validation.",
+      "Dropdown select input. Use { $bindState } on value for binding. Use checks for validation. validateOn controls timing (default: change).",
   },
 
   Checkbox: {
@@ -341,9 +336,12 @@ export const shadcnComponentDefinitions = {
       label: z.string(),
       name: z.string(),
       checked: z.boolean().nullable(),
+      checks: validationCheckSchema,
+      validateOn: validateOnSchema,
     }),
     events: ["change"],
-    description: "Checkbox input. Use { $bindState } on checked for binding.",
+    description:
+      "Checkbox input. Use { $bindState } on checked for binding. Use checks for validation. validateOn controls timing (default: change).",
   },
 
   Radio: {
@@ -352,9 +350,12 @@ export const shadcnComponentDefinitions = {
       name: z.string(),
       options: z.array(z.string()),
       value: z.string().nullable(),
+      checks: validationCheckSchema,
+      validateOn: validateOnSchema,
     }),
     events: ["change"],
-    description: "Radio button group. Use { $bindState } on value for binding.",
+    description:
+      "Radio button group. Use { $bindState } on value for binding. Use checks for validation. validateOn controls timing (default: change).",
   },
 
   Switch: {
@@ -362,9 +363,12 @@ export const shadcnComponentDefinitions = {
       label: z.string(),
       name: z.string(),
       checked: z.boolean().nullable(),
+      checks: validationCheckSchema,
+      validateOn: validateOnSchema,
     }),
     events: ["change"],
-    description: "Toggle switch. Use { $bindState } on checked for binding.",
+    description:
+      "Toggle switch. Use { $bindState } on checked for binding. Use checks for validation. validateOn controls timing (default: change).",
   },
 
   Slider: {

+ 79 - 32
packages/shadcn/src/components.tsx

@@ -664,11 +664,12 @@ export const shadcnComponents = {
     const isBound = !!bindings?.value;
     const value = isBound ? (boundValue ?? "") : localValue;
     const setValue = isBound ? setBoundValue : setLocalValue;
+    const validateOn = props.validateOn ?? "blur";
 
     const hasValidation = !!(bindings?.value && props.checks?.length);
     const { errors, validate } = useFieldValidation(
       bindings?.value ?? "",
-      hasValidation ? { checks: props.checks ?? [] } : undefined,
+      hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
     );
 
     return (
@@ -682,13 +683,16 @@ export const shadcnComponents = {
           type={props.type ?? "text"}
           placeholder={props.placeholder ?? ""}
           value={value}
-          onChange={(e) => setValue(e.target.value)}
+          onChange={(e) => {
+            setValue(e.target.value);
+            if (hasValidation && validateOn === "change") validate();
+          }}
           onKeyDown={(e) => {
             if (e.key === "Enter") emit("submit");
           }}
           onFocus={() => emit("focus")}
           onBlur={() => {
-            if (hasValidation) validate();
+            if (hasValidation && validateOn === "blur") validate();
             emit("blur");
           }}
         />
@@ -711,11 +715,12 @@ export const shadcnComponents = {
     const isBound = !!bindings?.value;
     const value = isBound ? (boundValue ?? "") : localValue;
     const setValue = isBound ? setBoundValue : setLocalValue;
+    const validateOn = props.validateOn ?? "blur";
 
     const hasValidation = !!(bindings?.value && props.checks?.length);
     const { errors, validate } = useFieldValidation(
       bindings?.value ?? "",
-      hasValidation ? { checks: props.checks ?? [] } : undefined,
+      hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
     );
 
     return (
@@ -729,9 +734,12 @@ export const shadcnComponents = {
           placeholder={props.placeholder ?? ""}
           rows={props.rows ?? 3}
           value={value}
-          onChange={(e) => setValue(e.target.value)}
+          onChange={(e) => {
+            setValue(e.target.value);
+            if (hasValidation && validateOn === "change") validate();
+          }}
           onBlur={() => {
-            if (hasValidation) validate();
+            if (hasValidation && validateOn === "blur") validate();
           }}
         />
         {errors.length > 0 && (
@@ -758,11 +766,12 @@ export const shadcnComponents = {
     const options = rawOptions.map((opt) =>
       typeof opt === "string" ? opt : String(opt ?? ""),
     );
+    const validateOn = props.validateOn ?? "change";
 
     const hasValidation = !!(bindings?.value && props.checks?.length);
     const { errors, validate } = useFieldValidation(
       bindings?.value ?? "",
-      hasValidation ? { checks: props.checks ?? [] } : undefined,
+      hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
     );
 
     return (
@@ -772,7 +781,8 @@ export const shadcnComponents = {
           value={value}
           onValueChange={(v) => {
             setValue(v);
-            if (hasValidation) validate();
+            // Select has no native blur event, so only validate on "change"
+            if (hasValidation && validateOn === "change") validate();
             emit("change");
           }}
         >
@@ -808,19 +818,32 @@ export const shadcnComponents = {
     const checked = isBound ? (boundChecked ?? false) : localChecked;
     const setChecked = isBound ? setBoundChecked : setLocalChecked;
 
+    const validateOn = props.validateOn ?? "change";
+    const hasValidation = !!(bindings?.checked && props.checks?.length);
+    const { errors, validate } = useFieldValidation(
+      bindings?.checked ?? "",
+      hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
+    );
+
     return (
-      <div className="flex items-center space-x-2">
-        <Checkbox
-          id={props.name ?? undefined}
-          checked={checked}
-          onCheckedChange={(c) => {
-            setChecked(c === true);
-            emit("change");
-          }}
-        />
-        <Label htmlFor={props.name ?? undefined} className="cursor-pointer">
-          {props.label}
-        </Label>
+      <div className="space-y-1">
+        <div className="flex items-center space-x-2">
+          <Checkbox
+            id={props.name ?? undefined}
+            checked={checked}
+            onCheckedChange={(c) => {
+              setChecked(c === true);
+              if (hasValidation && validateOn === "change") validate();
+              emit("change");
+            }}
+          />
+          <Label htmlFor={props.name ?? undefined} className="cursor-pointer">
+            {props.label}
+          </Label>
+        </div>
+        {errors.length > 0 && (
+          <p className="text-sm text-destructive">{errors[0]}</p>
+        )}
       </div>
     );
   },
@@ -843,6 +866,13 @@ export const shadcnComponents = {
     const value = isBound ? (boundValue ?? "") : localValue;
     const setValue = isBound ? setBoundValue : setLocalValue;
 
+    const validateOn = props.validateOn ?? "change";
+    const hasValidation = !!(bindings?.value && props.checks?.length);
+    const { errors, validate } = useFieldValidation(
+      bindings?.value ?? "",
+      hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
+    );
+
     return (
       <div className="space-y-2">
         {props.label && <Label>{props.label}</Label>}
@@ -850,6 +880,7 @@ export const shadcnComponents = {
           value={value}
           onValueChange={(v) => {
             setValue(v);
+            if (hasValidation && validateOn === "change") validate();
             emit("change");
           }}
         >
@@ -868,6 +899,9 @@ export const shadcnComponents = {
             </div>
           ))}
         </RadioGroup>
+        {errors.length > 0 && (
+          <p className="text-sm text-destructive">{errors[0]}</p>
+        )}
       </div>
     );
   },
@@ -886,19 +920,32 @@ export const shadcnComponents = {
     const checked = isBound ? (boundChecked ?? false) : localChecked;
     const setChecked = isBound ? setBoundChecked : setLocalChecked;
 
+    const validateOn = props.validateOn ?? "change";
+    const hasValidation = !!(bindings?.checked && props.checks?.length);
+    const { errors, validate } = useFieldValidation(
+      bindings?.checked ?? "",
+      hasValidation ? { checks: props.checks ?? [], validateOn } : undefined,
+    );
+
     return (
-      <div className="flex items-center justify-between space-x-2">
-        <Label htmlFor={props.name ?? undefined} className="cursor-pointer">
-          {props.label}
-        </Label>
-        <Switch
-          id={props.name ?? undefined}
-          checked={checked}
-          onCheckedChange={(c) => {
-            setChecked(c);
-            emit("change");
-          }}
-        />
+      <div className="space-y-1">
+        <div className="flex items-center justify-between space-x-2">
+          <Label htmlFor={props.name ?? undefined} className="cursor-pointer">
+            {props.label}
+          </Label>
+          <Switch
+            id={props.name ?? undefined}
+            checked={checked}
+            onCheckedChange={(c) => {
+              setChecked(c);
+              if (hasValidation && validateOn === "change") validate();
+              emit("change");
+            }}
+          />
+        </div>
+        {errors.length > 0 && (
+          <p className="text-sm text-destructive">{errors[0]}</p>
+        )}
       </div>
     );
   },