Repository navigation
Form engine: keep Jotai as the single source of truth, drop the TanStack Form mirror #244
Description
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 callsonSubmit, 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
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. - The per-form store in
ContentFormis the right pattern.
Issues to fix:
- Whole-atom subscribers.
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. - Values are loaded into the atom only once.
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). - One atom type for every form.
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);- TanStack's
FieldApi.update()seesundefinedand 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 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. 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-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.- 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:
isSubmittingand acanSubmitderived 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+Providerper panel (likeContentForm), 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
ContentFormby@idinedit.tsx. Same idea for the add route (by type and container). - 3. Remove whole-atom subscriptions. In
BlocksEditor(title,@id) andRecurrenceWidget(start,end), switch to focused atoms orselectAtom. - 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>anduseSchemaFieldin@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-formfromcmsuiandhelpers.DeepKeys/DeepValuecurrently 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
sneridagh commentedon Oct 8, 2026
/cc @pnicolli
Cover the control panel store fix with an acceptance test
sneridagh commentedon 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
settings.captionanditems.0.label.DeepKeys/DeepValueare replaced by our ownPath<T>/PathValue<T, P>.Creating a form
useFormStorecreates the store withuseMemokeyed bykey. It replacesuseAppForm,InitAtoms/useHydrateAtomsand the module-levelformAtom.Reading and writing from anywhere inside the form
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():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
@id(add: path + type)onSubmit→fetcher.submit(values)controlpanel.dataonSubmit→fetcher.submit(values)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;useFormStore;useFormContext;@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
@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.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.formAtomregistry utility: remove it in step 2 (Aurora is still in alpha), or keep it deprecated for one release?Give each form its own store and remount it per item (#247)
Subscribe only to the form fields that are read (#248)
sneridagh commentedon Oct 9, 2026
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
BlocksEditor46 → 0 renders while typing)@plone/helpersformAtomutility anduseAppFormremoved@tanstack/react-formremovedmainRelated 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)
FormWidgetProps/FormWidgetin@plone/types, andonBlurwired throughField.tsx.#251, One shared field renderer (+484 −283, 19 files)
SchemaFieldsets+buildWidgetProps(). The three forms stop spreading the raw schema into widget props.invalid+ oneerrorMessageinstead oferror;defaultValueis the schema default, not the current value;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.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.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)
BlocksEditorand Plate's title binding.components/Form/Form.tsx,routes/atoms.ts, and theformAtomregistry utility (Plate now usesuseOptionalFormContextfrom helpers, which sits below both packages).onChangeSideEffectsin onesetNodes;#254, Recurrence modal off TanStack Form (+159 −345, 14 files)
AppFieldblocks and 3Subscribeblocks. The modal's form is deliberately not provided as the current form, soUntilEndFieldstill reads the event'send.@tanstack/react-formis gone from the lockfile.#256, Schema validation (+1218 −61, 26 files)
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).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).Things to know
enlocale;de/itfall back to English.default_languagevalidator only applies once a panel setsformat: 'default_language'.Trying the stack locally
What's next
#243 steps 4–7:
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);Add a Jotai-native form layer to helpers (#252)
Move the forms onto the helpers form store (#253)
Move the recurrence modal off TanStack Form (#254)
Validate schema-driven forms (#256)
Add adapters for the remaining registered controls (#260)