Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

RFC: Form primitive #1977

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .yarn/versions/f59d7106.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
declined:
- primitives
301 changes: 301 additions & 0 deletions rfcs/2023-radix-form-primitive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,301 @@
- Start Date: 2023-02-17
- RFC PR: (leave this empty, to be filled in later)
- Authors: Benoît Grélard

# Radix Form primitive

## Summary

This RFC proposes adding a new primitive to Radix UI primitives: `Form`. It will provide an easier way to create forms in React. The goal is to provide a simple, declarative, uncontrolled (but controllable) API (à la Radix) to create forms that includes client-side validation in an accessible manner, as well as handling of server errors accessibly too.

## Motivation

Forms are a huge part of building for the web, and whilst we do offer building blocks for it (things like `Checkbox`, `Select`, etc) we do not yet have an overarching solution for creating accessible forms. This RFC aims to solve that.

Additionally, similarly to how Radix introduced a great out of the box experience for specific components (not having to wire state or refs for example) we see a big opportunity to do the same for forms as this is a space where we see a lot of people struggle or overcomplicate things.

At [WorkOS](https://workos.com), we have also been working on components specifically geared towards building great authentication experiences (see [Radix Auth RFC](https://github.com/radix-ui/primitives/pull/1978)). As these rely heavily on forms, we thought it would make sense to provide some fundations in Radix to help with this.

## Detailed design

At the most basic level, the `Form` primitive is a regular Radix primitives package which expose a component split in multiple parts like other primitives. It offers a declarative way to use controls and labels, as well as a validation rules that are built on top of the native browser [constraint validation API](https://developer.mozilla.org/en-US/docs/Web/HTML/Constraint_validation).

It abstracts away all of the complexity of accessibility and validation, using standard HTML attributes, as well as aria attributes.

### API example

Let's look at an example:

```jsx
import * as React from 'react';
import * as Form from '@radix-ui/react-form';

function Page() {
return (
<Form.Root
onSubmit={(event) => {
// `onSubmit` only triggered if it passes client-side validation
const data = Object.fromEntries(new FormData(event.currentTarget));
console.log(data);
event.preventDefault();
}}
benoitgrelard marked this conversation as resolved.
Show resolved Hide resolved
>
<Form.Field name="name">
<Form.Label>Full name</Form.Label>
<Form.Control /> {/* renders a `input[type="text"]` by default */}
<Form.ClientMessage type="missingValue">Please enter your name.</Form.ClientMessage>
</Form.Field>

<Form.Field name="email">
<Form.Label>Email address</Form.Label>
<Form.Control type="email" />
<Form.ClientMessage type="missingValue">Please enter your email.</Form.ClientMessage>
<Form.ClientMessage type="typeMismatch">Please provide a valid email.</Form.ClientMessage>
</Form.Field>

<Form.Field name="age">
<Form.Label>Age</Form.Label>
<Form.Control type="number" min="18" max="57" step="1" />
<Form.ClientMessage type="missingValue">Please enter your age.</Form.ClientMessage>
<Form.ClientMessage type="rangeOverflow">You must be 18 or older.</Form.ClientMessage>
<Form.ClientMessage type="rangeUnderflow">You must be 57 or younger.</Form.ClientMessage>
<Form.ClientMessage type="stepMismatch">Please enter a whole number.</Form.ClientMessage>
benoitgrelard marked this conversation as resolved.
Show resolved Hide resolved
</Form.Field>

<Form.Submit>Submit</Form.Submit>
</Form.Root>
);
}
```

Note that there is not state present about the client, everything is uncontrolled by default (yet can be controlled). Also note that there are no attributes necessary to handle accessibility, this is all handled by the primitive:

- label and controls are associated using the `name` provided on `Form.Field`.
- when one or more client-side error message display, they are automatically associated to their matching control and focus is moved to the first invalid control.

### Styling

Similar to other Radix primtives, the `Form` primitive is unstyled by default. This means that you can style it using any CSS solution of your choice. Each part is a node you can style. Like other primitives, data attributes are present to aid styling states such as `data-invalid` and `data-valid`.
benoitgrelard marked this conversation as resolved.
Show resolved Hide resolved

### Composition

Using Radix's `asChild` approach, you can compose the `Form` primitive parts with your own components.

```jsx
<Form.Field name="name">
<Form.Label>Full name</Form.Label>
<Form.Control asChild>
<TextField variant="primary" />
</Form.Control>
</Form.Field>
```

It can also be used to compose other types of controls, such as a `select`:

```jsx
<Form.Field name="country">
<Form.Label>Country</Form.Label>
<Form.Control asChild>
<select>
<option value="uk">United Kingdom</option>…
</select>
</Form.Control>
</Form.Field>
```

### More on validation

#### Providing your own validation messages

When no `children` are provided, `Form.ClientMessage` will render a default error message for the given `type`.

```jsx
<ClientMessage type="missingValue" /> // will yield "This value is missing."
```

You can provide a more meaningful message by providing your own `children`. This also allows for internationalization.

```jsx
<ClientMessage type="missingValue">Please provide a name.</ClientMessage> // will yield "Please provide a name."
```

#### Client-side validation types

`Form.ClientMessage` accepts a required `type` prop which is used to determine when the message should show. It matches the native HTML validity state (`ValidityState` on [MDN](https://developer.mozilla.org/en-US/docs/Web/API/ValidityState)) which result from using attributes such as `required`, `min`, `max`. The message will show if the given `type` is `true` on the control’s validity state.

```ts
// `type` is one of the following keys from the DOM ValidityState interface:

interface ValidityState {
readonly badInput: boolean;
readonly customError: boolean;
readonly patternMismatch: boolean;
readonly rangeOverflow: boolean;
readonly rangeUnderflow: boolean;
readonly stepMismatch: boolean;
readonly tooLong: boolean;
readonly tooShort: boolean;
readonly typeMismatch: boolean;
readonly valid: boolean;
readonly valueMissing: boolean;
}
```

This means you can even show something whe the field is valid:

```jsx
<Form.ClientMessage type="valid">✅ Valid!</Form.ClientMessage>
```

#### Custom validation

On top of all the built-in client-side validation types described above you can also provide your own custom validation whislt still making use of the platform's validation abilities. It uses the `customError` type present in the constraint validity API.

You can pass `type="customError"` on `Form.ClientMessage` and provide an `isValid` prop with your custom validation logic.

> `isValid` is called with the current value of the control and the current values of all other controls in the form.

Here's a contrived example:

```jsx
<Form.Field name="name">
<Form.Label>Full name</Form.Label>
<Form.Control />
<Form.ClientMessage type="customError" isValid={(value, fields) => value === 'John'}>
Copy link

@peduarte peduarte Feb 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isValid seems like a boolean prop at first glance, instead of a function. How about validator?

Copy link
Contributor Author

@benoitgrelard benoitgrelard Feb 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a good point, we can check prior art too. react-hook-forms seem to use validate for custom validation, so perhaps we can go with this too, it definitly is better as a verb I agree.

Only John is allowed.
</Form.ClientMessage>
</Form.Field>
```

> `isValid` can also be an `async` function (or return a promise) to perform async validation (see type below):

```ts
type CustomValidatorFn = ValidatorSyncFn | ValidatorAsyncFn;
type ValidatorSyncFn = (value: string, fields: FormFields) => boolean;
type ValidatorAsyncFn = (value: string, fields: FormFields) => Promise<boolean>;
type FormFields = { [index in string]?: FormDataEntryValue };
benoitgrelard marked this conversation as resolved.
Show resolved Hide resolved
```

#### Accessing the validity state for even more control

Sometimes, you may need to access the raw validity state of a field in order to display your own icons, or interface with your own component library by passing certain props to it. You can do this by using the `Form.ValidityState` part:

```jsx
<Form.Field name="name">
<Form.Label>Full name</Form.Label>
<Form.ValidityState>
{(validity) => (
<Form.Control asChild>
<TextField variant="primary" state={getTextFieldState(validity)} />
</Form.Control>
)}
Comment on lines +196 to +200

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Radix got so far without having to resort to render props — why add this pattern now?

Couldn't it be a component instead?

<Form.ValidityState value="success">
  ... success state ...
</Form.ValidityState>

Copy link
Contributor Author

@benoitgrelard benoitgrelard Feb 24, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey Paco, sure a component-based API could be created to render based on certain state matching (pretty much a simpler version of ClientMessage) but sometimes you do need more access.

ie. the case you highlighed above where your DS expects a prop for some variant or state.

Worth noting also that although we we haven't historically used render props, we are not completely opposed to it. It depends what the use-case is and if there are other technical solution with less API surface. For most of the states, we do believe a controlled prop fulfills the need.

With validity though, that isn't something that can be "controlled". However, we could follow the same logic and expose a callback for when the validity of a field changes so you have access to it potentially?

I do feel like a render prop is a good fit for this particular case though.

Do you have specific concerns about it?

</Form.ValidityState>
</Form.Field>
```

> Note: `Form.ValidityState` should be nested inside a `Form.Field` part and gives you access to the validity of that field.

#### Server-side validation
benoitgrelard marked this conversation as resolved.
Show resolved Hide resolved

The component also supports server-side validation via `Form.ServerMessage`.

Given that the server logic is completely outside of the scope of this component, the errors are provided to it using a controlled API: `serverErrors` and `onServerErrorsChange`: It will display accordingly to the errors passed into `serverErrors` on `Form.Root` (typically mapped from the actual errors returned by your server call). When inside a `Field` part, it will display the errors matching that field (`serverErrors[fieldName]`) When outside a `Field` part, it will display global errors that aren't tied to a specific field (`serverErrors.global`).

Similary to `Form.ClientMessage`, all accessibility relating to server errors is handled by the primitive:

- when one or more server-side error message display, they are automatically associated to their matching control and focus is moved to the first invalid control.
- when there are only global server errors, focus is moved to the submit button and the message is associated with it so screen readers announce it.

Let's see the same example as above but with added server-side error handling:

```jsx
import * as React from 'react';
import * as Form from '@radix-ui/react-form';

function Page() {
const [serverErrors, setServerErrors] = React.useState({});

return (
<Form.Root
onSubmit={(event) => {
// `onSubmit` only triggered if it passes client-side validation
event.preventDefault();
const data = Object.fromEntries(new FormData(event.currentTarget));

// maybe do an async server call and gather some errors from the server to display
submitForm(data)
.then(() => …)
/**
* Map errors from your server response into the expected format of `serverErrors` (see type below)
* For example:
* {
* email: [ { code: 'email_invalid', message: 'This is not a valid email.' } ],
* global: [ { code: 'server_error', message: 'Something went wrong.' }]
* }
*/
.catch((errors) => setServerErrors(mapServerErrors(errors)));
}}
serverErrors={serverErrors}
onServerErrorsChange={setServerErrors}
>
<Form.Field name="name">
<Form.Label>Full name</Form.Label>
<Form.Control />
<Form.ClientMessage type="missingValue">Please enter your name.</Form.ClientMessage>
<Form.ServerMessage />
</Form.Field>

<Form.Field name="email">
<Form.Label>Email address</Form.Label>
<Form.Control type="email" />
<Form.ClientMessage type="missingValue">Please enter your email.</Form.ClientMessage>
<Form.ClientMessage type="typeMismatch">Please provide a valid email.</Form.ClientMessage>
<Form.ServerMessage /> {/* will yield "This is not a valid email." */}
</Form.Field>

<Form.Field name="age">
<Form.Label>Age</Form.Label>
<Form.Control type="number" min="18" max="57" step="1" />
<Form.ClientMessage type="missingValue">Please enter your age.</Form.ClientMessage>
<Form.ClientMessage type="rangeOverflow">You must be 18 or older.</Form.ClientMessage>
<Form.ClientMessage type="rangeUnderflow">You must be 57 or younger.</Form.ClientMessage>
<Form.ClientMessage type="stepMismatch">Please enter a whole number.</Form.ClientMessage>
<Form.ServerMessage />
</Form.Field>

<Form.Submit>Submit</Form.Submit>

<Form.ServerMessage /> {/* will yield "Something went wrong." */}
benoitgrelard marked this conversation as resolved.
Show resolved Hide resolved
</Form.Root>
);
}
```

If no `children` are provided to `Form.ServerError`, it will display a concatenation of error messages from the server. If passing a React node, it will render this node instead. For finer control, you can pass a render function instead which will be called with the server errors.

The server messages will be routed to the correct fields, based on the `name` attribute on `Form.Field` matching in the `serverErrors` object (see type below). There is also a `global` key which will be used for global errors (displayed in the `Form.ServerMessage` sitting outside a `Form.Field`).

```ts
interface ServerError {
code: string;
message: React.ReactNode;
}

type ServerErrors = {
[fieldName in string]?: ServerError[];
} & {
global?: ServerError[];
};
benoitgrelard marked this conversation as resolved.
Show resolved Hide resolved
```

## Open questions

- Does this API make sense?
- Is it easy to use?
- Is everything named as you'd expect?
- What about the distinction between `ClientMessage` and `ServerMessage`?
- Would you call this something else?
- Does the server side stuff make sense?
- Would you call `serverErrors` something else as it's not directly server errors, but a structure wanted by the UI
- Does this API cover most form use-cases? Have we missed some use-cases?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q. How should I express different validations when one field is dependent on another?

For example, if I have a country field and an age field, as shown below, and I need the max/min value of age to be different depending on the value of the country field, how can I use it?

<Form.Field name="country">
  <Form.Label>Country</Form.Label>
  <Form.Control asChild>
    <select>
      <option value="uk">United Kingdom</option>
      <option value="us">United States</option>
    </select>
  </Form.Control>
</Form.Field>

<Form.Field name="age">
  <Form.Label>Age</Form.Label>
  <Form.Control type="number" min="18" max="57" step="1" />
</Form.Field>

Copy link
Contributor

@jjenzz jjenzz Mar 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you can use react state as normal:

const [country, setCountry] = React.useState('uk');

<Form.Field name="country">
  <Form.Label>Country</Form.Label>
  <Form.Control asChild>
    <select value={country} onChange={event => setCountry(event.currentTarget.value)}>
      <option value="uk">United Kingdom</option>
      <option value="us">United States</option>
    </select>
  </Form.Control>
</Form.Field>

<Form.Field name="age">
  <Form.Label>Age</Form.Label>
  <Form.Control type="number" min={country === 'uk' ? 18 : 21} max="57" step="1" />
</Form.Field>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or you could also do it using a custom validation function as it has access to the entire form data so it can check other fields values.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the kind words!
As you said, can implement it intuitively using react state, but it would be more convenient to be able to handle dependent forms in an uncontrolled way :)

- Is the API flexible enough to allow composition with other components?