React Native Paper

React Native Paper is a Material Design component library for React Native. This guide targets Paper v5. No adapter package is needed: TextInput accepts the whole field.props spread, while every other control reports its value through its own callback and marks the field as touched on press.

Installation

Install Formisch, Valibot and React Native Paper, then wrap your app in PaperProvider as described in the Paper getting started guide:

npx expo install @formisch/react-native valibot react-native-paper react-native-safe-area-context

The slider section below additionally uses @react-native-community/slider, which is a separate package rather than part of Paper:

npx expo install @react-native-community/slider

Import everything from @formisch/react-native. That package bundles the core and the methods, so importing from @formisch/methods/react-native alongside it would load a second copy of Formisch's reactive state and break updates in field arrays.

Wiring patterns

React Native has no DOM, so field.props is only { ref, onFocus, onBlur, onChangeText }. There is no name and no autoFocus to forward:

  • On a text input, spread field.props and pass field.input as the value.
  • On any other control, pass field.input as the value, send the control's callback to field.onChange, and call field.props.onFocus() in the press handler.

That last point is the important one. A press does not raise a focus event in React Native, so without calling field.props.onFocus() yourself the field never becomes touched and touch validation never runs.

Spreading field.props

Paper's TextInput forwards the spread to the underlying native input:

<Field of={loginForm} path={['email']}>
  {(field) => (
    <TextInput
      {...field.props}
      mode="outlined"
      label="Email"
      value={field.input ?? ''}
      error={!!field.errors}
    />
  )}
</Field>

Paper's error prop switches the input to its error styling. React Native has no equivalent of the aria-invalid attribute, so the visible HelperText below the input is what conveys the error.

Controlled components

Checkbox.Item accepts no ref, so there is no element to register and focus cannot reach this field. Do not register a wrapping View to work around it: React Native cannot verify whether an element took focus, so Formisch trusts any registered element that cannot report its focus state and stops there, which makes a failed focus look successful. Mark the field as touched in the press handler instead:

<Field of={loginForm} path={['rememberMe']}>
  {(field) => (
    <Checkbox.Item
      label="Remember me"
      status={field.input ? 'checked' : 'unchecked'}
      onPress={() => {
        // Press does not trigger focus, so mark the field as touched here
        field.props.onFocus();
        field.onChange(!field.input);
      }}
    />
  )}
</Field>

Note that the programmatic setter is field.onChange(value). In React Native, field.props carries only onChangeText, which is meant for text input.

Displaying errors

Formisch exposes errors as [string, ...string[]] | null. Paper's HelperText renders the message and unmounts it when the field becomes valid again:

<Field of={loginForm} path={['email']}>
  {(field) => (
    <TextInput
      {...field.props}
      label="Email"
      value={field.input ?? ''}
      error={!!field.errors}
    />
    <HelperText type="error" visible={!!field.errors}>
      {field.errors?.[0]}
    </HelperText>
  )}
</Field>

Paper does not associate the helper text with the input, so there is no equivalent of aria-errormessage here.

By default, Formisch validates on submit and revalidates on input. The validation guide explains how to change that timing.

Login form example

React Native has no Form component. Wrap the fields in a View and pass handleSubmit straight to a button, since it returns a function that takes no arguments:

import { Field, handleSubmit, useForm } from '@formisch/react-native';
import { View } from 'react-native';
import { Button, Checkbox, HelperText, TextInput } from 'react-native-paper';
import * as v from 'valibot';

const LoginSchema = v.object({
  email: v.pipe(
    v.string(),
    v.nonEmpty('Please enter your email.'),
    v.email('The email address is badly formatted.')
  ),
  password: v.pipe(
    v.string(),
    v.nonEmpty('Please enter your password.'),
    v.minLength(8, 'Your password must have 8 characters or more.')
  ),
  rememberMe: v.optional(v.boolean(), false),
});

export function LoginForm() {
  const loginForm = useForm({
    schema: LoginSchema,
    initialInput: { email: '', password: '', rememberMe: false },
  });

  const submitForm = handleSubmit(loginForm, (output) => {
    console.log(output);
  });

  return (
    <View style={{ gap: 8 }}>
      <Field of={loginForm} path={['email']}>
        {(field) => (
          <View>
            <TextInput
              {...field.props}
              mode="outlined"
              label="Email"
              placeholder="example@email.com"
              value={field.input ?? ''}
              error={!!field.errors}
              keyboardType="email-address"
              autoCapitalize="none"
              autoComplete="email"
            />
            <HelperText type="error" visible={!!field.errors}>
              {field.errors?.[0]}
            </HelperText>
          </View>
        )}
      </Field>

      <Field of={loginForm} path={['password']}>
        {(field) => (
          <View>
            <TextInput
              {...field.props}
              mode="outlined"
              label="Password"
              value={field.input ?? ''}
              error={!!field.errors}
              secureTextEntry
              autoComplete="current-password"
            />
            <HelperText type="error" visible={!!field.errors}>
              {field.errors?.[0]}
            </HelperText>
          </View>
        )}
      </Field>

      <Field of={loginForm} path={['rememberMe']}>
        {(field) => (
          // `Checkbox.Item` accepts no ref, so `focus` cannot reach this field
          <Checkbox.Item
            label="Remember me"
            status={field.input ? 'checked' : 'unchecked'}
            onPress={() => {
              // Press does not trigger focus, so mark the field as touched here
              field.props.onFocus();
              field.onChange(!field.input);
            }}
          />
        )}
      </Field>

      <Button
        mode="contained"
        onPress={submitForm}
        loading={loginForm.isSubmitting}
        disabled={loginForm.isSubmitting}
      >
        Login
      </Button>
    </View>
  );
}

