ADR-0004: Component state hygiene & shared form logic
- Status: Accepted
- Date: 2026-05-31
- Scope: core (any frontend reusing stateful components)
Context
Edit/create modals are reused across entities. They used to also persist between
opens, because they were rendered unconditionally with isOpen merely hiding them.
That’s fine — until a component holds local state that doesn’t track the entity it’s
showing. Two real bugs in Expenses came from this:
InvoiceCyclePickerkept auseStatetoggle set by a write-once effect
(only evertrue) → the “charge on a different invoice” toggle leaked from a
deferred expense onto the next non-deferred one.- An admin
EditProfileModalinitialised its formuseStateonce from the
userprop with no reset → editing, saving, and reopening showed the old
values.
The common failure: redundant local state that can desync from its source.
Decision
- Derive, don’t duplicate. If a value is computable from props or form state,
derive it (useMemo/inline) — don’t mirror it intouseStatethat can drift.
(InvoiceCyclePicker.toggledis nowactiveIndex > 0.) - Unmount rather than remember (amended 2026-08-03 — see below). Render a
reused form modal only while it is open:{isOpen ? <FormModal … /> : null}.
A close then destroys every piece of state it held, and the next open builds it
fresh fromdefaultValues. If a modal genuinely must stay mounted, it must
instead re-initialise all its state when the edited entity changes or it
(re)opens —reset(defaultValues)in auseEffectkeyed on[isOpen, entity]
for react-hook-form, an equivalent effect for non-RHF state, orkey={entity.id}
at the call site. - Don’t ask for what the entity already holds. Seed
defaultValuesfrom the
record being edited or transformed — a form that makes you retype a value the
app already knows is a bug, not a blank slate. - Share common sub-logic. Behaviour shared by multiple forms lives in one
component/hook, not copy-pasted — so they behave identically and a fix
propagates to all of them. Examples: the masked-input strategy
(0003-masked-inputs-for-formattable-values),InvoiceCyclePicker, the
reset-on-open effect.
Consequences
- Forms can’t leak stale state across edits; reopening always reflects current data.
- Fixing shared logic fixes every consumer at once (the point of (4)).
- A little discipline per form (always wire the reset / derive) — cheap insurance.
Amendment — 2026-08-03: unmounting supersedes resetting
The original rule 2 assumed a modal stays mounted and told you to reset it. In practice
that inverted the burden: a reset effect can only restore the fields it enumerates, so
anything in local useState — and any field the effect forgot — still leaked into the
next record you opened. Expenses hit this repeatedly even with the effect in place, and
the report that finally settled it asked for “a definitive fix, since we work with
components”.
Unmounting is that fix, because it is structural rather than remembered: there is no
list of fields to keep in sync, and no way for a new field to be added and silently
omitted. Rule 2 now leads with it; the reset pattern remains documented for the rare
modal that must persist (e.g. one holding an in-flight upload).
Applied across expenses-web (v2.34.1) and expenses-app for expenses, installments,
convert-to-installments, incomes, income entries, log-hours and subscriptions.