Mantine

Mantine is a fully featured React components library with more than 100 customizable components. This guide targets Mantine v9. No adapter package is needed: most Mantine inputs forward their props to a native element, so they receive field.props directly, and every input handles labels and error accessibility through its built-in label and error props.

Installation

Install Formisch, Valibot and Mantine, and follow the Mantine getting started guide to set up MantineProvider and the core styles:

npm install @formisch/react valibot @mantine/core @mantine/hooks

Wiring patterns

Choose the wiring from the component's public API:

  • If it forwards props and a ref to a native form element, spread field.props and pass field.input as its value.
  • If it exposes a value callback such as onChange(value), control it with field.input and field.onChange, then forward the remaining lifecycle props separately.

Spreading field.props

TextInput, PasswordInput, Textarea and Checkbox render native elements and forward their ref to them, so the field.props spread works directly. The label prop associates the label for you:

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

Controlled components

Select hides the native element behind a readonly combobox input, but its public props still cover the complete field contract. Pass the field name and ref, forward focus, blur and autofocus behavior, and normalize the value in both directions, since Mantine represents no selection as null and types every value as a plain string:

<Field of={form} path={['framework']}>
  {(field) => (
    <Select
      name={field.props.name}
      ref={field.props.ref}
      autoFocus={field.props.autoFocus}
      onFocus={field.props.onFocus}
      onBlur={field.props.onBlur}
      value={field.input ?? null}
      onChange={(value) =>
        field.onChange((value ?? undefined) as typeof field.input)
      }
      label="Framework"
      placeholder="Select a framework"
      data={frameworks}
      error={field.errors?.[0]}
    />
  )}
</Field>

ref registers the combobox input so Formisch can focus the field, and the cast narrows Mantine's string value back to the schema's union type. Formisch's focus and blur handlers are parameterless because they only update field lifecycle state, so you can pass them directly even when the component supplies an event argument.

These examples wire fields directly so you can see the complete integration. Once a pattern repeats, wrap the control, lifecycle props and errors in your own input component. The input components guide shows how.

Displaying errors

Formisch exposes errors as [string, ...string[]] | null. Mantine inputs display an error message through their error prop and set aria-invalid and aria-describedby on the input automatically, so a single expression covers the visual and accessible error state:

<Field of={loginForm} path={['email']}>
  {(field) => (
    <TextInput
      {...field.props}
      value={field.input ?? ''}
      error={field.errors?.[0]}
    />
  )}
</Field>

For widgets without an error prop, such as Slider, wrap the control in Input.Wrapper, which renders the same label and error markup:

<Field of={form} path={['volume']}>
  {(field) => (
    <Input.Wrapper label="Volume" error={field.errors?.[0]}>
      <Slider {/* ... */} />
    </Input.Wrapper>
  )}
</Field>

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

Login form example

This complete example combines native inputs and a checkbox with accessible errors:

import { Field, Form, type SubmitHandler, useForm } from '@formisch/react';
import { Button, Checkbox, PasswordInput, TextInput } from '@mantine/core';
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 default function LoginPage() {
  const loginForm = useForm({
    schema: LoginSchema,
    initialInput: {
      email: '',
      password: '',
      rememberMe: false,
    },
  });

  const handleSubmit: SubmitHandler<typeof LoginSchema> = (output) => {
    console.log(output);
  };

  return (
    <Form of={loginForm} onSubmit={handleSubmit}>
      <Field of={loginForm} path={['email']}>
        {(field) => (
          <TextInput
            {...field.props}
            value={field.input ?? ''}
            type="email"
            label="Email"
            placeholder="jane@example.com"
            autoComplete="email"
            error={field.errors?.[0]}
          />
        )}
      </Field>

      <Field of={loginForm} path={['password']}>
        {(field) => (
          <PasswordInput
            {...field.props}
            value={field.input ?? ''}
            label="Password"
            autoComplete="current-password"
            error={field.errors?.[0]}
          />
        )}
      </Field>

      <Field of={loginForm} path={['rememberMe']}>
        {(field) => (
          <Checkbox
            {...field.props}
            checked={field.input ?? false}
            label="Remember me"
            error={field.errors?.[0]}
          />
        )}
      </Field>

      <Button type="submit" disabled={loginForm.isSubmitting}>
        Login
      </Button>
    </Form>
  );
}

The initial input keeps every control defined from its first render. All three fields use the field.props spread because Mantine forwards their props and refs to native elements, including the checkbox.

Component reference

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

Text input

TextInput forwards to a native <input>, so spread field.props directly:

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

Textarea works the same way because it also renders a native element.

Checkbox

Checkbox also wraps a native input, so a boolean field only adds checked:

<Field of={form} path={['newsletter']}>
  {(field) => (
    <Checkbox
      {...field.props}
      checked={field.input ?? false}
      label="Newsletter"
      error={field.errors?.[0]}
    />
  )}
</Field>

Select

Select is controlled. Convert between Mantine's null and Formisch's undefined, and narrow the plain string value back to the schema union:

const frameworks = [
  { value: 'angular', label: 'Angular' },
  { value: 'react', label: 'React' },
  { value: 'solid', label: 'Solid' },
  { value: 'vue', label: 'Vue' },
];
<Field of={form} path={['framework']}>
  {(field) => (
    <Select
      name={field.props.name}
      ref={field.props.ref}
      autoFocus={field.props.autoFocus}
      onFocus={field.props.onFocus}
      onBlur={field.props.onBlur}
      value={field.input ?? null}
      onChange={(value) =>
        field.onChange((value ?? undefined) as typeof field.input)
      }
      label="Framework"
      placeholder="Select a framework"
      data={frameworks}
      error={field.errors?.[0]}
    />
  )}
</Field>

Radio group

Radio.Group is controlled through onChange(value). Its label and error props handle the group accessibility, while the individual Radio components wrap native inputs. Register the first radio so Formisch can move focus into the group:

<Field of={form} path={['plan']}>
  {(field) => (
    <Radio.Group
      name={field.props.name}
      value={field.input}
      onChange={(value) => field.onChange(value as 'hobby' | 'pro')}
      label="Plan"
      error={field.errors?.[0]}
    >
      <Radio
        value="hobby"
        label="Hobby"
        ref={field.props.ref}
        autoFocus={field.props.autoFocus}
        onFocus={field.props.onFocus}
        onBlur={field.props.onBlur}
      />
      <Radio value="pro" label="Pro" />
    </Radio.Group>
  )}
</Field>

Slider

Slider works with a single number, so field.onChange can be passed directly. It has no error prop, so wrap it in Input.Wrapper, and use thumbLabel for the accessible name because the visible wrapper label is not associated with the thumb.

Mantine's slider keeps its focusable thumb as a div and only renders a hidden input of type hidden, so there is no element Formisch can register. That means focus and submit-time error focusing cannot reach this field, while its value, validation and blur handling work normally. The thumb also carries only its accessible name: thumbProps is typed but not forwarded to the DOM, so the error text cannot be associated with it either:

<Field of={form} path={['volume']}>
  {(field) => (
    <Input.Wrapper label="Volume" error={field.errors?.[0]}>
      <Slider
        name={field.props.name}
        value={field.input ?? 50}
        onChange={field.onChange}
        onFocus={field.props.onFocus}
        onBlur={field.props.onBlur}
        thumbLabel="Volume"
        min={0}
        max={100}
      />
    </Input.Wrapper>
  )}
</Field>

Library-specific notes

Mantine's own use-form hook and its getInputProps helper are not needed with Formisch. The Field component provides the same wiring through its render prop, with validation coming from your Valibot schema.

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