Skip to content

Form engine: keep Jotai as the single source of truth, drop the TanStack Form mirror #244

Description

@sneridagh

Summary

Aurora's schema-driven forms are built on @tanstack/react-form. We chose it from the start to avoid Volto's problem: a change to any field re-rendered the whole form ("the dev tools light up like a Christmas tree"). Since then we also adopted a hard requirement: the form data lives in one Jotai atom, the central source of truth. That way fields, handlers, blocks (Plate) and add-ons can read and change any part of the current form without going through the form library.

Taken together, those two decisions now mean we keep two stores holding the same data, plus a per-field sync layer between them. This issue reviews that setup on main (ca58740) and proposes a way forward.

Short version: keeping Jotai as the source of truth is sound and we should keep it. In the content form, TanStack Form has become a mirror: we submit the atom, there are no validators, and dirty/touched state is never read. Keeping the mirror in sync is where the bugs are, including an infinite render loop. Proposal: fix the bugs now, then replace TanStack with a thin Jotai-native field layer.

Related: #243 (widget contract: what widgets receive and emit). The two are independent: the widget contract doesn't depend on which form engine is underneath.


How it works today

There are three forms, and each keeps its data differently:

Form Where the data lives What gets submitted How it stays in sync
Content add/edit (ContentForm.tsx) formAtom in a Jotai store created per form (createStore + Provider), plus TanStack the atom: store.get(formAtom) Each mounted field copies changes both ways (AtomField)
Control panels (controlpanel.tsx) a module-level atom in Jotai's global store (no Provider), plus TanStack TanStack's values (onSubmit({ value })) Same per-field copying
Block settings (BlockSettingsForm.tsx) the Plate node data — (written to the node with setNodes) TanStack is a mirror, reset with form.reset() when a deep-equality check fails

What the content form actually uses from TanStack:

  • the field context (form.AppField / field.Quanta);
  • handleChange;
  • handleSubmit, which just calls onSubmit, and that reads the atom.

Nothing in the repo configures validators, so field.state.meta.errors is always empty. isDirty, isTouched, canSubmit and array fields are not used.

Version: the ^1.3.3 range resolves to 1.23.8 in the lockfile. The latest is 1.33.5, and 2.0 has been in alpha since August.


Is "Jotai as the central source of truth" sound? Yes

  • focusAtom (jotai-optics) gives each field its own subscription. Typing in one field re-renders that field only, which is the original goal.
  • Code outside the form can read and change form data without knowing about it. Plate's title binding writes title through the formAtom registry utility. The blocks editor writes blocks. Sibling-field side effects (Define and enforce the form widget contract (forms ↔ widgets) #243) can do the same.
  • The per-form store in ContentForm is the right pattern.

Issues to fix:

  1. Whole-atom subscribers. BlocksEditor subscribes to the whole formAtom to read title and @id. It re-renders the Plate host on every keystroke, both in Plate itself (which writes blocks) and in any metadata field. RecurrenceWidget does the same. These are the remaining "Christmas tree" sources.
  2. Values are loaded into the atom only once. InitAtoms uses useHydrateAtoms, which sets each atom once per store and ignores later values. edit.tsx doesn't key ContentForm by @id, so going straight from one item's edit form to another's keeps the first item's data in both the atom and TanStack (defaultValues only applies on mount).
  3. One atom type for every form. formAtom is typed Content and only works for the content form. Control panels and block settings declare their own atoms. Everything that wants to reach "the current form" goes through a registry utility that only knows about the content form's atom.

