Repository navigation
Form engine: keep Jotai as the single source of truth, drop the TanStack Form mirror #244
Description
Activity
- added04 type: enhancementmaking existing stuff bettermaking existing stuff better
on Oct 8, 2026 /cc @pnicolli
- added a commit that references this issue
on Oct 9, 2026 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.captionanditems.0.label. - No TanStack types.
DeepKeys/DeepValueare replaced by our ownPath<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>;
useFormStorecreates the store withuseMemokeyed bykey. It replacesuseAppForm,InitAtoms/useHydrateAtomsand the module-levelformAtom.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()replacesconfig.getUtility({ name: 'formAtom', type: 'atom' })in Plate's metadata title binding.useFieldValue('title')replaces theuseFormFieldValuehelper 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)andserverErrorsAtom(path): plain atoms per path.dirty(path): derived. Compares the path's value withinitialValues(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.errorsonly 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():- marks every schema field as touched;
- collects the errors of all schema fields;
- if there are any, focuses the first invalid field and stops;
- otherwise sets
isSubmitting, callsonSubmit(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
messageis a list of{ field, message }. A small helper turns that into the{ path: message }map thatonSubmitreturns.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.dataonSubmit→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
onChangeSideEffectsthe same way, now throughform.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.SchemaFieldandSchemaFieldsetsstay in@plone/cmsui.PRs
-
Form layer (
@plone/helpers):useFormStore,FormProvider,useFormContext,useSchemaField,useFieldValue,useSetFieldValue,useFormState,Path/PathValue. Unit tests, including render-count tests for per-field updates. -
Migration (
@plone/cmsui,@plone/plate):SchemaFieldusesuseSchemaField;- the three forms use
useFormStore; - Plate's title binding uses
useFormContext; - remove
@tanstack/react-form,routes/atoms.ts'formAtom, theformAtomregistry utility andInitAtoms.
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.
-
Validation: schema validators, showing errors after touch or submit, server error mapping. This finally makes
invalid/errorMessagein the widget contract carry real errors (Define and enforce the form widget contract (forms ↔ widgets) #243 A4).
Questions
- 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. - 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.
- 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. - The
formAtomregistry utility: remove it in step 2 (Aurora is still in alpha), or keep it deprecated for one release?
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 ( BlocksEditor46 → 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; formAtomutility anduseAppFormremoved#253 ✅ merged 6 Recurrence modal moved; @tanstack/react-formremoved#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 → #263After 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/FormWidgetin@plone/types, andonBlurwired throughField.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+ oneerrorMessageinstead oferror;defaultValueis 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 asschema.
- Review focus: the rule for which schema keys are reserved (
RESERVED_SCHEMA_KEYSinField.tsx), and theObjectBrowserWidgetchange (it useddefaultValueas 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/stateAtomare stabilized, so a field only re-renders when its own state changes (render-count tests inreact.test.tsx);FormProviderdoesn'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,
BlocksEditorand Plate's title binding. - Removed:
components/Form/Form.tsx,routes/atoms.ts, and theformAtomregistry utility (Plate now usesuseOptionalFormContextfrom helpers, which sits below both packages). - Review focus:
- block settings now write a change and its
onChangeSideEffectsin onesetNodes; - the sidebar toggle and the sidebar now share one store (they didn't before).
- block settings now write a change and its
#254, Recurrence modal off TanStack Form (+159 −345, 14 files)
- A mostly mechanical conversion of 16
AppFieldblocks and 3Subscribeblocks. The modal's form is deliberately not provided as the current form, soUntilEndFieldstill reads the event'send. - After this PR,
@tanstack/react-formis gone from the lockfile.
#256, Schema validation (+1218 −61, 26 files)
- Keeps Volto's validator architecture (registry
validatorutilities matched byfieldType/widget/format/behaviorName+fieldName/blockType+fieldName), with i18nexttinstead offormatMessage. ValidatorUtilityArgsin@plone/typesgainstandfieldNameand keepsformatMessage, 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
TextWidgetandDateTimeWidgetadapters, 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
- Visual regression: the baselines will likely need regenerating with the "Update VRT Screenshots" workflow. Render every schema form through one field renderer #251 removes the forced "Type something..." placeholder, and Validate schema-driven forms #256 shows the required marker.
- Translations: the validation messages are only in the
enlocale;de/itfall back to English. - Known gaps:
- the recurrence widget's buttons have no accessible names (to fix with Define and enforce the form widget contract (forms ↔ widgets) #243 step 5);
- the
default_languagevalidator only applies once a panel setsformat: 'default_language'.
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:
- the remaining widget adapters;
- a widget context, so
ObjectBrowserWidgetandRecurrenceWidgetstop depending on the edit route (this also fixes the Image add form, Add Image Content tab crashes because the image field is rendered as a TextField #163); - a typed widget registry and a contract test helper;
- the missing widgets (select, number, tags, file).
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:
formAtomin a Jotai store created per form (createStore+Provider), plus TanStackstore.get(formAtom)AtomField)Provider), plus TanStackonSubmit({ value }))setNodes)form.reset()when a deep-equality check failsWhat the content form actually uses from TanStack:
form.AppField/field.Quanta);handleChange;handleSubmit, which just callsonSubmit, and that reads the atom.Nothing in the repo configures validators, so
field.state.meta.errorsis always empty.isDirty,isTouched,canSubmitand array fields are not used.Version: the
^1.3.3range 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.titlethrough theformAtomregistry utility. The blocks editor writesblocks. Sibling-field side effects (Define and enforce the form widget contract (forms ↔ widgets) #243) can do the same.ContentFormis the right pattern.Issues to fix:
BlocksEditorsubscribes to the wholeformAtomto readtitleand@id. It re-renders the Plate host on every keystroke, both in Plate itself (which writesblocks) and in any metadata field.RecurrenceWidgetdoes the same. These are the remaining "Christmas tree" sources.InitAtomsusesuseHydrateAtoms, which sets each atom once per store and ignores later values.edit.tsxdoesn't keyContentFormby@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 (defaultValuesonly applies on mount).formAtomis typedContentand 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
🐞 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:
AtomFieldseesundefinedin the atom and copies it into TanStack (Field.tsx#L214);FieldApi.update()seesundefinedand puts the field's default value back;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 leavesundefinedin the atom for a field that has a default.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.
Two submission paths. The content form submits the atom, while control panels submit TanStack's values.
A third pattern in block settings. Each change calls both
field.handleChangeandonFormDataChange(→ PlatesetNodes→ newformDataprop). The form is thenreset()whenever the deep-equality check fails.Was TanStack Form the right choice?
For the re-render goal, it delivers.
useFormdoesn't subscribe the component that owns the form, and eachAppFieldre-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.store.sub(formAtom, …)→form.setFieldValuefor changed keys), and only ever submit the atom. We still keep two copies, plus a sync layer to maintain.@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-levelformAtomand 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.isSubmittingand acanSubmitderived from the error atoms.The field renderer and the widget contract from #243 sit on top of
useSchemaField, so widgets are not affected by the engine change.Plan
createStore+Providerper panel (likeContentForm), and key the route component by panel id. Add a regression test that renders two panels in a row.ContentFormby@idinedit.tsx. Same idea for the add route (by type and container).BlocksEditor(title,@id) andRecurrenceWidget(start,end), switch to focused atoms orselectAtom.undefined.createFormStore<T>anduseSchemaFieldin@plone/helpers(or@plone/cmsui), with unit tests covering:@tanstack/react-formfromcmsuiandhelpers.DeepKeys/DeepValuecurrently come from TanStack; replace them with a local type helper.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.