npm package: @reatom/form
Description: Reatom for form
Author: artalar
Latest version: 3.6.1

This is a general form library with a simple focus and validation management.

**The API is unstable and will be changed during minor updates!** Sorry for the semver mismatch, the first releases contained A LOT OF bugs and should have been released with an "experimental" tag. Until this note is present here, please don't consider the library stable.

The form API is designed for the best type-safety and flexibility. Instead of setting up the form state with a single object, each field is created separately, giving you the ability to fine-tune each field perfectly. As the field and its meta statuses are stored in atoms, you can easily combine them, define hooks, and effects to describe any logic you need.

The cherry on the cake is dynamic field management. You don't need to use weird string APIs like `form.${index}.property`. Instead, you can simply have a list of fields using [atomization](https://www.reatom.dev/recipes/atomization/).

## Installation

```sh
npm i @reatom/form
```

## Usage

```ts
import { reatomForm, reatomField } from '@reatom/form'

// Create fields directly
export const nameField = reatomField('', 'nameField')
export const passwordField = reatomField('', {
  name: 'passwordField',
  validate(ctx, { state }) {
    if (state.length < 6)
      throw new Error('The password should have at least six characters.')
  },
})

// Create a form with the fields
export const loginForm = reatomForm(
  {
    username: nameField,
    password: passwordField,
  },
  {
    name: 'form',
    async onSubmit(ctx, state) {
      //                ^? ParseAtoms<typeof loginForm.fields>
      const user = await api.login({
        name: state.username,
        password: state.password,
      })
    },
  },
)
```

### Form with Schema Validation

