Forms look like the simplest part of an admin app to build. Then your tenth form looks nothing like your first. Here's the pattern stack that keeps every form in the codebase consistent.
The previous four posts in this series covered patterns and one workflow case study: the floor problem, tables, permissions, and a deep dive into the Assessments module. Together, they point at the same theme: admin products fail when foundational patterns are solved ad hoc.
Today: forms. The floor item almost every team underestimates, because forms look easy. A <form>, a few inputs, a submit handler. Done.
Then you ship a few of them. The login form uses ad-hoc validation. The profile form uses yup. The settings form has its state in Redux for some reason. Server errors show up as toasts in one place and inline errors in another. The submit button on form A disables during submission; the inputs on form B disable. Six months later a new engineer asks "which way do we actually do forms here?" and the honest answer is "all of them, and none of them are consistent."
Forms are easy to write and hard to keep coherent. Here are the five problems in roughly the order they appear, and the pattern stack that survives.
Problem 1: hand-rolled validation grows past what it can support
The first form ships with if (!email) setError('Required') in the submit handler. It works. There's one field, one error case, nothing to think about.
Then a second field needs to validate on blur, not on submit. A third needs to depend on the first (passwords must match). A fourth needs to validate against a list ("must not be one of your previous passwords"). A fifth needs to be conditionally required — present only if a checkbox is checked.
The hand-rolled approach scales linearly with conditions and quadratically with cross-field rules. Past about three fields with any nontrivial logic, the submit handler becomes the place validation lives, which means validation runs only on submit, which means the user fills out a form and finds out at the end that field two was wrong.
The fix is to declare validation as data, not code:
const schema = yup.object({
email: yup.string().email(tForm('Invalid email')).required(),
passwordOld: yup.string().required(tForm('This field is required')),
password: yup
.string()
.required()
.min(4)
.notOneOf([yup.ref('passwordOld')], tForm('Must differ from current')),
passwordRepeat: yup.string().oneOf([yup.ref('password')], tForm('Must match')),
}).required();
Now cross-field rules are one line (yup.ref). Conditionally required fields are one method call (yup.when). Async validation is supported natively. Messages are translation-keyed from day one. And — most importantly — validation becomes a consistent part of the form lifecycle, instead of living only inside the submit handler.
Coreola standardizes every form on react-hook-form + yup via yupResolver. The schema is the single source of truth for client-side validation. No ad-hoc if checks in submit handlers. A reader who has seen one Coreola form has seen the validation pattern in all of them.
Problem 2: form state ends up in Redux
A team member says "we might need this form's state elsewhere." Or "we want it to survive navigation." Or "we want it for analytics." So the form's values land in a Redux slice. Every keystroke dispatches an action.
The cost compounds quickly. Every keystroke now round-trips through the store. Selectors re-fire. Components that subscribed to anything in the form's vicinity re-render. The Redux devtools history fills with [form/setField] actions and becomes unreadable. The form feels laggy on cheap devices.
And the original "we might need it elsewhere" turns out to be wrong most of the time. The data needs to survive submission, not the partial form values mid-keystroke.
Form state belongs local. Inside the component, or inside a hook the component owns. react-hook-form is built around exactly this idea — it keeps state in uncontrolled inputs by default, re-rendering only the fields whose validation state actually changed.
For the cases where form values really do need to live elsewhere — multi-step wizards, draft autosave — there's a separate pattern: keep the per-step state local, persist the committed state when a step completes. The committed state is small. The mid-keystroke state never leaves the form.
Coreola keeps active, mid-keystroke form state local. Redux holds submitted or committed data, not temporary input values. The convention is that a form lives inside a model hook that owns its schema, its useForm call, and its submit handler. The view consumes register, errors, and the bound submit handler — nothing more. Redux holds the result of submissions, not the keystrokes leading to them.
Problem 3: server errors and validation errors fight each other
A user submits a form with two problems. Their email is formatted invalidly, and the email they're trying to use is already taken on the server.
In a codebase with no pattern for this, what happens depends on which engineer wrote which piece. The yup schema catches the format error and shows it inline. The submit fires anyway because some forms don't gate on validity. The server returns "email already taken." A global toast pops up. Now the user sees an inline error on the email field saying "Invalid email" and a toast saying "Email already taken" — about the same field, from different layers, with no clear relationship.
Or the inverse: a form catches the server error, but its inline display logic only knows about yup, so the error vanishes into a try/catch and the user clicks Submit and nothing happens.
The fix is a reconciliation rule: field errors win for validation problems; toasts win for everything else; the same field never shows both at once. This requires:
- A helper that translates server-error responses into per-field shapes the input components already understand.
- A rule about which error to show when both exist (yup wins, because the user can fix it without a round-trip).
- A standardized error shape on the server. Anything ad-hoc on the backend ends up ad-hoc on the frontend.
const errorFields = useFieldsFromError(errors, watch());
<TextField {...register('email')} {...errorFields.email} />
useFieldsFromError returns { <fieldName>: { error: boolean, helperText: string } } per field. Spread it onto the input. The input shows the yup error if there is one, or the server error if not — without duplicated logic in the view, and without two errors showing at once.
Coreola ships useFieldsFromError plus a pure fieldsFromError helper for non-React contexts. Every form spreads the result onto its inputs. The convention is enforced by example — every model hook in the codebase wires it the same way, so writing a new form means following the shape that's already there.
Problem 4: submit handlers drift
By the fifth form, the submit handlers diverge.
One catches errors and shows nothing. Another catches and shows a toast. Another lets them propagate and crashes the page. One disables the entire form during submission. Another disables only the submit button. Another disables nothing and the user double-clicks Submit, firing two requests. One resets the form after success. Another keeps the values around "in case the user wants to edit and resubmit." None of them are wrong individually. Together, they make the product feel like five different products.
The shape that survives is a small canonical submit handler that every form in the codebase follows:
const onSubmit = handleSubmit(async (values) => {
try {
await updatePassword(values).unwrap();
snackActions.success(tForm('Password updated'));
} catch {
// Errors surfaced via base-query snackbar + field errors via useFieldsFromError
}
});
Four rules baked into one shape:
-
Errors are caught and intentionally swallowed. Coreola's shared
baseQueryshows a snackbar for transport errors, anduseFieldsFromErrorwires server validation errors to the right fields. The submit handler doesn't need to re-handle them. - Success is explicit. Always show a success snackbar. Never assume the user knows the mutation worked.
- The submit button — not the inputs — disables during the mutation. Disabled inputs lose focus, lose accessibility affordances, and feel broken. Disabled submit prevents double-submission and that's the only thing that needs preventing.
- No duplicate success toasts. If the API layer already shows one, the submit handler doesn't. Pick one place. What happens after success — reset, navigate, close a dialog, exit edit mode — is form-specific. An edit-mode toggle resets and collapses back to read view. A login form navigates. A dialog closes. A "create another" form might reset and stay open. This is a per-form decision, not part of the canonical handler shape.
Coreola treats this shape as the convention every form follows. The usePasswordViewModel.tsx hook in the codebase is the reference implementation. New forms scaffold from this shape via the Plop generator, so they start consistent and stay that way.
Problem 5: forms diverge in shape across the codebase
Three engineers, three different ways to wire a form. One puts the schema inline. Another extracts it to a separate file. A third uses Controller for every input including ones that work fine with register. A fourth keeps the submit handler in the component because "the hook abstraction felt unnecessary for this small one."
Six months later, the codebase has ten forms in seven different shapes. A new engineer reading them gets no signal about which conventions to follow. Bug fixes pile up because changing "the form pattern" means touching all seven variants.
The pattern that survives is a single canonical shape, enforced not by linters but by example and tooling:
- Schema lives next to the form's model hook. Inline if small; extracted if reused across two forms.
-
useFormis called inside the model hook with the resolver and explicitdefaultValues. -
registeris the default binding for inputs.Controlleris used only when the input needs a controlled value (selects, masked inputs, custom date pickers). - The submit handler lives in the model hook, follows the shape from Problem 4.
-
The view consumes
register,errors(orerrorFieldsfromuseFieldsFromError), and the bound submit handler. Nothing else. This isn't a religious commitment to a specific stack; it's a discipline that keeps the cost of reading any form in the codebase low. A scaffolding tool helps a lot — aplop formcommand that produces the model hook and the view from a template means a new form starts in the canonical shape and would have to be deliberately reshaped to drift out of it.
Coreola ships this scaffolding via the Plop generator. New forms produce a model hook with the schema, the useForm call, the submit handler, and a view that consumes them — all in the canonical shape. The convention is enforced by the path of least resistance.
The pattern, one more time
Five problems, one shape. Re-read them with the table and permission posts in mind:
- The naive model breaks at scale — ad-hoc validation; client-side data; role strings.
- State ends up in the wrong place — Redux instead of local; component state instead of URL; permission checks scattered next to consumers.
- Two surfaces collide — server errors vs. validation errors; two tables on one page; flags vs. permissions.
- A canonical reconciliation rule is needed — submit handler shape; URL state vs. user preferences; defense in depth between frontend and backend.
- Definitions diverge across the codebase — ten forms in seven shapes; tangled column/filter/query definitions; permission checks duplicated near consumers. Three floor items, the same five problems. This is what makes the floor a structural problem, not a list of unrelated tasks. The work of building each floor item well is recognizing which of the five problems you're standing in front of and applying the matching fix. Solving them once across the floor is what makes a foundation; solving them ad-hoc per project is what makes most admin codebases.
Coreola is a React admin foundation that standardizes forms on react-hook-form + yup, with a useFieldsFromError helper for server-side error mapping, a canonical submit handler shape, and Plop scaffolding so new forms start consistent. Live demo at demo.coreola.com, or learn more at coreola.com.
Top comments (0)