Building forms in the Expo app | Expo Supabase SaaS Kit
Build a form in the native app that validates, submits and reports errors exactly like the ones that ship: react-hook-form with zod, @kit/mobile-ui/form, keyboard handling, OTP entry, toasts and destructive confirmations.
Every form in apps/native is built the same way: a zod schema, useForm with zodResolver, the @kit/mobile-ui/form primitives around a React Native input, and a React Query mutation for submission. If you have written a form against @kit/ui/form on the web app, the shape transfers almost unchanged — this page covers the parts that do not, and the native-only concerns (keyboard, code entry, toast placement) that a web reader will not anticipate.
The stack
| Piece | Package | Where it comes from |
|---|---|---|
| Form state | react-hook-form 7.84.0 | apps/native/package.json and packages/mobile-ui/package.json |
| Validation | zod 4.4.3 | apps/native/package.json |
| Bridge | @hookform/resolvers/zod | apps/native/package.json |
| Field primitives | @kit/mobile-ui/form | packages/mobile-ui/src/makerkit/form.tsx |
| Text input | @kit/mobile-ui/input (/textarea, /checkbox, /select, /switch also ship) | RNR components in packages/mobile-ui/src/components/ui/ |
| Submit button | @kit/mobile-ui/button | same |
| Feedback | @kit/mobile-ui/sonner | wraps sonner-native |
| Copy | useTranslations from use-intl, Trans from @kit/mobile-ui/trans | packages/mobile-ui/src/makerkit/trans.tsx |
| Keyboard | react-native-keyboard-controller 1.21.9 | apps/native/package.json |
zod and @hookform/resolvers are dependencies of apps/native, not of @kit/mobile-ui — the UI package only knows about react-hook-form, so it stays validator-agnostic.
@kit/mobile-ui/form
packages/mobile-ui/src/makerkit/form.tsx is a React Native mirror of web's @kit/ui/form. It exports the same names, so imports port across by changing the specifier:
| Export | What it is | Props |
|---|---|---|
Form | Re-export of RHF's FormProvider. Spread the form object: <Form {...form}> | FormProvider props |
FormField | Wraps RHF's Controller and publishes the field name to descendants via context | ControllerProps — control, name, render, plus RHF's rules / defaultValue / disabled |
FormItem | Row container. Renders a View with gap-y-2 and creates the item id context | ViewProps (className, children, …) |
FormLabel | Label that turns text-destructive when the field has an error | React.ComponentPropsWithRef<typeof Label> |
FormControl | Passthrough slot for the input — renders a fragment | children only |
FormDescription | Muted helper Text under the input | className, children |
FormMessage | Error text. Runs the message through Trans so zod messages can be i18n keys | className, children, values |
useFormField | Field state for custom primitives | — |
Four differences from the web version are worth knowing before you port a component:
FormControltakes no props. On web it wiresaria-describedby/aria-invalidonto the child. React Native has no ARIA, so the native version callsuseFormField()(to assert it is inside aFormField) and returns<>{children}</>. PutclassNameon the input itself.FormMessage's interpolation prop isvalues, notparams. Web's signature isparams?: Record<string, unknown>; native's isvalues?: Record<string, string | number>.useFormFieldstill returnsformItemId,formDescriptionIdandformMessageId, but nothing in the native primitives consumes them — they exist so a ported component keeps compiling.FormLabelis optional in practice. Several shipping screens use a plain<Text className="text-foreground text-sm font-medium">instead, because RN labels are not clickable focus targets the way<label for>is on web. Both are correct;FormLabelgets you the error colouring for free.
FormMessage is the reason zod messages in this kit are written as translation keys rather than English sentences. AccountDetailsSchema in packages/features/accounts/src/schema/account-details.schema.ts sets { message: 'account.nameMinLength' }; FormMessage renders <Trans i18nKey={raw} defaults={raw} />, so the key resolves against the catalogue and falls back to the raw string when it is missing.
A complete form, annotated
apps/native/features/accounts/components/update-account-details-form.tsx is the smallest form that exercises every part of the pattern. The code below is the shipping component:
import { zodResolver } from '@hookform/resolvers/zod';import { User } from 'lucide-react-native';import { useForm } from 'react-hook-form';import { View } from 'react-native';import { useTranslations } from 'use-intl';import type * as z from 'zod';import { AccountDetailsSchema } from '@kit/accounts/shared';import { Button } from '@kit/mobile-ui/button';import { Form, FormControl, FormField, FormItem, FormMessage,} from '@kit/mobile-ui/form';import { Icon } from '@kit/mobile-ui/icon';import { Input } from '@kit/mobile-ui/input';import { toast } from '@kit/mobile-ui/sonner';import { Text } from '@kit/mobile-ui/text';import { useUpdatePersonalAccount } from '~/features/accounts/hooks/mutations/use-update-personal-account';import { isAuthError } from '~/features/core/api-client';type AccountDetailsFormValues = z.infer<typeof AccountDetailsSchema>;export function UpdateAccountDetailsForm({ displayName }: { displayName: string }) { const t = useTranslations('account'); const updateAccount = useUpdatePersonalAccount(); const form = useForm<AccountDetailsFormValues>({ resolver: zodResolver(AccountDetailsSchema), values: { displayName }, resetOptions: { keepDirtyValues: true }, }); const onSubmit = (values: AccountDetailsFormValues) => { updateAccount.mutate( { name: values.displayName }, { onSuccess: () => { form.reset(values); toast.success(t('updateProfileSuccess')); }, onError: (error) => { if (isAuthError(error)) return; toast.error(t('updateProfileError')); }, }, ); }; return ( <Form {...form}> <View className="gap-4"> <FormField control={form.control} name="displayName" render={({ field }) => ( <FormItem> <FormControl> <View className="relative"> <Input accessibilityLabel={t('name')} autoCapitalize="words" autoComplete="name" className="pl-10" maxLength={100} onBlur={field.onBlur} onChangeText={field.onChange} onSubmitEditing={form.handleSubmit(onSubmit)} placeholder={t('name')} returnKeyType="done" value={field.value} /> <View pointerEvents="none" className="absolute inset-y-0 left-3 justify-center"> <Icon as={User} className="text-muted-foreground size-4" /> </View> </View> </FormControl> <FormMessage /> </FormItem> )} /> <Button onPress={form.handleSubmit(onSubmit)} disabled={updateAccount.isPending || !form.formState.isDirty} > <Text> {updateAccount.isPending ? t('updateProfileLoading') : t('updateProfileSubmitLabel')} </Text> </Button> </View> </Form> );}What each part is doing:
valuesinstead ofdefaultValues. This form is fed by a query, so it uses RHF'svalues(re-syncs when the prop changes) plusresetOptions: { keepDirtyValues: true }so a server refetch does not stomp on what the user is typing. Forms with no server-provided initial state —sign-up-form.tsx,update-password-form.tsx— usedefaultValuesinstead.onChangeText, notonChange. React Native'sTextInputemitsonChangeText(value: string).field.onChangeaccepts the value directly, so they connect with no adapter.field.onBlurandfield.valuewire through unchanged. There is noregister()on native — always go throughFormField/Controller.onSubmitEditing+returnKeyType. The keyboard's return key is the primary submit affordance on a phone. Single-field forms usereturnKeyType="done"and submit; multi-field forms usereturnKeyType="next"and focus the next input through a ref — see thepasswordRef/repeatPasswordRefchain inapps/native/features/auth/components/sign-up-form.tsx.accessibilityLabel. There is no<label for>relationship on native, so every input in the kit carries an explicitaccessibilityLabel, even when a visible label is present.- Pending state.
Buttonis disabled onmutation.isPending, and the label swaps to a loading string. There is noSpinnercomponent in@kit/mobile-ui— buttons show pending state as a label change, and screen-level loading usesActivityIndicator(see below). - Error handling.
isAuthError(error)returns early: an expired session is handled globally by the API client, so the form must not also toast about it. That helper and the mutation hooks are covered indocs/native/data-fetching.mdoc.
Mapping a server error onto a field
When the API rejects a specific field rather than the request as a whole, push it back into RHF with setError and an i18n key — FormMessage translates it like any zod message. From apps/native/app/(home)/[account]/(tabs)/settings/edit-team-name.tsx:
onError: (error) => { if (isAuthError(error)) return; if (error instanceof ApiError && error.code === 'duplicate-slug') { setForceSlug(true); form.setError('newSlug', { message: 'teams.duplicateSlugError' }); } else { toast.error(t('updateTeamErrorMessage')); }},Repeating fields
apps/native/app/(home)/[account]/(tabs)/members/invite.tsx uses RHF's useFieldArray with InviteMembersSchema from @kit/team-accounts/shared, nesting field names as `invitations.${index}.email`. Two schemas in the kit carry transforms — InviteMembersSchema normalises emails with .trim().toLowerCase(), TeamNameFormSchema wraps its slug in z.preprocess — which makes zod's input type wider than the form values. Both call sites cast the resolver:
resolver: zodResolver(InviteMembersSchema) as Resolver<InviteFormValues>,That cast is the sanctioned escape hatch — use it only when the schema's input type genuinely differs from the field values, not to paper over a mismatch you introduced.
Schemas: share with web, or define locally
Most native forms import the same schema the web app validates against, from a feature package's /shared entrypoint. That is the default, and it is what keeps the two clients agreeing on what the API will accept.
| Form | Schema | Source |
|---|---|---|
| Sign in | PasswordSignInSchema | @kit/auth/shared |
| Personal account name | AccountDetailsSchema | @kit/accounts/shared |
| Change password | PasswordUpdateSchema.withTranslation(...) | @kit/accounts/shared |
| Team name / slug | TeamNameFormSchema | @kit/team-accounts/shared |
| Invite members | InviteMembersSchema | @kit/team-accounts/shared |
| Sign up, reset password | PasswordSignUpSchema, PasswordResetSchema | apps/native/features/auth/schemas/password.schema.ts |
The rule: reuse the shared schema when the contract is identical; define locally only when native's inputs genuinely differ.
The one local schema file in the app shows what "genuinely differs" means. @kit/auth/shared exports a ready-made PasswordSignUpSchema, but it is built as createPasswordSignUpSchema(webPasswordRequirements), and webPasswordRequirements reads process.env.NEXT_PUBLIC_PASSWORD_REQUIRE_* — variables that do not exist in an Expo bundle. So apps/native/features/auth/schemas/password.schema.ts calls the same factory with native's own config:
import { createPasswordResetSchema, createPasswordSignUpSchema,} from '@kit/auth/shared';import { passwordRequirements } from '~/features/auth/config/password-policy.config';export const PasswordSignUpSchema = createPasswordSignUpSchema(passwordRequirements);export const PasswordResetSchema = createPasswordResetSchema(passwordRequirements);apps/native/features/auth/config/password-policy.config.ts reads the EXPO_PUBLIC_PASSWORD_REQUIRE_* mirror of web's flag set. The rules and message keys are still shared — only the policy input is local. Prefer that shape (share the factory, supply native's config) over copying a schema.
EXPO_PUBLIC_PASSWORD_REQUIRE_SPECIAL_CHARS, _NUMBERS and _UPPERCASE are separate variables from web's NEXT_PUBLIC_ equivalents. If they drift, native accepts a password the web app rejects — or worse, GoTrue's server-side policy rejects it and the user sees a weak-password alert instead of inline validation. docs/native/config-requirements.mdoc tracks which native config changes force a matching change elsewhere.
PasswordUpdateSchema is a third shape worth recognising: it is exported as an object with a withTranslation(errorMessage) factory, because the "passwords don't match" refinement message is resolved by the caller. update-password-form.tsx types its values as z.infer<ReturnType<typeof PasswordUpdateSchema.withTranslation>>.
Keyboard handling
The software keyboard covers roughly half the screen. Nothing handles this for you — every screen with an input has to choose a strategy. app/_layout.tsx wraps the whole tree in KeyboardProvider from react-native-keyboard-controller, which is the prerequisite for the two components below.
| Approach | Use it for | Where it ships |
|---|---|---|
KeyboardAvoidingScreen | Headerless, vertically-centred form screens — the auth stack | apps/native/features/makerkit/components/keyboard-avoiding-screen.tsx |
KeyboardAwareScrollView | Long forms where a specific field must be scrolled clear of the keyboard | react-native-keyboard-controller, used in app/(home)/[account]/(tabs)/members/invite.tsx |
Plain ScrollView with keyboardShouldPersistTaps="handled" | Short forms inside a native header/modal, where the header already reserves space | settings/edit-team-name.tsx, settings/leave-team.tsx |
KeyboardAvoidingScreen composes a KeyboardAvoidingView (behavior="padding" on iOS, "height" on Android) around a ScrollView whose content container is flexGrow: 1 + justifyContent: 'center', padded by the safe-area insets. The box shrinks from a single animated paddingBottom on the UI thread, so the centred block lifts in one smooth interpolation; once the shrunk box is shorter than the content, centring stops and the block top-aligns with the rest reachable by scrolling.
It is used by all four auth screens: app/(auth)/sign-in.tsx, sign-up.tsx, reset-password.tsx and verify-reset-code.tsx.
KeyboardAvoidingScreen lifts the whole container; it does not find the focused input and scroll it clear. That behaviour is KeyboardAwareScrollView's bottomOffset, which is used deliberately in the invite screen. Its mode="layout" reflows the container every frame, which is why it is not the default for the short auth forms.
Set keyboardShouldPersistTaps="handled" on any scroll container holding a form. Without it, the first tap after the keyboard opens is consumed dismissing it, so submit buttons appear to need two presses.
Code entry (OTP)
Native email flows use typed codes rather than links, because the browser your mail client opens shares no cookie jar with the app. Two components implement that:
apps/native/features/makerkit/components/otp-input.tsx renders length visual cells (default 6) with a single transparent TextInput stretched across them. One real input means paste, SMS/email autofill (autoComplete="one-time-code", textContentType="oneTimeCode"), backspace and screen-reader focus all behave natively. A Pressable wrapper re-focuses the hidden input after the IME is dismissed. Its props:
| Prop | Type | Notes |
|---|---|---|
value / onChange | string / (value: string) => void | Controlled; non-digits are stripped |
onComplete | (value: string) => void | Fires the moment the digit count reaches length |
length | number | Defaults to 6 |
autoFocus, disabled | boolean | disabled dims the cells and drops keystrokes, but deliberately keeps editable true |
accessibilityLabel, accessibilityHint | string | Required — there is no visible label |
apps/native/features/auth/components/otp-code-form.tsx is the shared step wrapper used by MFA challenge, email confirmation, password reset and MFA enrolment. It exports OTP_CODE_LENGTH = 6, owns the code state, clears it after a rejected attempt, suppresses the duplicate submit when onComplete and the button fire in the same frame, and renders the resend cooldown. Callers supply only the mutation, the labels and a pinned error — React Query nulls mutation.error when a retry starts, so pass a useState copy, not mutation.error directly.
The net user-facing behaviour is auto-submit on the sixth digit: typing or pasting the last digit calls onComplete, which submits without a button press.
OTP_CODE_LENGTH is 6 and the entry UI renders exactly that many cells. If you raise Supabase's [auth.email] otp_length to 8, the emailed code becomes longer than anything the user can type — the input caps input at length and never reaches onComplete. The failure is silent: no error, just a code that never submits. docs/native/config-requirements.mdoc lists this alongside the other backend settings that native flows depend on.
Feedback: toasts, loading, errors
Toasts
@kit/mobile-ui/sonner re-exports toast and a Toaster wrapper around sonner-native. The wrapper's only opinion is position="top-center" — a bottom toast collides with the keyboard and with the tab bar. Everything else is a prop, because the package must not import app contexts.
app/_layout.tsx mounts it once at the root:
const TOAST_HEADER_OFFSET = 60;// inside RootStackconst insets = useSafeAreaInsets();const { preference } = useThemePreference();<Toaster theme={preference ?? 'system'} offset={insets.top + TOAST_HEADER_OFFSET}/>The offset is the safe-area top inset (notch / dynamic island) plus 60pt, which clears a native stack header so a toast never lands on the back button. theme is threaded from the app's theme preference; 'system' resolves against the OS scheme. Theme preference itself is covered in docs/native/styling-theming.mdoc.
Call sites use toast.success and toast.error with an already-translated string:
toast.success(t('updateProfileSuccess'));toast.error(tTeams('inviteMembersErrorMessage'));Those two are the only variants used across the app, though sonner-native's full API is available.
On iOS, a native sheet or modal presentation (presentation: 'modal' | 'formSheet') can cover the root-mounted toaster despite its internal FullWindowOverlay. If a form dismisses itself on success, fire the toast after navigation settles rather than from the screen that is going away — update-password-form.tsx calls router.back() and then toast.success(...). Mounting a second <Toaster /> inside a modal's subtree is the per-screen escape hatch.
Loading and error states
Forms that need server data before they can render use the two shared components in apps/native/features/makerkit/components/:
| Component | Renders | Use for |
|---|---|---|
LoadingState | Full-screen centred ActivityIndicator | A screen waiting on its query |
ErrorState | Full-screen generic message, __DEV__-only diagnostic line, retry, optional sign-out | A whole screen that failed to load |
InlineErrorState | Same body, inline padding | One failed section of an otherwise-rendered screen |
ErrorState takes error, onRetry, an optional title and an optional onSignOut. It never shows the raw error to users — the diagnostic line from describeError() is behind __DEV__. The standard shape is a wrapper screen that gates on the query and a child that owns the form, as in settings/edit-team-name.tsx:
export default function EditTeamNameScreen() { const workspace = useTeamWorkspace(); if (workspace.isPending) return <LoadingState />; if (workspace.isError) { return <ErrorState error={workspace.error} onRetry={() => void workspace.refetch()} />; } const { account } = workspace.data; return <EditTeamNameForm name={account.name} slug={account.slug} />;}For an inline error that belongs to the submission rather than a query — an auth rejection, a weak password — use AuthErrorAlert (apps/native/features/auth/components/auth-error-alert.tsx), which maps Supabase auth errors onto catalogue keys and expands weak_password into its list of reasons.
Confirmation and destructive flows
There are three destructive-confirmation patterns in the app. Pick the one that matches the blast radius of the action.
| Pattern | Confirmation | Example |
|---|---|---|
| Alert dialog | Press a destructive button in a non-dismissable AlertDialog | features/team-accounts/components/members/remove-member-dialog.tsx, invitations/delete-invitation-dialog.tsx |
| Typed confirmation | Type a literal word before the button enables | app/(home)/[account]/(tabs)/settings/leave-team.tsx |
| Emailed code | Request a code, type it, then confirm on a destructive panel | features/accounts/components/delete-account-flow.tsx |
Alert dialog. RemoveMemberDialog keeps AlertDialog rather than Dialog specifically so the backdrop is not dismissable, and blocks onOpenChange while the mutation is pending:
<AlertDialog open={!!member} onOpenChange={(open) => { if (!open && !remove.isPending) onClose(); }}>Typed confirmation. leave-team.tsx is not a react-hook-form form at all — a single literal-match field does not need a resolver:
const [value, setValue] = useState('');const canSubmit = value.trim() === 'LEAVE';The Input uses autoCapitalize="characters", autoCorrect={false} and placeholder="LEAVE", and the destructive Button is disabled={!canSubmit || inFlight}. The confirmation string is also sent to the API ({ accountId: id, confirmation: 'LEAVE' }), so the server re-checks it. If you copy this pattern, keep both halves.
Emailed code. DeleteAccountFlow is a three-step state machine — 'request' | 'verify' | 'confirm' — that requests an OTP, collects it with OtpInput, and only then shows the destructive panel. The code is collected client-side; the single server-side check happens with the DELETE request itself, so an invalid-otp ApiError sends the user back to the 'verify' step with the field cleared.
All three share one detail: inFlight includes isSuccess, not just isPending.
const inFlight = updateTeam.isPending || updateTeam.isSuccess;A mutation that navigates or signs out on success resolves a frame or two before the navigation actually happens. Disabling only on isPending re-enables the button during that window and lets a fast double-tap fire the mutation twice.
Translating form copy
Every string in a form comes from the catalogue. Two call shapes — useTranslations is imported from use-intl directly, Trans from @kit/mobile-ui/trans:
// Namespaced hook — for props that need a plain stringconst t = useTranslations('account');<Input accessibilityLabel={t('name')} placeholder={t('name')} />// Component — for rendered children, including rich text<FormLabel> <Trans i18nKey="common.emailAddress" /></FormLabel>Trans takes i18nKey, defaults, values, ns and components. Use t() when the value is a prop (placeholder, accessibilityLabel, a Button label built from a ternary) and <Trans /> when it is rendered content. Zod messages and form.setError messages are catalogue keys, resolved by FormMessage — you do not translate them at the call site.
Native inherits web's catalogue and keeps only a local delta; where new keys go, and how the generated file is produced, is owned by docs/native/i18n.mdoc. Add your keys there before wiring them into a form, or pnpm typecheck will fail on the narrowed message types.
Checklist for a new form
- Schema — reuse the shared one from
@kit/*/sharedif the contract matches web; otherwise call the shared factory with native config, and only then write a local schema. Messages are catalogue keys, not sentences. useForm—zodResolver(Schema)plusdefaultValues, orvalues+resetOptions: { keepDirtyValues: true }when a query feeds the form.- Fields —
FormField→FormItem→ (FormLabel) →FormControl→Input→ (FormDescription) →FormMessage. Wirevalue,onChangeText,onBlur; addaccessibilityLabel,returnKeyTypeandonSubmitEditing. - Keyboard — pick a container from the table above and set
keyboardShouldPersistTaps="handled". - Mutation — a hook from
features/<domain>/hooks/mutations/, submitted viaform.handleSubmit(onSubmit). Seedocs/native/data-fetching.mdoc. - Pending state —
disabled={mutation.isPending || mutation.isSuccess}on anything that navigates; swap the button label to a loading string. - Errors —
if (isAuthError(error)) return;first, then map known API codes toform.setErroror an inline alert, and fall back totoast.error. - Success —
toast.successon the destination screen if the form navigates away. - i18n keys — added to the catalogue per
docs/native/i18n.mdocand regenerated withpnpm i18n:native. - Verify —
pnpm typecheckcoversapps/nativeandpackages/mobile-uialongside web, thenpnpm lint:fixandpnpm format:fix.
Web components carry data-test attributes for the Playwright suite in apps/e2e. The native app sets no testID props anywhere, and apps/e2e does not target it — there is no native E2E harness in the repo. If you add one, the testID convention is yours to establish; do not assume a ported web component's data-test value carries over.
Related pages
docs/native/data-fetching.mdoc— theapi()client, mutation hooks,isAuthErrorandApiErrordocs/native/styling-theming.mdoc— Uniwind classes, theme tokens, adding a@kit/mobile-uicomponentdocs/native/i18n.mdoc— the message catalogue, additions vs overrides,pnpm i18n:nativedocs/native/config-requirements.mdoc— OTP length and expiry, password-policy flags, and what a config change forces elsewheredocs/native/covered-features.mdoc— which flows exist natively at all