The `schema` option supports any schema that implements the [Standard Schema interface](https://github.com/standard-schema/standard-schema)

```ts
import { reatomForm, fieldArray } from '@reatom/form'
import { z } from 'zod'

// Create a form with schema validation
const userForm = reatomForm(
  {
    username: '',
    email: '',
    addresses: fieldArray([
      {
        street: '',
        city: '',
        zipCode: '',
      },
    ]),
  },
  {
    name: 'form',
    // Schema validation using zod
    schema: z.object({
      username: z.string().min(3, 'Username must be at least 3 characters'),
      email: z.string().email('Invalid email format'),
      addresses: z.array(
        z.object({
          street: z.string().min(1, 'Street is required'),
          city: z.string().min(1, 'City is required'),
          zipCode: z.string().regex(/^\d{5}$/, 'Invalid zip code format'),
        }),
      ),
    }),
    async onSubmit(ctx, state) {
      //                ^? z.infer<typeof schema>
    },
  },
)
```

## Field Initialization

When creating a form, you can initialize fields in several ways:

### Field Initialization Options

1. **Primitive Values**:

By passing a primitive value, you implicitly initialize a reatomField with that value as its default.
This way is suitable when no individual options are needed for the field.

```ts
const form = reatomForm(
  {
    username: '', // String field
    age: 25, // Number field
    isActive: true, // Boolean field
    birthDate: new Date(), // Date field
  },
  'form',
)
```

2. **Field Options Object**:

By passing an object with `initState` property, you can initialize a reatomField with a value as its default, and pass additional options for the field.

```ts
const form = reatomForm(
  {
    username: {
      initState: '',
      validateOnChange: true,
      validate: (ctx, { state }) =>
        state.length < 3 ? 'too short' : undefined,
    },
    age: {
      initState: 25,
      validateOnBlur: true,
    },
  },
  'form',
)
```

3. **Existing `reatomField` Instances**:

```ts
const usernameField = reatomField('', 'usernameField').extend(
  withLocalStorage(),
)
const ageField = reatomField(25, 'ageField')

const form = reatomForm(
  {
    username: usernameField,
    age: ageField,
  },
  'form',
)
```

You are free to attach existing fields to the form. However, note that in this case, the fields will not receive naming scoped to the form domain.
For better debbuging experience, it is recommended to initialize fields directly within the form's field tree, initializing the name similar to how it is done in factories.
For this purpose, the form's initState accepts a callback with the `name` parameter.

```ts
import { reatomForm, reatomField } from '@reatom/form'

const form = reatomForm(
  (name) => ({
    username: reatomField('', `${name}.username`),
    password: reatomField('', `${name}.password`),
  }),
  'form',
)
```

4. **Atoms Extended with `withField`**:

Also, you can extend existing smart atoms with the field functionality. In this case, the atom itself will become the state of the field, and any methods that mutate the extended atom will change the state of this field.

```ts
import { reatomBoolean } from '@reatom/primitives'
import { reatomForm, withField } from '@reatom/form'

const form = reatomForm(
  (name) => ({
    active: reatomBoolean(false, `${name}.active`).pipe(withField()),
  }),
  'form',
)
```

## Array Fields

There are several ways to initialize array fields:

1. **Simple Array Literals**:

```ts
const form = reatomForm(
  {
    // Empty array field
    tags: [],

    // Array with default item as a string field
    emails: ['default@example.com'],

    // Array with default item as a string field through options definition
    phones: [{ initState: '123-456-7890', validateOnChange: true }],
  },
  'form',
)
```

2. **Empty Array with Type Information**:

```ts
const form = reatomForm(
  {
    // Empty array with correct type information
    emails: new Array<string>(),
    contacts: new Array<{ name: string; phone: string }>(),
  },
  'form',
)
```

3. **Using fieldArray Function**:

```ts
const form = reatomForm(
  {
    // Empty array field
    tags: fieldArray<string>([]),

    // Array with default values
    emails: fieldArray(['default@example.com']),
  },
  'form',
)
```

4. **Complex Array Field Factory**:

If you want to configure the rules for creating fields in an array field, you should define a factory function using `fieldArray` function that describes how each new field in this array field will be created.

```ts
const form = reatomForm(
  {
    // Using initState and create
    phoneNumbers: fieldArray({
      initState: [{ number: '123-456-7890', priority: false }],
      create: (ctx, { number, priority }, name) => ({
        number: { initState: number, validateOnChange: true },
        priority: reatomBoolean(priority, `${name}.priority`).pipe(withField()),
      }),
    }),
  },
  'form',
)

form.fields.phoneNumbers.create(ctx, {
  number: '123-456-7890',
  priority: false,
})
```

### Basic Array Field Operations

Since `fieldArray` or array literal in the fields definition are a syntactic sugar over `reatomLinkedList`, it provides several methods to manipulate the array of fields:

- `create(value)`: Adds a new field with the given value to the end of the array
- `remove(field)`: Removes a specific field from the array
- `clear()`: Removes all fields from the array
- `array()`: Returns an array of all fields, which you should use to iterate over the fields
- `swap(field1, field2)`: Swaps the positions of two fields in the array
- `move(field, targetField)`: Moves a field to a position after the target field (use null to move to the beginning)
- `find(predicate)`: Finds a field in the array that matches the predicate function

When rendering field arrays in UI components, you should always use the `.array()` method to iterate over the fields.

```ts
import { reatomForm, fieldArray } from '@reatom/form'

const contactForm = reatomForm(
  {
    name: '',
    emails: fieldArray(['']), // Simple array of string fields
  },
  'form',
)

// Add a new email field
contactForm.fields.emails.create(ctx, '')

// Access the array of email fields
const emailFields = ctx.get(contactForm.fields.emails.array)

// Iterate over the fields to render them
// In React, this would look like:
// {emailFields.map((emailField) => (
//   <EmailFieldComponent key={emailField.name} field={emailField} />
// ))}

// Remove a specific email field
contactForm.fields.emails.remove(ctx, emailFields[0])

// Clear all email fields
contactForm.fields.emails.clear(ctx)
```

Since we use the "field as model" approach and each field is an object, we can achieve maximum type safety by working directly with objects. But the cherry on top is atomization, a principle used by array fields that allows maintaining a high-quality type-safe experience at any level of nesting in your forms.

### Nested Array Fields

You can also create nested array structures:

```ts
import { reatomForm, fieldArray } from '@reatom/form'

const userForm = reatomForm(
  {
    name: '',
    addresses: fieldArray([
      {
        street: '',
        city: '',
        tags: fieldArray(['home']),
      },
    ]),
  },
  'form',
)

// Access nested fields
const addresses = ctx.get(userForm.fields.addresses.array)
const firstAddressTags = ctx.get(addresses[0]?.tags.array)
```

## Fields sets

Field sets allow you to group related fields together and manage them as a single unit. This is useful for organizing complex forms into logical sections, such as steps in a multi-step form, or for tracking the combined state of multiple fields without creating a full form.

### Creating and Using Field Sets in Multi-step Forms

A common use case for field sets is creating multi-step forms where each step has its own validation and state management:

```ts
import { reatomForm, reatomFieldSet } from '@reatom/form'

const checkoutForm = reatomForm(
  {
    personal: {
      firstName: '',
      lastName: '',
      email: {
        initState: '',
        validate: ({ state }) =>
          invariant(state.includes('@'), 'Invalid email format'),
      },
    },
    shipping: {
      address: '',
      city: '',
      zipCode: {
        initState: '',
        validate: ({ state }) =>
          invariant(/^\d{5}$/.test(state), 'Invalid zip code format'),
      },
    },
  },
  'checkoutForm',
)

// Create field sets for each step
const personalSet = reatomFieldSet(
  checkoutForm.fields.personal,
  'checkoutForm.personalSet',
)
const shippingSet = reatomFieldSet(
  checkoutForm.fields.shipping,
  'checkoutForm.shippingSet',
)
```

Each field set (`personalInfoSet` and `shippingInfoSet`) provides access to:

- `fieldsState`: The combined state of all fields in the set
- `validation`: The combined validation status of all fields in the set
- `focus`: The combined focus status of all fields in the set
- Other methods like `reset`, `init` and properties as in the forms

Field sets are particularly useful for multi-step forms because they allow you to validate each step independently before allowing the user to proceed to the next step, or for reactive calculations for groups of fields.

## Recipes

By composing form and reatom primitives, you can solve long-standing problems in forms as effectively as other libraries do through configuration.

### Async default values

This recipe shows how to load form values from an API. It creates an async resource that fetches data and resets the form with the retrieved values when the request completes. The `pendingAtom` atom can be used to show a loading state.

```ts
import { reatomForm } from '@reatom/form'
import { reatomResource, withDataAtom } from '@reatom/async'

const profileForm = reatomForm(
  {
    username: '',
    address: '',
  },
  'profileForm',
)

const fetchFormValues = reatomResource(async (ctx) => {
  const response = await ctx.schedule(() => fetch('/api/profile'))
  return ctx.schedule(() => response.json())
}, 'fetchFormValues').pipe(withDataAtom(null))

fetchFormValues.onFulfill.onCall((ctx, defaultValues) => {
  if (defaultValues) profileForm.reset(ctx, defaultValues)
})

fetchFormValues.pendingAtom // Use this atom to show loading state
```

### Async validation debounce

This recipe implements debounced validation for a field. Thanks to reatom's concurrency mechanism, each new validation call automatically cancels the previous one. The field waits 300ms before making an API request to check if a username is already taken.

```ts
import { reatomField } from '@reatom/form'
import { sleep } from '@reatom/framework'

const usernameField = reatomField(
  '',
  {
    validate: async (ctx, { value }) => {
      await ctx.schedule(() => sleep(300))
      const response = await ctx.schedule(() =>
        fetch(`/api/usernames?username=${state}`, ctx.controller),
      )
      const { taken } = await ctx.schedule(() => response.json())
      invariant(!taken, 'This username already taken')
    },
  },
  'usernameField',
)
```

The `ctx.controller` provides an AbortController that automatically cancels previous fetch requests when a new validation is triggered, preventing race conditions and unnecessary network requests.

### Dependent validation

There are two approaches to implement validation that depends on other fields.

The first approach uses field-level validation. The `confirmPassword` field directly accesses the value of the `password` field to perform the comparison:

```ts
import { reatomForm, reatomField } from '@reatom/form'

const loginForm = reatomForm(
  (name) => ({
    username: reatomField('', `${name}.username`),
    password: reatomField('', `${name}.password`),
    confirmPassword: reatomField('', {
      name: `${name}.confirmPassword`,
      validate: (ctx, { value }) => {
        if (ctx.get(loginForm.fields.password.value) != value)
          throw new Error('Passwords do not match')
      },
    }),
  }),
  'loginForm',
)
```

The second approach uses schema-level validation with Zod. This centralizes validation logic in the schema and uses the `refine` method to add a custom validation rule:

```ts
import { reatomForm, reatomField } from '@reatom/form'
import { z } from 'zod'

const schema = z
  .object({
    username: z.string(),
    password: z.string(),
    confirmPassword: z.string(),
  })
  .refine((values) => values.password === values.confirmPassword, {
    message: 'Passwords do not match',
    path: ['confirmPassword'],
  })

const loginForm = reatomForm(
  (name) => ({
    username: reatomField('', `${name}.username`),
    password: reatomField('', `${name}.password`),
    confirmPassword: reatomField('', `${name}.confirmPassword`),
  }),
  {
    name: 'loginForm',
    schema,
  },
)
```

Cross errors are also supported. When validation is activated according to the scheme, errors are placed into the corresponding fields, prepending them directly to the `validation.errors` array of each field. With subsequent successful schema validation, these errors are excluded from this array.

```ts
const form = reatomForm(
  {
    min: 0,
    max: 10,
  },
  {
    validateOnChange: true,
    schema: z
      .object({
        min: z.number().min(0, 'must be minimum 0').max(20, 'must be up to 20'),
        max: z.number().min(0, 'must be minimum 0').max(20, 'must be up to 20'),
      })
      .superRefine(({ min, max }, ctx) => {
        if (min > max) {
          ctx.addIssue({
            code: z.ZodIssueCode.custom,
            path: ['min'],
            message: 'value "min" should be less than "max" value',
          })
          ctx.addIssue({
            code: z.ZodIssueCode.custom,
            path: ['max'],
            message: 'value "min" should be less than "max" value',
          })
        }
      }),
  },
)

form.fields.min.change(ctx, 11) // causing an error for both `min` and `max` fields
```

### Autofocus on error

This recipe demonstrates how to automatically focus on the first field with a validation error after a form submission fails. This improves user experience by directing their attention to the field that needs correction.

Each field has an `elementRef` property that can be used to store a reference to the DOM element associated with the field. The `elementRef` interface matches the `HTMLElement` interface, allowing you to call standard DOM methods like `focus()`.

```ts
import { reatomForm } from '@reatom/form'
import { z } from 'zod'
const form = reatomForm(
  {
    email: '',
    age: 12,
  },
  {
    schema: z.object({
      email: z.string().email(),
      age: z.number().min(18),
    }),
  },
)

form.submit.onReject.onCall((ctx) => {
  const errorField = ctx
    .get(form.fieldsList)
    .find((field) => !!ctx.get(field.validation).error)
  if (errorField) ctx.get(errorField.elementRef)?.focus()
})
```

You can extend the `elementRef` type using interface augmentation if you need to store additional data or custom properties. This is particularly useful when working with custom input components that might have their own API beyond the standard HTMLElement interface.

## Fieldset API

The fieldset created with `reatomFieldSet` has the following properties:

- `fields`: Object containing all the fields created for this fieldset.
- `fieldsState`: Atom with the state of the fieldset, computed from all the fields.
- `focus`: Atom with focus state of the fieldset, computed from all the fields.
- `init`: Action to initialize the fieldset with a partial state.
- `reset`: Action to reset the state, the value, the validation, and the focus states.
- `validation`: Atom with validation state of the fieldset, computed from all the fields.
  - `trigger`: Action to trigger fieldset validation. If there is at least one field in the set that has asynchronous validation, the `validating` property is expected to become a Promise, which will resolve when all nested validations are completed.

## Form API

`reatomForm` is a basically extension of fieldset, which provides submit and schema validation functionality.
Thus, the `reatomForm` gets all the same properties as the `reatomFieldSet`, but with the addition of:

- `submit`: Submit async handler. It checks the validation of all the fields, calls the form's `validate` options handler, and then the `onSubmit` options handler.
- `submitted`: Atom indicating if the form has been submitted.
- `validation`: Atom with validation state of the form, computed from all the fields.
  - `trigger`: Action to trigger form validation. Works like in fieldsets but it's always asynchronous, performs form validation via the `validate` function from the form options and finnaly returns schema validation result if it's defined

### Form Options

- `name`: The name of the form (optional, auto-generated if not provided).
- `onSubmit`: The callback to process valid form data.
- `resetOnSubmit`: Should reset the state after successful submit? (default: `true`).
- `validateBeforeSubmit`: The callback to validate form before `onSubmit` will be triggered.
- `schema`: A schema for validation (supports StandardSchemaV1 specification, like Zod, Valibot, etc).
- `validateOnChange`: Defines if validation should be triggered with every field change by default for all fields (default: `false`).
- `validateOnBlur`: Defines if validation should be triggered on field blur by default for all fields (default: `false`).
- `keepErrorDuringValidating`: Defines the default reset behavior of the validation state during async validation for all fields (default: `false`).
- `keepErrorOnChange`: Defines the default reset behavior of the validation state on field change for all fields (default: `!validateOnChange`).

### Form Validation Behavior

The `form.submit` call triggers the validation process of all related fields. If a schema is provided, it will be used to validate the form state. After that, the validation function from the options will be called. If there are no validation errors, the `onSubmit` callback in the options is called.

You can track the submitting process in progress using `form.submit.statusesAtom`.

## Field API

A field created with `reatomField` is an atom, and you can change it like a regular atom by calling it with the new value or reducer callback.

The field stores "state" data. However, there is an additional `value` atom that stores "value" data, which could be a different kind of state related to the UI. For example, for a select field, you might want to store the `string` "state" and `{ value: string, label: string }` "value," which will be used in the "Select" UI component.

Here is the list of all additional properties and methods:

- `initState`: The initial state of the field.
- `focus`: Record atom with all related focus statuses:
  - `active`: The field is focused.
  - `dirty`: The field's state is not equal to the initial state.
  - `touched`: The field has gained and lost focus at some point.
  - `in`: Action for handling field focus.
  - `out`: Action for handling field blur.
- `validation`: Record atom with all related validation statuses:
  - `error`: Computed message of the first error from errors array
    - `source`: The source of the error. The value will be `validation` if the error occurred due to the `validate` function of the field and `schema` if the error caused by schema validation
    - `message`: The text of the error
    - `meta`: The record with arbitrary information about the error like minimum chars, upper bound of a number, etc.
  - `triggered`: The validation actuality status. The standard schema does not support such met aattributes, so they only make sense if they are set manually in the `validate` functions.
  - `validating`: If asynchronous validation is running, it returns a promise which will be resolved after the validation and which will return a non-empty list of errors, if any.
  - `trigger`: Action to trigger field validation.
  - `errors`: Array of validation errors.
  - `clearErrors`: Action to clear all errors by passed sources.
- `value`: Atom with the "value" data, computed by the `fromState` option.
- `change`: Action for handling field changes, accepts the "value" parameter and applies it to `toState` option.
- `reset`: Action to reset the state, the value, the validation, and the focus.
- `disabled`: Atom that defines if the field is disabled.
- `elementRef`: Atom with an element reference. Should be synchronized with the actual DOM element.
- `options`: Record atom with all related field options:
  - `keepErrorDuringValidating`: Value that defines the reset behavior of the validation state during async validation.
  - `keepErrorOnChange`: Value that defines the reset behavior of the validation state on field change.
  - `validateOnChange`: Value that defines if the validation should be triggered with every field change.
  - `validateOnBlur`: Value that defines if the validation should be triggered on the field blur.

By combining these statuses you can derive additional meta information:

- `!touched && active` - the field got focus for the first time
- `touched && active` - the field got focus again

### Disabled Fields Behavior

When fields are disabled, they no longer automatically trigger their own validation. In field sets, these disabled fields are excluded from the `validation` and `focus` computations, meaning they are not considered in the validation process according to the schema. This ensures that disabled fields do not affect the validation status of the form or field set they belong to.

### Field Options

- `name`: The name of the field (optional, auto-generated if not provided).
- `filter`: The optional callback to filter "value" changes (from the 'change' action). It should return 'false' to skip the update. By default, it always returns `true`.
- `fromState`: The optional callback to compute the "value" data from the "state" data. By default, it returns the "state" data without any transformations.
- `isDirty`: The optional callback used to determine whether the "value" has changed. Accepts context, the new value and the old value. By default, it utilizes `isDeepEqual` from reatom/utils.
- `toState`: The optional callback to transform the "state" data from the "value" data from the `change` action. By default, it returns the "value" data without any transformations.
- `validate`: The optional callback to validate the field.
- `contract`: The optional callback to validate field contract.
- `disabled`: Defines if the field is disabled by default.
- `elementRef`: Defines a default element reference accosiated with the field.
- `keepErrorDuringValidating`: Defines the reset behavior of the validation state during async validation (default: `false`).
- `keepErrorOnChange`: Defines the reset behavior of the validation state on field change (default: `!validateOnChange`).
- `validateOnBlur`: Defines if the validation should be triggered on the field blur (default: `false`).
- `validateOnChange`: Defines if the validation should be triggered with every field change (default: `false`).

### Field Validation Behavior

You can set a validation function and manage validation triggers using the options. The validation flow works as follows:

1. When validation is triggered, the system checks if a validation function exists
2. If no validation function exists, the field is marked as valid
3. If a validation function exists, it is executed
4. If the validator throws an error, the field is marked as invalid with the error message
5. If the validator returns a promise (async validation):
   - The field is marked as validating
   - If `keepErrorDuringValidating` is true, the previous error is preserved during validation
   - If `keepErrorDuringValidating` is false, the error is cleared during validation
   - When the promise resolves, the field is marked as valid
   - When the promise rejects, the field is marked as invalid with the error message

```mermaid
flowchart TB
    trigger([Validation triggered])
    trigger --> hasfunction{Has validation function?}
    hasfunction --> |Yes| validate{Validate}
    hasfunction --> |No| valid[error: undefined, valid: true, validating: false]
    validate --> |Validator threw| invalid["return {error: string, valid: true, validating: false}"]
    validate --> |Validator returned a promise| promise[Promise]
    validate --> |Validator returned any other value| valid
    promise --> |Pending| keepErrorDuringValidating{keepErrorDuringValidating}
    keepErrorDuringValidating --> |true| keepErrorDuringValidatingTrue["set {error: previousError, valid: true, validating: true}"]
    keepErrorDuringValidating --> |false| keepErrorDuringValidatingFalse["set {error: undefined, valid: true, validating: true}"]
    promise --> |Fulfilled| valid
    promise --> |Rejected| invalid
```