@bc-solutions-coder/forms (packages/forms) is the shared form-authoring layer both frontends
write their forms with. It binds TanStack Form state onto the
@bc-solutions-coder/ui catalog once, so a form is a schema, a mutation, and a list of fields —
not a re-implementation of submit plumbing.
It sits one layer above the component library and depends on it one way:
@bc-solutions-coder/styles → @bc-solutions-coder/ui → @bc-solutions-coder/forms → apps
ui knows nothing about forms and must never import this package. Like ui, forms is private
(never published) and consumed as a workspace:* dependency.
The mutation half of a form is TanStack Query, and it arrives the same way it does everywhere
else in this workspace: through @bc-solutions-coder/query, never @tanstack/react-query
directly — including in the samples below, and including inside packages/forms itself. See
Frontend State.
Before it, each of the five forms in the two apps hand-rolled the same things and disagreed about
half of them: the preventDefault + stopPropagation + void form.handleSubmit() trio, a
per-field validator function, the disabled={pending} / {pending ? "Sending..." : "Send"} pair on
every submit button, hand-written data-testid strings on every control and its message, and
two different ways of showing a failure — some forms put per-field messages in an ErrorBanner that
named no field, others put them under the input. The package replaces all of it with one shell, one
hook and one catalog, and the testids it derives are byte-identical to the ones the Playwright
suites already select.
Every form in both apps. Eleven screens were migrated onto it, largest first, one commit each:
| Screen | App | Notable |
|---|---|---|
register/RegisterForm |
wallow-auth | Passwordless toggle hides the password block; the schema stays valid either way. |
mfa-enroll/MfaEnrollForm |
wallow-auth | Only the CONFIRM is a form; the start is a hook. |
mfa-challenge/MfaChallengeForm |
wallow-auth | Two testids for one field, branching on "use a backup code". |
invitation/InvitationScreen |
wallow-auth | — |
login/LoginScreen |
wallow-auth | The shell; three sibling forms below it. |
accept-terms/AcceptTermsScreen |
wallow-auth | — |
mfa/MfaSettingsSection |
wallow-web | Its query wiring came out first, as useMfaSettings. |
login/OtpLoginForm |
wallow-auth | Two <form> elements in one component — request, then verify. |
login/MagicLinkLoginForm |
wallow-auth | Keeps its useEffect; it was already correct. |
mfa/MfaEnrollFlow |
wallow-web | — |
login/PasswordLoginForm |
wallow-auth | Three local field wrappers collapsed to TextField / CheckboxField. |
The four wallow-auth login/MFA screens share a house pattern worth recognising before you copy one:
a rule-free zod schema plus the plain-onSubmit escape hatch, because their endpoints answer
with a bare { succeeded, error } body rather than problem details, and because their blank-input
guards report into a banner the surrounding SHELL owns — which a zod rule could not do, since it
would abort handleSubmit before the callback ran. A form talking to an RFC 7807 endpoint should
use mutation and put its rules in the schema, like CreateInquiryForm.
src/index.ts is the only entry — there are no subpaths, and nothing else in packages/forms/src
is importable. Everything below comes from @bc-solutions-coder/forms.
| Export | What it is |
|---|---|
useAppForm |
The one hook a form calls: schema + mutation + submit pipeline + RFC 7807 error split. |
AppForm |
The shell: owns the <form> element, the submit wiring, the vertical rhythm, and the testid prefix. |
SubmitButton, FormError |
The two children that read the shell's pending / serverError instead of taking them as props. |
TextField, PasswordField, TextareaField, SelectField, CheckboxField |
The catalog fields, reached through form.AppField's render prop. |
fieldTestId, fieldErrorTestId, splitServerError |
The derivation and error-split helpers, so a bespoke control can match the catalog exactly. |
withForm |
TanStack's higher-order form composition, bound to this package's contexts. |
The canonical template is
apps/wallow-web/src/features/organizations/components/CreateOrganizationForm.tsx. Stripped to its
shape:
import { AppForm, FormError, SubmitButton, useAppForm } from "@bc-solutions-coder/forms";
import { useQueryClient } from "@bc-solutions-coder/query";
import { useRouteContext } from "@tanstack/react-router";
import { z } from "zod";
import { organizationsCreateMutation, queriesWithTag } from "../api";
const createOrganizationSchema = z.object({
name: z.string().trim().min(1, "Name is required"),
});
function CreateOrganizationFormFields() {
const { sdk } = useRouteContext({ from: "__root__" });
const queryClient = useQueryClient();
const form = useAppForm({
schema: createOrganizationSchema,
defaultValues: { name: "" },
// The generated factory goes over WHOLE — no destructuring, no cast.
mutation: organizationsCreateMutation({ client: sdk.client }),
// Only needed when the request body is not the form's values verbatim.
toVariables: (values) => ({ body: { name: values.name, domain: null } }),
onSuccess: () => {
void queryClient.invalidateQueries(queriesWithTag("Organizations"));
form.reset();
},
fallbackError: "Could not create the organization.",
});
return (
<AppForm form={form} testIdPrefix="organization-create">
<form.AppField name="name">
{(field) => <field.TextField label="Name" testId="organization-name" />}
</form.AppField>
<FormError />
<SubmitButton>Create organization</SubmitButton>
</AppForm>
);
}mutation is the generated {operation}Mutation({ client }) factory from
@bc-solutions-coder/sdk/query (usually re-exported through the feature's api.ts — see
Frontend State), passed whole. useAppForm infers its error type, so nothing
is destructured or cast.
| Option | Required | What it does |
|---|---|---|
schema |
yes | A zod schema, wired as TanStack's validators.onDynamic. Its input type is the form's value shape. |
defaultValues |
yes | The initial values. Its keys are also the field names the server-error split matches against. |
mutation |
no | The generated {operation}Mutation({ client }) options object, passed whole. Omit it for the onSubmit escape hatch. |
toVariables |
no | Values → mutation variables. Defaults to (values) => ({ body: values }) when mutation is given. |
onSubmit |
no | The no-mutation escape hatch (see below). Still runs through an internal mutation, so pending keeps working. |
onSuccess |
no | Runs with the mutation's data: sweep the query cache, reset the form, hand a one-time secret to the parent. |
fallbackError |
no | Banner text for a failure carrying nothing usable. Defaults to "Something went wrong. Please try again.". |
It returns the TanStack form instance (so form.AppField, form.Field, form.reset and
form.handleSubmit are all where a TanStack user expects them) plus a form.wallow member:
form.wallow |
What it holds |
|---|---|
pending |
Whether the submit mutation is in flight. AppForm publishes it to SubmitButton and the fields. |
serverError |
The form-level failure text FormError renders, or null. |
reset() |
Drops the mutation's result/error state — e.g. when a dialog reopens. |
clearServerErrors() |
Drops the last submit's banner and server field messages. AppForm calls it on every submit. |
Every field is reached as a member of form.AppField's render-prop argument (field.TextField,
field.SelectField, …) and shares label, testId, and disabling itself while the form is
pending.
| Field | Value type | Props beyond label / testId |
|---|---|---|
TextField |
string |
type ("text" | "email" | "tel" | "url"), placeholder, autoComplete, optional, inputMode |
PasswordField |
string |
placeholder, autoComplete, labelAction — the type is pinned to "password" and cannot be widened |
TextareaField |
string |
placeholder, rows, optional |
SelectField |
string |
options ({ value, label }[]), placeholder, optional |
CheckboxField |
boolean |
description |
optional renders a muted (optional) marker after the label, which is how a form says a field is
not required instead of leaving a user to discover it by submitting.
Two of those props are narrower than they look. inputMode names the virtual keyboard a touch
device offers and is kept separate from type because the two are not interchangeable for a
digits-only value — type="number" eats the leading zero of a zero-padded one-time code, so an OTP
field stays type="text" and asks for the keypad here. labelAction puts an affordance on the
label's LINE (the sign-in screen's "Forgot password?"), beside the label and never inside it: a
label names its control, so an anchor folded into one would join the field's accessible name and
put a navigation target inside the box's click area. Watch the depth budget when passing one —
react/jsx-max-depth is 2 in both apps and a prop's JSX counts at the depth of the element
carrying it, so an inline labelAction={<X/>} usually has to become a module-scope element
constant.
Testids are derived, never hand-written, from the shell's testIdPrefix plus the TanStack field
name (camelCase folded to kebab-case), so they satisfy the repo's {page}-{element} rule
(.claude/rules/E2E.md) by construction:
| Element | Derived id | With testIdPrefix="inquiry", field projectType |
|---|---|---|
The <form> element |
{testIdPrefix}-form |
inquiry-form |
| A field's control | {testIdPrefix}-{kebab field name} |
inquiry-project-type |
| A field's message | the control's id plus -error |
inquiry-project-type-error |
SubmitButton |
{testIdPrefix}-submit |
inquiry-submit |
FormError |
{testIdPrefix}-error |
inquiry-error |
testIdPrefix and testId are different tools, and mixing them up is the easiest way to move an id
a Playwright suite depends on:
testIdPrefix(onAppForm) is the derivation root. Change it and every id in the form moves at once. Pick the prefix the existing field ids already share.testId(onAppForm, a field,SubmitButton, orFormError) overrides one id. On a field it overrides both ids — control and message — sotestId="organization-name"also producesorganization-name-error.
CreateInquiryForm is the case that needs both: its <form> is stamped inquiry-create-form while
its fields, submit and banner use the bare inquiry prefix, so it sets testIdPrefix="inquiry" and
overrides the element alone with testId="inquiry-create-form".
Three failure surfaces, two testid shapes, one path each:
| Failure | Route | Rendered as |
|---|---|---|
| Client-side validation | zod schema → TanStack's onDynamic validator → field.state.meta.errors |
Field.Error under the control — {prefix}-{field}-error |
| Server field errors | RFC 7807 errors dict → WallowError.fieldErrors → splitServerError → form.setErrorMap({ onServer }) |
the same Field.Error, the same testid |
| Form-level failure | RFC 7807 detail, the fallbackError, or a thrown Error's own message → form.wallow.serverError |
FormError → a ui ErrorBanner — {prefix}-error |
splitServerError decides which is which, and the rules are worth knowing because they are what
keeps a message from disappearing:
- The API emits property names in PascalCase (
"Name"); the split folds only the first character, soProjectTypelands onprojectTypeandemailAddresssurvives. - A message keyed by a name the form does not hold joins the banner rather than vanishing.
- If every message landed on a field,
serverErrorstaysnulland no banner renders — a banner there would only repeat what is already under the inputs. - A failure that is not a
WallowError(a network fault, say) contributes its own message if it has one, andfallbackErrorotherwise.
FormError renders nothing at all when there is no form-level error, so no empty banner reserves
space and no stale testid is left behind.
- The shell owns the rhythm.
AppFormappliesspace-y-5; aclassNamereplaces it wholesale rather than merging (CreateOrganizationFormpassesspace-y-6for its card). - Every field has a visible label. There is no label-less catalog field; use
optionalto mark a field that is not required. - Validation runs on submit, then keeps up. The schema is wired as
validators.onDynamicunder TanStack'srevalidateLogic(), which validates on submit and on every change thereafter. A first-time visitor is never corrected mid-word; once a submit has flagged a field, its message tracks the value live — it clears the moment the value is fixed and returns if the value goes bad again, with no second submit. This is set inuseAppFormand inherited, so no form configures it. - Server errors are cleared on the way into a submit, not after it.
AppFormcallsclearServerErrors()beforehandleSubmit(), becausehandleSubmitaborts on a field still carrying the previous submit's server error and nothing in TanStack clears anonServererror by itself. - Pending disables, it does not spin. While the mutation is in flight every catalog control and
the submit button are disabled;
SubmitButton's optionalpendingLabelswaps the text (<SubmitButton pendingLabel="Sending...">Send reset link</SubmitButton>). There are no spinners. - The
<form>isnoValidate. The schema owns validation, so the browser never double-validates atype="email"control or pops a native bubble.
A control the catalog does not have stays on form.AppField's render prop, which hands over the
raw TanStack field object — it is still a real form field, just not a catalog one.
RegisterAppForm's scope multi-select does this:
<form.AppField name="scopes">
{(field) => <ScopeToggles value={field.state.value} onChange={field.handleChange} />}
</form.AppField>A child that is wired to no field at all (RegisterAppForm's branding subsection) is simply a plain
child of the shell.
A form that owns its own submit semantics omits mutation and passes onSubmit. It still gets
pending, the shell, and the fields; it just does not get the mutation-driven error split.
ForgotPasswordForm uses this to swallow every failure for anti-enumeration:
const form = useAppForm({
schema: forgotPasswordSchema,
defaultValues: { email: "" },
onSubmit: async (values): Promise<void> => {
try {
await accountForgotPassword({ client: sdk.client, body: { email: values.email.trim() } });
} catch {
// Deliberately swallowed: a failure that appears for only some addresses
// tells the caller which addresses are real.
}
},
onSuccess: onSubmitted,
});ResetPasswordForm is the same hatch one step further: its endpoint answers failures with a bare
JSON object rather than problem details, so it keeps the banner text in its own useState and hands
it to the shell as an explicit serverError prop, which AppForm prefers over
form.wallow.serverError. <FormError /> still derives the testid from the prefix.
Note the hatch's one trap: an early return out of the onSubmit callback still resolves the
internal mutation and therefore fires onSuccess. A form whose guards can bail early should do its
navigation at the end of the callback rather than in onSuccess.
z.string().trim()does not trim the submitted value. It makes" "fail a.min(1), which is the whitespace-only guard — but TanStack's standard-schema adapter reads only the issue list off a validation result and discards the parsed output, soform.state.valuesstays raw. A form that must post a trimmed value trims it itself (values.email.trim()).- Import from the package root only.
@bc-solutions-coder/formspublishes one entry; the shared TanStack contexts, the shell's React context and the field-part helpers are deliberately not on it. A form that reached them — or calledcreateFormHooka second time — would build fields bound to a context noAppFormpublishes, and nothing would catch it at review time. FormErrorandSubmitButtonmust render inside<AppForm>. They read the shell's context and throw without it rather than stamping anundefined-submittestid nobody notices until Playwright goes red.react/jsx-max-depthis 2 (oxlintpedantic, andpnpm lintruns--deny-warnings). AnAppForm > form.AppField > field.TextFieldchain is already at the budget, so a form body belongs in its own component rather than nested inside aCardin the same function — which is why every migrated form splits<Feature>Formfrom<Feature>FormFields.unicorn/catch-error-namerequires the catch parameter to be namederroroutside test files.
- Add
packages/forms/src/fields/<name>-field.tsx, followingtext-field.tsx— a uiFieldrow built fromuseCatalogField,CatalogFieldLabelandCatalogFieldErrorso the testid derivation and thetestIdoverride behave identically to every other field. - Register it in
fieldComponentsinpackages/forms/src/core/form-hook.tsx. That is the package's singlecreateFormHookcall and the only place a field becomes a member ofAppField's argument. - Export the component and its props type from
packages/forms/src/index.ts. - Pin it in
packages/forms/src/index.test.ts: add the runtime name toPUBLIC_RUNTIME_EXPORTSand the props type to thePublicTypeExportstuple at the bottom of the same file. - If it wraps a ui component backed by a Base UI part not already listed, append that subpath to
baseUiSubpathsinpackages/forms/vitest.config.ts— without it the browser project pre-bundles a second copy of React and the specs die.
packages/forms/CLAUDE.md holds the contributor detail: internal layering, the test model, and the
package's scripts.
pnpm --filter @bc-solutions-coder/forms test runs the shared two-project Vitest split from
@bc-solutions-coder/testing: a node project for the pure-logic specs (the barrel pin, the testid
derivation, the error-normalization and failure-splitting helpers, the shared form contexts, and the
browser pre-bundle guard) and a browser project running every field and
shell spec in real headless Chromium — with the Tailwind pipeline and the fork theme attached, since
a ui control gets its box from a recipe utility and would otherwise measure 0×0. Nothing is mocked;
@bc-solutions-coder/ui in particular must never be (.claude/rules/TESTING.md).
- Component Library — the
@bc-solutions-coder/uicatalog these fields wrap. - Frontend State: TanStack Query vs. Zustand — where the generated
{operation}Mutation()a form submits through comes from, and how to sweep the cache after a write. - Frontend Setup — app bootstrap and the shared-package wiring.