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

PiecePackageWhere it comes from
Form statereact-hook-form 7.84.0apps/native/package.json and packages/mobile-ui/package.json
Validationzod 4.4.3apps/native/package.json
Bridge@hookform/resolvers/zodapps/native/package.json
Field primitives@kit/mobile-ui/formpackages/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/buttonsame
Feedback@kit/mobile-ui/sonnerwraps sonner-native
CopyuseTranslations from use-intl, Trans from @kit/mobile-ui/transpackages/mobile-ui/src/makerkit/trans.tsx
Keyboardreact-native-keyboard-controller 1.21.9apps/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:

ExportWhat it isProps
FormRe-export of RHF's FormProvider. Spread the form object: <Form {...form}>FormProvider props
FormFieldWraps RHF's Controller and publishes the field name to descendants via contextControllerPropscontrol, name, render, plus RHF's rules / defaultValue / disabled
FormItemRow container. Renders a View with gap-y-2 and creates the item id contextViewProps (className, children, …)
FormLabelLabel that turns text-destructive when the field has an errorReact.ComponentPropsWithRef<typeof Label>
FormControlPassthrough slot for the input — renders a fragmentchildren only
FormDescriptionMuted helper Text under the inputclassName, children
FormMessageError text. Runs the message through Trans so zod messages can be i18n keysclassName, children, values
useFormFieldField state for custom primitives

Four differences from the web version are worth knowing before you port a component:

  • FormControl takes no props. On web it wires aria-describedby / aria-invalid onto the child. React Native has no ARIA, so the native version calls useFormField() (to assert it is inside a FormField) and returns <>{children}</>. Put className on the input itself.
  • FormMessage's interpolation prop is values, not params. Web's signature is params?: Record<string, unknown>; native's is values?: Record<string, string | number>.
  • useFormField still returns formItemId, formDescriptionId and formMessageId, but nothing in the native primitives consumes them — they exist so a ported component keeps compiling.
  • FormLabel is 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; FormLabel gets 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:

  • values instead of defaultValues. This form is fed by a query, so it uses RHF's values (re-syncs when the prop changes) plus resetOptions: { 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 — use defaultValues instead.
  • onChangeText, not onChange. React Native's TextInput emits onChangeText(value: string). field.onChange accepts the value directly, so they connect with no adapter. field.onBlur and field.value wire through unchanged. There is no register() on native — always go through FormField / Controller.
  • onSubmitEditing + returnKeyType. The keyboard's return key is the primary submit affordance on a phone. Single-field forms use returnKeyType="done" and submit; multi-field forms use returnKeyType="next" and focus the next input through a ref — see the passwordRef / repeatPasswordRef chain in apps/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 explicit accessibilityLabel, even when a visible label is present.
  • Pending state. Button is disabled on mutation.isPending, and the label swaps to a loading string. There is no Spinner component in @kit/mobile-ui — buttons show pending state as a label change, and screen-level loading uses ActivityIndicator (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 in docs/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.

FormSchemaSource
Sign inPasswordSignInSchema@kit/auth/shared
Personal account nameAccountDetailsSchema@kit/accounts/shared
Change passwordPasswordUpdateSchema.withTranslation(...)@kit/accounts/shared
Team name / slugTeamNameFormSchema@kit/team-accounts/shared
Invite membersInviteMembersSchema@kit/team-accounts/shared
Sign up, reset passwordPasswordSignUpSchema, PasswordResetSchemaapps/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.

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.

ApproachUse it forWhere it ships
KeyboardAvoidingScreenHeaderless, vertically-centred form screens — the auth stackapps/native/features/makerkit/components/keyboard-avoiding-screen.tsx
KeyboardAwareScrollViewLong forms where a specific field must be scrolled clear of the keyboardreact-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 spacesettings/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.

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:

PropTypeNotes
value / onChangestring / (value: string) => voidControlled; non-digits are stripped
onComplete(value: string) => voidFires the moment the digit count reaches length
lengthnumberDefaults to 6
autoFocus, disabledbooleandisabled dims the cells and drops keystrokes, but deliberately keeps editable true
accessibilityLabel, accessibilityHintstringRequired — 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.

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 RootStack
const 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.

Loading and error states

Forms that need server data before they can render use the two shared components in apps/native/features/makerkit/components/:

ComponentRendersUse for
LoadingStateFull-screen centred ActivityIndicatorA screen waiting on its query
ErrorStateFull-screen generic message, __DEV__-only diagnostic line, retry, optional sign-outA whole screen that failed to load
InlineErrorStateSame body, inline paddingOne 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.

PatternConfirmationExample
Alert dialogPress a destructive button in a non-dismissable AlertDialogfeatures/team-accounts/components/members/remove-member-dialog.tsx, invitations/delete-invitation-dialog.tsx
Typed confirmationType a literal word before the button enablesapp/(home)/[account]/(tabs)/settings/leave-team.tsx
Emailed codeRequest a code, type it, then confirm on a destructive panelfeatures/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 string
const 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

  1. Schema — reuse the shared one from @kit/*/shared if 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.
  2. useFormzodResolver(Schema) plus defaultValues, or values + resetOptions: { keepDirtyValues: true } when a query feeds the form.
  3. FieldsFormFieldFormItem → (FormLabel) → FormControlInput → (FormDescription) → FormMessage. Wire value, onChangeText, onBlur; add accessibilityLabel, returnKeyType and onSubmitEditing.
  4. Keyboard — pick a container from the table above and set keyboardShouldPersistTaps="handled".
  5. Mutation — a hook from features/<domain>/hooks/mutations/, submitted via form.handleSubmit(onSubmit). See docs/native/data-fetching.mdoc.
  6. Pending statedisabled={mutation.isPending || mutation.isSuccess} on anything that navigates; swap the button label to a loading string.
  7. Errorsif (isAuthError(error)) return; first, then map known API codes to form.setError or an inline alert, and fall back to toast.error.
  8. Successtoast.success on the destination screen if the form navigates away.
  9. i18n keys — added to the catalogue per docs/native/i18n.mdoc and regenerated with pnpm i18n:native.
  10. Verifypnpm typecheck covers apps/native and packages/mobile-ui alongside web, then pnpm lint:fix and pnpm format:fix.
  • docs/native/data-fetching.mdoc — the api() client, mutation hooks, isAuthError and ApiError
  • docs/native/styling-theming.mdoc — Uniwind classes, theme tokens, adding a @kit/mobile-ui component
  • docs/native/i18n.mdoc — the message catalogue, additions vs overrides, pnpm i18n:native
  • docs/native/config-requirements.mdoc — OTP length and expiry, password-policy flags, and what a config change forces elsewhere
  • docs/native/covered-features.mdoc — which flows exist natively at all