# 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.

*Canonical: https://makerkit.dev/docs/react-native-supabase/forms*

---

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:

- **`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:

```tsx
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`:

```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:

```tsx
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:

```ts
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.

{% alert type="warning" title="Keep the two flag sets in sync" %}
`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.
{% /alert %}

`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`.

{% alert type="info" title="It does not scroll a covered input into view" %}
`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.
{% /alert %}

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.

{% alert type="warning" title="The six-box input is fixed, and the backend must agree" %}
`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.
{% /alert %}

## 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:

```tsx
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:

```tsx
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.

{% alert type="warning" title="Fire cross-screen toasts on the destination" %}
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.
{% /alert %}

### 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`:

```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:

```tsx
<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:

```tsx
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`.**

```tsx
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`:

```tsx
// 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. **`useForm`** — `zodResolver(Schema)` plus `defaultValues`, or `values` + `resetOptions: { keepDirtyValues: true }` when a query feeds the form.
3. **Fields** — `FormField` → `FormItem` → (`FormLabel`) → `FormControl` → `Input` → (`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 state** — `disabled={mutation.isPending || mutation.isSuccess}` on anything that navigates; swap the button label to a loading string.
7. **Errors** — `if (isAuthError(error)) return;` first, then map known API codes to `form.setError` or an inline alert, and fall back to `toast.error`.
8. **Success** — `toast.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. **Verify** — `pnpm typecheck` covers `apps/native` and `packages/mobile-ui` alongside web, then `pnpm lint:fix` and `pnpm format:fix`.

{% alert type="info" title="There is no test id convention on native" %}
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.
{% /alert %}

## Related pages

- `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