The initial input keeps every control defined from its first render. A failed submit moves focus to the first invalid field on its own, so no extra wiring is needed for that.

Component reference

The following snippets assume a form store named form and initialized values that match the schema.

Text input

<Field of={form} path={['email']}>
  {(field) => (
    <View>
      <TextInput
        {...field.props}
        mode="outlined"
        label="Email"
        value={field.input ?? ''}
        error={!!field.errors}
      />
      <HelperText type="error" visible={!!field.errors}>
        {field.errors?.[0]}
      </HelperText>
    </View>
  )}
</Field>

Add multiline for a longer text field. The wiring stays the same.

Checkbox

<Field of={form} path={['newsletter']}>
  {(field) => (
    <Checkbox.Item
      label="Newsletter"
      status={field.input ? 'checked' : 'unchecked'}
      onPress={() => {
        field.props.onFocus();
        field.onChange(!field.input);
      }}
    />
  )}
</Field>

Select

Paper has no select component. Build one from Menu with a pressable anchor, and expose a FieldElement through useImperativeHandle so focus can open it. The component below reads ref from its props, which requires React 19; on React 18 wrap it in forwardRef instead:

function MenuSelect<TValue extends string>({
  ref,
  onFocus,
  onBlur,
  label,
  value,
  options,
  onValueChange,
}: Pick<FieldElementProps, 'ref' | 'onFocus' | 'onBlur'> & {
  label: string;
  value: TValue | undefined;
  options: readonly { label: string; value: TValue }[];
  onValueChange: (value: TValue) => void;
}) {
  const [visible, setVisible] = useState(false);

  // The menu closing ends the interaction, so report it as a blur
  const close = () => {
    setVisible(false);
    onBlur();
  };

  // Expose a `FieldElement` so `focus(form, { path })` can reach this control.
  // `isFocused` is deliberately omitted: Formisch treats an element without it
  // as successfully focused, instead of skipping to the next errored field.
  useImperativeHandle(ref, () => ({
    focus: () => setVisible(true),
    blur: close,
  }));

  return (
    <Menu
      visible={visible}
      onDismiss={close}
      anchor={
        <Button
          mode="outlined"
          onPress={() => {
            onFocus();
            setVisible(true);
          }}
        >
          {options.find((option) => option.value === value)?.label ?? label}
        </Button>
      }
    >
      {options.map((option) => (
        <Menu.Item
          key={option.value}
          title={option.label}
          onPress={() => {
            onValueChange(option.value);
            close();
          }}
        />
      ))}
    </Menu>
  );
}

Making the component generic over TValue keeps it assignable to field.onChange, which expects the schema's union rather than a plain string:

<Field of={form} path={['framework']}>
  {(field) => (
    <MenuSelect
      ref={field.props.ref}
      onFocus={field.props.onFocus}
      onBlur={field.props.onBlur}
      label="Select a framework"
      value={field.input}
      options={FRAMEWORKS}
      onValueChange={field.onChange}
    />
  )}
</Field>

For a small set of options, SegmentedButtons is a simpler alternative.

Radio group

RadioButton.Group reports the new value, so mark the field as touched there. The group needs its own accessible name, since the individual options only announce their own labels:

<Field of={form} path={['plan']}>
  {(field) => (
    <View role="radiogroup" accessibilityLabel="Plan">
      <RadioButton.Group
        value={field.input ?? ''}
        onValueChange={(value) => {
          field.props.onFocus();
          field.onChange(value as 'hobby' | 'pro');
        }}
      >
        <RadioButton.Item label="Hobby" value="hobby" />
        <RadioButton.Item label="Pro" value="pro" />
      </RadioButton.Group>
      <HelperText type="error" visible={!!field.errors}>
        {field.errors?.[0]}
      </HelperText>
    </View>
  )}
</Field>

Slider

Paper has no slider. The community package @react-native-community/slider works with numbers directly, so field.onChange can be passed as is, and its drag events map onto the focus and blur handlers:

<Field of={form} path={['volume']}>
  {(field) => (
    <View>
      <Text>Volume: {field.input}</Text>
      <Slider
        style={{ width: '100%', height: 40 }}
        value={field.input}
        minimumValue={0}
        maximumValue={100}
        step={1}
        onSlidingStart={() => field.props.onFocus()}
        onSlidingComplete={() => field.props.onBlur()}
        onValueChange={field.onChange}
      />
    </View>
  )}
</Field>

Library-specific notes

When you write a custom control, only expose isFocused from its FieldElement if it reports the truth. Formisch treats an element without isFocused as focused, but one that returns false is treated as a failed focus, and it moves on to the next invalid field.

Next steps

Read the input components guide to package repeated wiring into reusable controls, the controlled fields guide for the underlying pattern, and the validation guide for validation timing.

Contributors

Thanks to all the contributors who helped make this page better!

  • GitHub profile picture of @fabian-hiller

Partners

Thanks to our partners who support the project ideally and financially.

Sponsors

Thanks to our GitHub sponsors who support the project financially.

  • GitHub profile picture of @vasilii-kovalev
  • GitHub profile picture of @UpwayShop
  • GitHub profile picture of @ruiaraujo012
  • GitHub profile picture of @hyunbinseo
  • GitHub profile picture of @nickytonline
  • GitHub profile picture of @kibertoad
  • GitHub profile picture of @caegdeveloper
  • GitHub profile picture of @Thanaen
  • GitHub profile picture of @bmoyroud
  • GitHub profile picture of @ysknsid25
  • GitHub profile picture of @dslatkin