Is the form ↔ store sync sound? No

  1. 🐞 It can loop forever. The control panel atom lives in Jotai's global store and is only set once, so on the second control panel opened in a session the atom still holds the first panel's data. For a field that isn't in that data:

    • AtomField sees undefined in the atom and copies it into TanStack (Field.tsx#L214);
    • TanStack's FieldApi.update() sees undefined and puts the field's default value back;
    • the effect runs again, and so on.

    The result is Maximum update depth exceeded. If both panels share some field names, those fields show the first panel's values instead, and since control panels submit TanStack's values, saving can write wrong data. I reproduced this with a unit test that copies the route's wiring; it still needs confirming in the browser. The same loop happens whenever any writer leaves undefined in the atom for a field that has a default.

  2. The sync only covers fields that are on screen. The Content tab isn't rendered while you're on the Blocks tab, so TanStack's copy goes stale there. For example, the title edited in Plate's title block only reaches the atom. This is harmless today only because the content form submits the atom. Any validation or dirty check added on top of TanStack would run against stale values.

  3. Two submission paths. The content form submits the atom, while control panels submit TanStack's values.

  4. A third pattern in block settings. Each change calls both field.handleChange and onFormDataChange (→ Plate setNodes → new formData prop). The form is then reset() whenever the deep-equality check fails.

Was TanStack Form the right choice?

For the re-render goal, it delivers. useForm doesn't subscribe the component that owns the form, and each AppField re-renders only for its own slice. But once Jotai is the source of truth, TanStack becomes a second copy of the data. TanStack can't sit on an external store, so this mirroring is unavoidable as long as both stay. And the features that would justify it beyond re-render control aren't used: Standard Schema / async validation, touched/dirty state, array helpers.

Option Assessment
Keep TanStack, upgrade to 1.33, make the sync one-way Works. Replace the per-field effects with one store-level subscription (store.sub(formAtom, …) → form.setFieldValue for changed keys), and only ever submit the atom. We still keep two copies, plus a sync layer to maintain.
React Hook Form Not a fit. It keeps values in its own uncontrolled, ref-based store, so we'd still have two copies of the data, and it suits Plate and complex controlled widgets worse.
Jotai-native field layer (proposed) One source of truth, no sync. Most of the building blocks already exist in @plone/helpers (useFieldFocusedAtom & co.). Per-field re-renders come from focused atoms, exactly like today.

What a Jotai-native layer needs, all of it small:

  • createFormStore<T>(initial): one Jotai store and a typed root atom per form instance, provided through context and re-created when the form's identity (@id / panel id) changes. This replaces both the module-level formAtom and the global-store usage.
  • useSchemaField(name) → { value, onChange, onBlur, meta: { touched, dirty, errors } }, with value read and written through focused atoms. The extra per-field state (touched, dirty, errors) lives in its own per-field atoms.
  • Validation: sync validators generated from the JSON schema (required, min/max, min/max length, pattern), plus mapping the errors plone.restapi returns after a save onto the fields. Async/debounced validation is the main thing TanStack gives us that we'd write ourselves, and Plone forms rarely need it.
  • Submit state: isSubmitting and a canSubmit derived from the error atoms.
  • Code outside the form keeps writing to the root atom (or to focused atoms) as it does today. The hook gives fields that access in a cleaner form.

The field renderer and the widget contract from #243 sit on top of useSchemaField, so widgets are not affected by the engine change.


Plan

  • 1. Bug fix: control panels get their own form store. createStore + Provider per panel (like ContentForm), and key the route component by panel id. Add a regression test that renders two panels in a row.
  • 2. Remount forms per item. Key ContentForm by @id in edit.tsx. Same idea for the add route (by type and container).
  • 3. Remove whole-atom subscriptions. In BlocksEditor (title, @id) and RecurrenceWidget (start, end), switch to focused atoms or selectAtom.
  • 4. Make submission consistent. Every form submits the store's value. Until step 6 lands, make the sync one-way (store → TanStack) through a single subscription, so the loop can't happen even for writers that leave undefined.
  • 5. createFormStore<T> and useSchemaField in @plone/helpers (or @plone/cmsui), with unit tests covering:
    • per-field re-renders (render-count assertions);
    • writes from outside the form reaching the right field;
    • field state (touched, dirty, errors);
    • re-creating the store when the form's identity changes.
  • 6. Migrate the three forms off TanStack, through the shared field renderer from Define and enforce the form widget contract (forms ↔ widgets) #243 step 2. Remove @tanstack/react-form from cmsui and helpers. DeepKeys/DeepValue currently come from TanStack; replace them with a local type helper.
  • 7. Schema validation: JSON schema → validators, plus mapping the server's errors after a save onto the fields. This also unblocks showing errors at all (Define and enforce the form widget contract (forms ↔ widgets) #243 A4).

Steps 1–3 are independent bug and performance fixes and can land right away. Step 4 is only needed if 5–6 take a while. Steps 5–6 are the actual engine change.

If we decide to keep TanStack instead, steps 1–4 still apply. Then upgrade to the latest 1.x and revisit when 2.0 is stable.

Activity

  1. sneridagh commented on Oct 8, 2026

    @sneridagh
    MemberAuthor
  2. sneridagh commented on Oct 9, 2026

    @sneridagh
    MemberAuthor

    Draft API for steps 5–7 (Jotai-native form layer)

    A proposal for review before any code. Steps 1–3 are in #247 and #248. The field renderer this plugs into (SchemaFieldsets, buildWidgetProps) is in #251 (#243 step 2).

    Goals

    • One source of truth: each form instance has a Jotai store and a root values atom. No second copy of the data and no sync.
    • Per-field subscriptions: a field re-renders only when its own value or state changes, like today.
    • Access from outside the form: Plate, sibling-field side effects and add-ons read and write any path of the current form through a context hook. No module-level atoms and no registry utility.
    • One API for the content form, control panels and block settings, including nested paths such as settings.caption and items.0.label.
    • No TanStack types. DeepKeys / DeepValue are replaced by our own Path<T> / PathValue<T, P>.

    Creating a form

    const form = useFormStore<Content>({
      // The form's identity. A new key creates a fresh store, so moving to
      // another item or panel never keeps the previous values.
      key: content['@id'],
      initialValues: content,
      // Optional: validators are generated from the JSON schema
      // (required, minLength/maxLength, minimum/maximum, pattern).
      schema,
      // Optional: extra validators per path.
      validators: {
        end: (end, values) => (end < values.start ? t('End must be after start') : undefined),
      },
      // Optional: called with each new values object.
      // Block settings use it to write back to the Plate node.
      onValuesChange: (values) => {},
      onSubmit: async (values) => {
        // Return server errors by path to show them on the fields.
        const result = await save(values);
        return result.ok ? undefined : { title: 'Required input is missing.' };
      },
    });
    
    return <FormProvider form={form}>{/* fields, toolbar, Plate… */}</FormProvider>;

    useFormStore creates the store with useMemo keyed by key. It replaces useAppForm, InitAtoms / useHydrateAtoms and the module-level formAtom.

    Reading and writing from anywhere inside the form

    // One field: value, setter and state. Used by `SchemaField`.
    const field = useSchemaField<string>('title');
    field.value;            // subscribes to this path only
    field.onChange(next);   // writes to the store; marks the field dirty
    field.onBlur();         // marks the field touched
    field.meta;             // { touched, dirty, errors, invalid }
    
    // Read-only, or write-only, access to one path (no meta).
    const title = useFieldValue<string>('title');
    const setTitle = useSetFieldValue<string>('title');
    
    // The form API, for code outside a field: Plate bindings, side effects,
    // toolbar buttons, add-ons.
    const form = useFormContext<Content>();
    form.getValues();                       // read without subscribing
    form.getFieldValue('blocks');
    form.setFieldValue('image_scales', scales);
    form.reset(nextValues?);
    form.submit();
    
    // Form-level state, for the save button.
    const { isSubmitting, isDirty } = useFormState();

    useFormContext() replaces config.getUtility({ name: 'formAtom', type: 'atom' }) in Plate's metadata title binding. useFieldValue('title') replaces the useFormFieldValue helper added in #248.

    How the state is stored

    • valuesAtom: the root atom holding the whole values object.
    • fieldAtom(path): a derived atom that reads and writes one path, created on demand and cached per form. It only notifies when that path's value changes (Object.is), which is what keeps re-renders per field. Writes rebuild only the objects along the path.
    • touchedAtom(path) and serverErrorsAtom(path): plain atoms per path.
    • dirty(path): derived. Compares the path's value with initialValues (deep equality).
    • errors(path): derived. It runs the path's validators on its value (and on other values if the validator reads them) and adds any server error. Errors are never stored, so they can't get out of sync.
    • meta.errors only lists errors once the field was touched or the form was submitted. Before that, a required field isn't shown as invalid.
    • isSubmitting: a plain atom. isDirty: derived from the dirty fields.

    Submitting

    form.submit():

    1. marks every schema field as touched;
    2. collects the errors of all schema fields;
    3. if there are any, focuses the first invalid field and stops;
    4. otherwise sets isSubmitting, calls onSubmit(values) and maps the returned server errors onto the fields. A server error is cleared as soon as its field changes.

    plone.restapi returns validation errors as a 400 response whose message is a list of { field, message }. A small helper turns that into the { path: message } map that onSubmit returns.

    The three forms

    Form key initialValues Writes / submits
    Content add/edit @id (add: path + type) content (add: schema defaults) onSubmit → fetcher.submit(values)
    Control panel panel id controlpanel.data onSubmit → fetcher.submit(values)
    Block settings block id the Plate node's data onValuesChange → setNodes; form.reset(data) when the node changes outside the form (e.g. undo)

    Block settings keep running their schema onChangeSideEffects the same way, now through form.setFieldValue.

    Where it lives

    In @plone/helpers, next to the existing atom helpers that Plate already uses. It contains only React and Jotai, with no UI. SchemaField and SchemaFieldsets stay in @plone/cmsui.

    PRs

    1. Form layer (@plone/helpers): useFormStore, FormProvider, useFormContext, useSchemaField, useFieldValue, useSetFieldValue, useFormState, Path / PathValue. Unit tests, including render-count tests for per-field updates.

    2. Migration (@plone/cmsui, @plone/plate):

      • SchemaField uses useSchemaField;
      • the three forms use useFormStore;
      • Plate's title binding uses useFormContext;
      • remove @tanstack/react-form, routes/atoms.ts' formAtom, the formAtom registry utility and InitAtoms.

      Covered by the existing acceptance tests plus Give each form its own store and remount it per item #247's and Render every schema form through one field renderer #251's.

    3. Validation: schema validators, showing errors after touch or submit, server error mapping. This finally makes invalid / errorMessage in the widget contract carry real errors (Define and enforce the form widget contract (forms ↔ widgets) #243 A4).

    Questions

    1. Package: @plone/helpers, or a new small @plone/form? A separate package makes the API easier to document and to version, at the cost of one more package.
    2. When errors show: after blur (touched), or only after the first submit attempt? The proposal is after touch or submit. That's the common pattern and avoids marking an untouched required field as invalid.
    3. Live canSubmit: should the save button stay disabled while there are errors? Computing it live means subscribing to every field's errors. The proposal is no: submit() validates and focuses the first invalid field instead.
    4. The formAtom registry utility: remove it in step 2 (Aurora is still in alpha), or keep it deprecated for one release?
  3. sneridagh commented on Oct 9, 2026

    @sneridagh
    MemberAuthor

    Review guide

    Every step of this issue is now in a PR. Steps 1–3 are merged; the rest is a stack of PRs, each targeting the branch of the one before. The stack also carries #243's first steps (the widget contract), because the new form engine plugs into the shared field renderer.

    Status

    Step What PR State
    1–2 One store per form, remount per item, control panels submit the store #247 ✅ merged
    3 Subscribe only to the fields that are read (BlocksEditor 46 → 0 renders while typing) #248 ✅ merged
    4 One-way sync as a stopgap — ⏭️ skipped: no longer needed, since steps 5–6 remove the sync
    (#243 step 1) Widget contract types and conceptual guide #97 ✅ merged
    (#243 step 2) One shared field renderer for all schema forms #251 ✅ merged
    5 Jotai-native form layer in @plone/helpers #252 ✅ merged
    6 Forms moved onto it; formAtom utility and useAppForm removed #253 ✅ merged
    6 Recurrence modal moved; @tanstack/react-form removed #254 ✅ merged
    7 Schema validation (registry validators, server errors) #256 ✅ merged
    (#243 step 4) Adapters for the remaining controls; Quanta pickers renamed; upgrade guide for the whole stack #260 🟡 open, base main
    (#243 step 5) Widget context instead of the route; fixes the Image add form (#163); core widgets reference #261 🟡 open, stacked on #260
    (#243 step 6) Typed widget registry (opt-in per app) and contract test helper #262 🟡 open, stacked on #261
    (#243 step 7) Missing core widgets: select (choices widget), tokens, number, file, email/password/URL; file and image fields upload files; closes #243 #263 🟡 open, stacked on #262

    Related and already merged: #249 (widget lookups scoped to their category, #243 step 3), #250 (hydration in the sharing tests), #255 (recurrence modal crash on days 1–9).

    All branches are up to date with main (as of #255). With the whole stack applied, the full acceptance suite passes locally: 209/209.

    Merge order

    #97 → #251 → #252 → #253 → #254 → #256 (merged) → #260 → #261 → #262 → #263

    After each merge, retarget the next PR to main (GitHub offers this when the base branch is deleted). Every PR has towncrier fragments for the packages it touches.

    What to look at in each PR

    #97, Document and type the form widget contract (+447 −17, 10 files)

    • Mostly docs: the "Form fields, controls, widgets, and adapters" guide, with a diagram, worked examples and a glossary (it answers @stevepiercy's review).
    • FormWidgetProps / FormWidget in @plone/types, and onBlur wired through Field.tsx.
    • Review focus: whether the vocabulary (field, control, widget, adapter) reads clearly. The glossary terms could later move to the Plone 6 docs glossary.

    #251, One shared field renderer (+484 −283, 19 files)

    • SchemaFieldsets + buildWidgetProps(). The three forms stop spreading the raw schema into widget props.
    • Breaking for add-on widgets:
      • invalid + one errorMessage instead of error;
      • defaultValue is the schema default, not the current value;
      • raw schema keys the form understands (type, title, factory…) are no longer props. Any other key is still passed through as a widget option, and the whole schema is available as schema.
    • Review focus: the rule for which schema keys are reserved (RESERVED_SCHEMA_KEYS in Field.tsx), and the ObjectBrowserWidget change (it used defaultValue as its current value; there's an acceptance test for that).

    #252, Form layer in @plone/helpers (+827 −3, 14 files; no consumers yet)

    • createFormStore (plain Jotai) plus React bindings: useFormStore, FormProvider, useFormContext, useSchemaField, useFieldValue, useFormState.
    • Review focus:
      • metaAtom / stateAtom are stabilized, so a field only re-renders when its own state changes (render-count tests in react.test.tsx);
      • FormProvider doesn't install a Jotai <Provider>: the hooks pass the form's store explicitly;
      • @plone/helpers' AGENTS.md now allows React in its two binding files.

    #253, Forms moved onto the form layer (+423 −611, 29 files)

    • The content form, control panels, block settings, BlocksEditor and Plate's title binding.
    • Removed: components/Form/Form.tsx, routes/atoms.ts, and the formAtom registry utility (Plate now uses useOptionalFormContext from helpers, which sits below both packages).
    • Review focus:
      • block settings now write a change and its onChangeSideEffects in one setNodes;
      • the sidebar toggle and the sidebar now share one store (they didn't before).

    #254, Recurrence modal off TanStack Form (+159 −345, 14 files)

    • A mostly mechanical conversion of 16 AppField blocks and 3 Subscribe blocks. The modal's form is deliberately not provided as the current form, so UntilEndField still reads the event's end.
    • After this PR, @tanstack/react-form is gone from the lockfile.

    #256, Schema validation (+1218 −61, 26 files)

    • Keeps Volto's validator architecture (registry validator utilities matched by fieldType / widget / format / behaviorName + fieldName / blockType + fieldName), with i18next t instead of formatMessage.
    • ValidatorUtilityArgs in @plone/types gains t and fieldName and keeps formatMessage, so Volto-typed validators still type-check (covered by a compatibility test).
    • An invalid save switches to the Content tab and focuses the first invalid field. plone.restapi's 400 errors come back onto their fields.
    • Adds TextWidget and DateTimeWidget adapters, so errors and the required marker are visible (an early part of Define and enforce the form widget contract (forms ↔ widgets) #243 step 4).
    • Review focus: the parser for plone.restapi's Python-repr error messages, and the built-in validators ported from Volto.

    Things to know

    Trying the stack locally

    git fetch origin && git switch feat/schema-validation
    pnpm install && pnpm build:deps
    make ci-acceptance-backend-start   # in one terminal
    make acceptance-frontend-dev-start # in another
    pnpm exec playwright test --config=playwright.config.ts packages/cmsui/acceptance/tests

    What's next

    #243 steps 4–7:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions