Makes every form in an Expo app one valibot schema behind react-hook-form, whose every message is a
catalog key, rendered with one labelled TextField whose kind sets the keyboard, autofill and
capitalization, chained by the return key, kept above the keyboard on iOS and Android, and checked
asynchronously only outside the schema. One zero-dependency checker proves it on the app's source.
Rules that must hold
- One valibot schema per form, behind
valibotResolver.useForm<Input, unknown, Output>({ resolver: valibotResolver(schema) }); the field types arev.InferInput, the submitted draft isv.InferOutput; noruleson a Controller (form-resolver). Why: one definition gives the type, the runtime check and every message, so a field can never validate one way and save another. - Every message is a catalog key, translated at the field. Each validation action carries a
literal key of the source catalog (
v.nonEmpty('task-editor.title.required'),schema-message-key);setErrorpasses a key too (error-message-key);fieldMessageKeyturns anything else into the generic key. Why: an action without a message shows valibot's English default ("Invalid length: Expected !0 but received 0") in every language. - The schema is synchronous and Hermes-safe. No
*AsyncAPI in a form schema (schema-async); no graphemes or words actions unlessIntl.Segmenteris polyfilled (schema-segmenter). Why: the resolver runs the whole schema on every validation, with no pause, cancel or deadline, and Hermes has noIntl.Segmenter(it throws on the phone while Jest on Node passes). - An async check ("is this name free?") runs in
useAsyncFieldCheck, never in the schema. It starts only for a valid shape, waits for typing to pause, lets the newest value win, has a deadline, stops on unmount, and says which value it answers; submit needs a pass for exactly the value sent, and the server still judges at write time (a refusal becomessetErrorwith a key). Why: a check per keystroke floods the server and an old answer can land on a newer value. - Digits in any script are read. A schema that matches
\dor callsNumber()transforms the text withtoLatinDigitsfirst, or, for a decimal, money or quantity field, with value-objects-money-and-units'normalizeDecimalInput(latin-digits). Why: Arabic, Persian and Devanagari keyboards, and the iOS pads in those languages, type ٣٠ or ۳۰ (and٫or,for the decimal point), which/^\d+$/andNumber()refuse. - Screens render
TextField, never a rawTextInput(raw-text-input).TextInputlives only in the field folder (src/ui/form/), whose components join the component catalogue. Why: label, error, hint, live region, keyboard and autofill are solved once; a raw input forgets one of them. - The visible label is the input's accessible name; the error is never colour alone. The label
sits above the input; on iOS the input carries it as
accessibilityLabel(the label view hidden from VoiceOver), on Android it points at the label view withaccessibilityLabelledBy(an EditText's content description loses to its typed text). The error or hint is the input'saccessibilityHintand sits in a polite live region, and an invalid field gets a bold edge and a sentence (input-label). Why: a placeholder vanishes when typing starts and is not read as a name, and about 1 in 12 men cannot tell the error colour apart. - A field kind sets the keyboard, autofill, capitalization, autocorrect and secure entry
together (
FIELD_KINDS; a raw input that hides text setsautoComplete, an email keyboard setsautoCapitalize:input-preset). Why: a password withoutautoCompletegets no password manager on either platform, and an email field that capitalizes breaks every address. - Fields bind the React Native way:
onChangeText,onBlur,ref(useTextField; neveronChange={field.onChange},{...field}orregister:field-on-change; the ref is passed on:field-ref). Why: react-hook-form readsevent.target.value, which a React Native change event lacks, so the field would holdundefined; without the ref nothing can focus the field. - Errors wait for the first blur, and a refused submit goes to the first problem.
mode: 'onTouched'(form-mode),shouldFocusErrorleft on (focus-error), and the refusal is announced. Why:onChangemode shouts "invalid email" at the first letter; focusing the field moves VoiceOver and TalkBack there too. - The return key walks the form. A single-line field names the next field (
enterKeyHintnext,submitBehaviorsubmit,setFocus), the last one submits (done), a multiline field makes new lines. Why: closing and reopening the keyboard between fields flickers and loses the user's place. - The keyboard never hides the field or swallows a tap.
KeyboardProviderwraps the root (keyboard-provider); forms scroll inKeyboardAwareScrollView(keyboard-aware-scroll) withkeyboardShouldPersistTaps="handled"(keyboard-persist-taps); a feature with a number, decimal or phone pad rendersKeyboardDoneBar(pad-done-bar), and the scroll view keeps the focused field above the bar (useDoneBarSpace:bottomOffset= the bar's measured height plus a gap). Why: Android's edge-to-edge window no longer resizes for the keyboard, the first tap on Save would only close it, the iOS pads have no return key, and the bar sits on the keyboard, over whatever the scroll view keeps just above it. - Submit is one void intent that never loses input. The model calls
handleSubmit(...)()and catches its promise, showsisSubmitting(fromuseFormState) as busy, resets the form only after the save returnedokandhandleSubmithas settled, and keeps every typed character on a failure. Why:handleSubmitrethrows what the save throws, a reset before the write is data loss, and a reset inside the save is undone (the form stays "submitted" and the next draft shows errors from its first keystroke).
Workflow
- Look first. From the repo root run
node ${CLAUDE_SKILL_DIR}/scripts/check-forms.mjs .. NOT APPLICABLE means the app has no form yet; otherwise the problems are the work list. Read references/checks-and-tests.md for each rule's fix. - Prerequisites. These come from their owner skills and must exist first: the theme,
AppTextandPressableSurface(design-system-structure),t()/useT,MessageKeyand the catalogs (i18n-strings-and-catalogs),useServicesand the error log (architecture-and-boundaries),Result(error-handling-typed-results), the latest-request,delayOrAbortandwithTimeouthelpers (async-safety),useAnnounce/useReduceMotion(accessibility-as-code), the render helpers (unit-and-component-tests). Install, verified 2026-10-03 (re-verify:npm view react-hook-form version):npx expo install react-native-keyboard-controller(1.21.9, in the SDK 57 map, included in Expo Go) andnpm install -E react-hook-form@7.89.0 @hookform/resolvers@5.9.1 valibot@1.5.0. - Copy the field kit from
templates/to the path on each file's first line:src/ui/form/(field-kinds.ts,text-field.tsx,field-note.tsx,keyboard-done-bar.tsx),src/hooks/(use-text-field.ts,field-message-key.ts,use-async-field-check.ts,use-done-bar-space.ts),src/core/text/latin-digits.ts,test/forms/i18n-form-wrapper.tsx, each with its test; merge theforms.*keys oftemplates/src/i18n/catalogs/*.jsoninto every catalog and translate them.__mocks__/react-native-keyboard-controller.tsis the canonical root mock (keep it as is). - Wire the root. Wrap the root stack in
KeyboardProvider, insideGestureHandlerRootViewand outside the motion and navigation-theme providers (examples/root-stack-keyboard-provider.tsx). The canonicalroot-stack.tsxof app-startup-and-bootstrap already renders it: check it is there; in an older root, add the import and the element and keep the file's header (thedevice-onlymarker). - List the field components. If
contracts/component-library.jsondoes not exist yet, create it from ui-component-library's catalogue template first (withsrc/ui/form/in place, that skill'scheck-componentsfailscatalogue-missingwithout it), then add the three entries ofassets/component-library-entries.json. - Write the schema first, with its test. Copy the
task-editortemplates as the pattern and rename them: bounds from the data layer, a key per message,toLatinDigitsbefore digits (normalizeDecimalInputfor a decimal, money or quantity field), local duplicate checks through a factory. Read references/schemas-and-messages.md. - Write the model hook:
useFormwith the resolver andmode: 'onTouched', oneuseTextFieldper field with its kind andnext/onSubmit, the submit intent, labels translated (references/fields-and-autofill.md, references/keyboard-focus-and-submit.md). - Add an async check only when the answer lives elsewhere (a server, another store): a
FieldCheckport,useAsyncFieldCheck, the status and error on the field, submit gated onhasPassed(value)(references/async-checks-and-server-errors.md, examples/list-name-availability.ts). - Write the screen as a pure view:
KeyboardAwareScrollViewwithkeyboardShouldPersistTaps="handled", oneTextFieldper field spread from the model, the save failure in a live region,KeyboardDoneBarwhen a pad kind is used, itsonHeightChangeand the scroll view'sbottomOffset/extraKeyboardSpacefrom the model'suseDoneBarSpace(). - Test: the schema (every message is a key in every catalog), the model (focus, refused submit, failed save keeps input, digits), the screen by label and role ending in the pressable audit (references/checks-and-tests.md).
- Run the checks: run, fix, rerun until
RESULT: PASS. Then run the neighbours' checkers named in Related skills (component catalogue, accessibility, catalogs).
Definition of done
- Every form has one valibot schema behind
valibotResolver; its test proves every message it can produce is a key in every catalog. - Screens render
TextFieldwith a kind;TextInputappears only insrc/ui/form/. - Each field shows a visible label, its error under it after the first blur, and the return key moves to the next field or submits.
- Async checks go through
useAsyncFieldCheck; submit requireshasPassed(value); server refusals becomesetErrorwith a catalog key. -
KeyboardProviderwraps the root; forms scroll inKeyboardAwareScrollViewwithkeyboardShouldPersistTaps="handled"; pad fields haveKeyboardDoneBar, and the scroll view'sbottomOffsetcomes fromuseDoneBarSpace(the screen test asserts it). - A failed save keeps the input; only a saved draft resets the form, after
handleSubmitsettled. -
contracts/component-library.jsonexists and listsTextField,FieldNoteandKeyboardDoneBar. - Tested by hand once on an iOS simulator and an Android emulator, each named for the job: the focused field stays above the keyboard and the Done bar (also at the largest text size), Next walks the fields, autofill offers the right entries, and VoiceOver and TalkBack read each field's label, empty and filled (record the result in fields-and-autofill.md).
-
node ${CLAUDE_SKILL_DIR}/scripts/check-forms.mjs .printsRESULT: PASS(or NOT APPLICABLE before the first form). The only SKIP it may print isSKIP src/i18n/catalogs/en.json [schema-message-key] not yet created (i18n-strings-and-catalogs creates it).
Anti-patterns
onPress={form.handleSubmit(save)}. The promise is dropped and a throwing save becomes an unhandled rejection. Callform.handleSubmit(save, onInvalid)().catch(record)inside a void intent.v.checkAsync(isNameFree)in the schema. It runs on every keystroke after the first blur, never cancels and has no deadline. UseuseAsyncFieldCheck.placeholder="Email"as the only label. It disappears while typing and screen readers do not treat it as the name. Show a label above the field.watch()to show live values or derive submit state. React Compiler cannot memoize it (lintreact-hooks/incompatible-library) and it re-renders the whole form per keystroke. UseuseWatch({ name })oruseFormState.disabledon the Save button until the form is valid. The user cannot find out what is wrong. Keep Save pressable; a refused submit focuses and announces the first problem.- Wrapping the screen in React Native's
KeyboardAvoidingViewor a plainScrollView. Android edge-to-edge ignores the resize, and the focused field ends up under the keyboard. keyboardType="numeric"plusNumber(text). The decimal separator and the digits depend on the keyboard's language. Use theintegerordecimalkind andtoLatinDigitsin the schema.form.reset()right after calling the save, or insidehandleSubmit's callback. A failed write throws the input away; a reset inside the callback leaves the form "submitted". Reset in the.thenafterhandleSubmit(...)(), only when the save returnedok.- Translating in the schema (
v.nonEmpty(t('...'))). The schema is built once, the language can change, and the checkers cannot see the key. Pass the key; the field translates. KeyboardToolbarfrom react-native-keyboard-controller as the Done bar. In 1.21.9 its buttons speak fixed English labels and cap their text at 130 %; check-forms does not count it unless the app lists it informs.doneBars. UseKeyboardDoneBar.- A fixed
bottomOffsetnext to a sticky bar. The bar rides on the keyboard and covers the bottom of the focused field. Take the offset fromuseDoneBarSpace.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
references/schemas-and-messages.md |
valibot and the resolver, input and output types, message keys, digits, Segmenter, cross-field and date fields | writing a schema (step 6) |
references/fields-and-autofill.md |
TextField, the kinds, autofill on iOS and Android (verified mapping), labels and screen readers, RTL | adding a field or a kind (step 7) |
references/keyboard-focus-and-submit.md |
modes, focus chain, keyboard-controller, edge-to-edge, Done bar, submit, drafts, leaving a dirty form | wiring the model and screen (steps 4, 7, 9) |
references/async-checks-and-server-errors.md |
useAsyncFieldCheck design, races, timing, server refusals to fields | an async or server check (step 8) |
references/checks-and-tests.md |
every check-forms rule with its fix, config keys, limits, test recipes | running the checker, writing tests (steps 1, 10, 11) |
templates/src/ui/form/ |
field-kinds.ts, text-field.tsx (with an Android-preset test), field-note.tsx, keyboard-done-bar.tsx, with tests |
step 3 |
templates/src/hooks/ |
use-text-field.ts, field-message-key.ts, use-async-field-check.ts, use-done-bar-space.ts, with tests |
step 3 |
templates/src/core/text/ |
latin-digits.ts: digits of any script to ASCII, with its test |
step 3 |
templates/src/features/task-editor/ |
the New task form: schema, model, screen, tests (the pattern to copy) | step 6 |
templates/src/i18n/catalogs/ |
en.json, ar.json: the forms.* and task-editor.* keys to merge |
step 3 |
templates/test/forms/i18n-form-wrapper.tsx |
test providers with the real catalogs, failing on a missing key | step 10 |
templates/__mocks__/react-native-keyboard-controller.ts |
canonical root Jest mock (synced, owned here) | step 3 |
examples/root-stack-keyboard-provider.tsx |
where KeyboardProvider goes in the root stack | step 4 |
examples/list-name-availability.ts |
an async availability check wired to a field and to submit | step 8 |
examples/server-field-errors.ts |
server rejected codes mapped to field keys with setError |
step 8 |
examples/account-fields.ts |
name, email and new-password kinds chained for autofill | step 7 |
assets/component-library-entries.json |
the field components' entries for the component catalogue | step 5 |
assets/shared.json |
the files synced from the library's canonical copies | maintainers only |
scripts/check-forms.mjs |
the checker (17 rules) | steps 1 and 11 |
scripts/selftest.mjs |
proves the checker against tests/fixtures/ |
after changing the checker |
scripts/check-lib.mjs |
shared checker library (synced; do not edit) | never by hand |
scripts/lib/ |
source-scan.mjs, the shared source lexer and JSX helpers (synced; do not edit) |
never by hand |
tests/fixtures/ |
planted-bug cases (one bad-* per rule), pass-*, error-* and good/ |
after changing the checker |
tests/bases/app/ |
the small Tasks base the fixtures start from (with the templates) | after changing the checker |
Related skills
ui-component-library- the component catalogue the field components join; buttons and rows.i18n-strings-and-catalogs- catalogs, keys and translation of every label and message.accessibility-as-code- touch targets, contrast, Dynamic Type and screen-reader rules.error-handling-typed-results-Result, failures and never losing data on an error path.async-safety- the latest-request, delay and deadline helpers the async check builds on.networking-and-server-state- theApiClient, mutations andrejectedfailures.auth-and-secure-storage- sign-in flows, passwords and tokens.dates-streaks-and-statistics- date fields holding a calendar day.value-objects-money-and-units-normalizeDecimalInput, money and quantity parsing for amount fields.rtl-and-direction- mixed-script text and logical alignment.navigation-and-routing- leaving a form with unsaved changes; the root stack order.unit-and-component-tests- render helpers, mocks and Jest setup.