Skip to content
This repository has been archived by the owner on Oct 1, 2024. It is now read-only.

Latest commit

 

History

History

react-form

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 

@shopify/react-form

Caution

@shopify/react-form is deprecated.

Shopifolk, see Shopify/quilt-internal for information on the latest packages available for use internally.

Build Status Build Status License: MIT npm version npm bundle size (minified + gzip)

Manage React forms tersely and safely-typed with no magic using React hooks. Build up your form logic by combining hooks yourself, or take advantage of the smart defaults provided by the powerful useForm hook.

Table of contents

  1. Installation
  2. Usage
    1. Quickstart
    2. Composition
  3. API
    1. Hooks
      1. useField
      2. useChoiceField
      3. useList
      4. useDynamicList
      5. useForm
      6. useDirty
      7. useReset
      8. useSubmit
    2. Validation
      1. inline
      2. multiple
      3. dependencies
      4. built-in
      5. validator
    3. Utilities
      1. reduceFields
      2. getValues
      3. getDirtyValues
      4. fieldsToArray
      5. makeCleanFields
  4. FAQ

Installation

yarn add @shopify/react-form

Usage

Quickstart

This package exports a variety of hooks for all things form state, but the quickest way to get up and running is with the hooks useForm and useField.

import {useForm, useField} from '@shopify/react-form';

By passing useForm a dictionary of field objects generated by useField calls, you can build out a fully featured form with minimal effort.

import React from 'react';
import {useField, useForm} from '@shopify/react-form';

function MyComponent() {
  const {
    fields: {title},
    submit,
    submitting,
    dirty,
    reset,
    submitErrors,
    makeClean,
  } = useForm({
    fields: {
      title: useField('some default title'),
    },
    onSubmit: async (fieldValues) => {
      return {status: 'fail', errors: [{message: 'bad form data'}]};
    },
  });

  const loading = submitting ? <p className="loading">loading...</p> : null;
  const errors =
    submitErrors.length > 0 ? (
      <p className="error">{submitErrors.join(', ')}</p>
    ) : null;

  return (
    <form onSubmit={submit}>
      {loading}
      {errors}
      <div>
        <label htmlFor="title">
          Title
          <input
            id="title"
            name="title"
            value={title.value}
            onChange={title.onChange}
            onBlur={title.onBlur}
          />
        </label>
        {title.error && <p className="error">{title.error}</p>}
      </div>
      <button type="button" disabled={!dirty} onClick={reset}>
        Reset
      </button>
      <button type="submit" disabled={!dirty} onClick={submit}>
        Submit
      </button>
    </form>
  );
}

The hooks provided here also work swimmingly with @shopify/polaris.

import React from 'react';
import {useField, useForm, notEmpty, lengthMoreThan} from '@shopify/react-form';

import {
  Page,
  Layout,
  FormLayout,
  Form,
  Card,
  TextField,
  ContextualSaveBar,
  Frame,
  Banner,
} from '@shopify/polaris';

export default function MyComponent() {
  const {fields, submit, submitting, dirty, reset, submitErrors, makeClean} =
    useForm({
      fields: {
        title: useField({
          value: '',
          validates: [
            notEmpty('Title is required'),
            lengthMoreThan(3, 'Title must be more than 3 characters'),
          ],
        }),
        description: useField(''),
      },
      async onSubmit(form) {
        const remoteErrors = []; // your API call goes here
        if (remoteErrors.length > 0) {
          return {status: 'fail', errors: remoteErrors};
        }

        return {status: 'success'};
      },
    });

  const contextBar = dirty ? (
    <ContextualSaveBar
      message="Unsaved product"
      saveAction={{
        onAction: submit,
        loading: submitting,
        disabled: false,
      }}
      discardAction={{
        onAction: reset,
      }}
    />
  ) : null;

  const errorBanner =
    submitErrors.length > 0 ? (
      <Layout.Section>
        <Banner status="critical">
          <p>There were some issues with your form submission:</p>
          <ul>
            {submitErrors.map(({message}, index) => {
              return <li key={`${message}${index}`}>{message}</li>;
            })}
          </ul>
        </Banner>
      </Layout.Section>
    ) : null;

  return (
    <Frame>
      <Form onSubmit={submit}>
        <Page title="New Product">
          {contextBar}
          <Layout>
            {errorBanner}
            <Layout.Section>
              <Card sectioned>
                <FormLayout>
                  <TextField label="Title" {...fields.title} />
                  <TextField
                    multiline
                    label="Description"
                    {...fields.description}
                  />
                </FormLayout>
              </Card>
            </Layout.Section>
          </Layout>
        </Page>
      </Form>
    </Frame>
  );
}

Composition

useForm gives us a lot in a small package, but one of the main benefits of hooks is composition. If we prefer, we can build our pages with much more granular use of hooks. This is especially valuable if you only need some of the behaviours of useForm, or need some aspect of the state to be managed differently.

import React from 'react';
import {
  useField,
  useReset,
  useDirty,
  useSubmit,
  notEmpty,
  lengthMoreThan,
} from '@shopify/react-form';

import {
  Page,
  Layout,
  FormLayout,
  Form,
  Card,
  TextField,
  ContextualSaveBar,
  Frame,
  Banner,
} from '@shopify/polaris';

export default function MyComponent() {
  const title = useField({
    value: '',
    validates: [
      notEmpty('Title is required'),
      lengthMoreThan(3, 'Title must be more than 3 characters'),
    ],
  });
  const description = useField('');
  const fields = {title, description};

  // track whether any field has been changed from its initial values
  const dirty = useDirty(fields);

  // generate a reset callback
  const reset = useReset(fields);

  // handle submission state
  const {submit, submitting, errors, setErrors} = useSubmit(
    async (fieldValues) => {
      const remoteErrors = []; // your API call goes here
      if (remoteErrors.length > 0) {
        return {status: 'fail', errors: remoteErrors};
      }

      return {status: 'success'};
    },
    fields,
  );

  const contextBar = dirty && (
    <ContextualSaveBar
      message="Unsaved product"
      saveAction={{
        onAction: submit,
        loading: submitting,
        disabled: false,
      }}
      discardAction={{
        onAction: reset,
      }}
    />
  );

  const errorBanner = errors.length > 0 && (
    <Layout.Section>
      <Banner status="critical">
        <p>There were some issues with your form submission:</p>
        <ul>
          {errors.map(({message}, index) => {
            return <li key={`${message}${index}`}>{message}</li>;
          })}
        </ul>
      </Banner>
    </Layout.Section>
  );

  return (
    <Frame>
      <Form onSubmit={submit}>
        <Page title="New Product">
          {contextBar}
          <Layout>
            {errorBanner}
            <Layout.Section>
              <Card sectioned>
                <FormLayout>
                  <TextField label="Title" {...fields.title} />
                  <TextField
                    multiline
                    label="Description"
                    {...fields.description}
                  />
                </FormLayout>
              </Card>
            </Layout.Section>
          </Layout>
        </Page>
      </Form>
    </Frame>
  );
}

API

This section details the individual functions exported by @shopify-react-form. For more detailed typing information see the .d.ts files.

Hooks

useField()

A custom hook for handling the state and validations of an input field.

Signature
const field = useField(config, validationDependencies);
Parameters:
  • config, The default value of the input, or a configuration object specifying both the value and validation config.
  • validationDependencies, An optional array of values for determining when to regenerate the field's validation callbacks. Any value that is referenced by a validator other than those passed into it should be included.
Return value:

A Field object representing the state of your input. It also includes functions to manipulate that state. Generally, you will want to pass these callbacks down to the component or components representing your input.

Examples

In its simplest form useField can be called with a single parameter for the default value of the field.

const field = useField('default value');

You can also pass a more complex configuration object specifying a validation function.

const field = useField({
  value: someRemoteData.title,
  validates: (title) => {
    if (title.length <= 3) {
      return 'Title must be longer than three characters';
    }
  },
});

You may also pass multiple validators.

const field = useField({
  value: someRemoteData.title,
  validates: [
    (title) => {
      if (title.length <= 3) {
        return 'Title must be longer than three characters';
      }
    },
    (title) => {
      if (!title.includes('radical')) {
        return 'Only radical items are allowed';
      }
    },
  ],
});

Generally, you will want to use the object returned from useField to handle state for exactly one form input.

const field = useField('default value');

const fieldError = field.error ? <p>{field.error}</p> : null;

return (
  <div>
    <label htmlFor="test-field">
      Test field{' '}
      <input
        id="test-field"
        name="test-field"
        value={field.value}
        onChange={field.onChange}
        onBlur={field.onBlur}
      />
    </label>
    {fieldError}
  </div>
);

If using @shopify/polaris or other custom components that support onChange, onBlur, value, and error props then you can accomplish the above more tersely by using the ES6 spread ... operator.

const title = useField('default title');
return <TextField label="Title" {...title} />;
Remarks

Reinitialization: If the value property of the field configuration changes between calls to useField, the field will be reset to use it as its new default value.

Imperative methods: The returned Field object contains a number of methods used to imperatively alter its state. These should only be used as escape hatches where the existing hooks and components do not make your life easy, or to build new abstractions in the same vein as useForm, useSubmit and friends.

useChoiceField()

An extension to useField() that produces a new field compatible with <Checkbox /> and <RadioButton /> from @shopify/polaris. Note that this hook cannot be used within a useForm, useChoiceField can only be used for standalone fields.

Signature

The signature is identical to useField() for boolean fields.

const simple = useChoiceField(false);
const complex = useChoiceField(config, validationDependencies);
Examples

Fields produced by useChoiceField operate just like normal fields, except they have been converted by asChoiceField automatically which swaps the value member for checked to provide compatibility with Checkbox and RadioButton.

const enabled = useChoiceField(false);

return <Checkbox label="Enabled" {...enabled} />;

For fields that need to be compatible with choice components on the fly, the asChoiceField utility function can be used instead to adapt the field for a specific composition.

const enabled = useField(false);

return <Checkbox label="Enabled" {...asChoiceField(enabled)} />;

asChoiceField()

A utility to convert a Field<Value> into a derivative that is compatible with <Checkbox /> and <RadioButton /> from @shopify/polaris. The value member will be replaced by a new checked member, and the onChange will be replaced with a choice component compatible callback.

Signature

asChoiceField consumes an existing Field<Value>, and optionally a checkedValue predicate when dealing with multi-value base fields.

const simpleField = useField(false);
const simple = asChoiceField(simpleField);

const multiField = useField<'A' | 'B'>('A');
const multiA = asChoiceField(multiField, 'A');
const multiB = asChoiceField(multiField, 'B');
Examples

asChoiceField is used as a helper to expand an existing field into a choice component (<Checkbox /> and <RadioButton />).

const enabled = useChoiceField(false);

return <Checkbox label="Enabled" {...enabled} />;

You can also expand an existing field directly into a choice component by wrapping the field in asChoiceField if you want to retain the original field's shape.

const enabled = useField(false);

return <Checkbox label="Enabled" {...asChoiceField(enabled)} />;

For multi-value base fields, we expand the same field into multiple <RadioButton> components.

const selectedOption = useField<'A' | 'B'>('A');

return (
  <Stack vertical>
    <RadioButton label="A" {...asChoiceField(selectedOption, 'A')} />
    <RadioButton label="B" {...asChoiceField(selectedOption, 'B')} />
    <RadioButton label="C" {...asChoiceField(selectedOption, 'C')} />
  </Stack>
);

asChoiceList()

A utility to convert a Field<Value> into a derivative that is compatible with <ChoiceList /> from @shopify/polaris. The value member will be replaced by a new selected member, and the onChange will be replaced with a choice list compatible callback.

Note: this only works for radio button groups, not checkbox groups; allowMultiple will be set to false.

Signature

asChoiceList consumes an existing Field<Value>.

enum Color {
  Red = 'red',
  Green = 'green',
}
const color = useField(Color.Red);

const choiceListProps = asChoiceList(color);
Examples

asChoiceList is used as a helper to expand an existing field into a choice component (<ChoiceList />).

enum Color {
  Red = 'red',
  Green = 'green',
}

const choices = [
  {label: 'Red', value: Color.Red},
  {label: 'Green', value: Color.Green},
];

const color = useField(Color.Red);
return <ChoiceList {...asChoiceList(color)} title="Color" choices={choices} />;

useList()

A custom hook for handling the state and validations of fields for a list of objects.

Signature
const fields = useList(config, validationDependencies);
Parameters:
  • config, A configuration object specifying both the value and validation config.
  • validationDependencies, An array of dependencies to use to decide when to regenerate validators.
Return value:

A list of dictionaries of Field objects representing the state of your input. It also includes functions to manipulate that state. Generally, you will want to pass these callbacks down to the component or components representing your input.

Examples

In its simplest form useList can be called with a single parameter with the list to derive default values and structure from.

const field = useList([
  {title: '', description: ''},
  {title: '', description: ''},
]);

You can also pass a more complex configuration object specifying a validation dictionary.

const field = useList({
  list: [
    {title: '', description: ''},
    {title: '', description: ''},
  ],
  validates: {
    title: (title) => {
      if (title.length > 3) {
        return 'Title must be longer than three characters';
      }
    },
    description: (description) => {
      if (description === '') {
        return 'Description is required!';
      }
    },
  },
});

Generally, you will want to use the list returned from useList by looping over it in your JSX.

function MyComponent() {
  const variants = useList([
    {title: '', description: ''},
    {title: '', description: ''},
  ]);

  return (
    <ul>
      {variants.map((fields, index) => (
        <li key={index}>
          <label htmlFor={`title-${index}`}>
            title{' '}
            <input
              id={`title-${index}`}
              name={`title-${index}`}
              value={fields.title.value}
              onChange={fields.title.onChange}
              onBlur={fields.title.onBlur}
            />
          </label>
          {field.title.error && <p>{field.title.error}</p>}
          <label htmlFor={`description-${index}`}>
            description{' '}
            <input
              id={`description-${index}`}
              name={`description-${index}`}
              value={fields.description.value}
              onChange={fields.description.onChange}
              onBlur={fields.description.onBlur}
            />
          </label>
          {field.description.error && <p>{field.description.error}</p>}
        </li>
      ))}
    </ul>
  );
}

If using @shopify/polaris or other custom components that support onChange, onBlur, value, and error props then you can accomplish the above more tersely by using the ES6 spread ... operator.

function MyComponent() {
  const variants = useList([
    {title: '', description: ''},
    {title: '', description: ''},
  ]);

  return (
    <ul>
      {variants.map((fields, index) => (
        <li key={index}>
          <TextField label="title" name={`title${index}`} {...fields.title} />
          <TextField
            label="description"
            id={`description${index}`}
            {...fields.description}
          />
        </li>
      ))}
    </ul>
  );
}
Remarks

Reinitialization: If the list property of the field configuration changes between calls to useList, the field will be reset to use it as its new default value.

Imperative methods: The returned Field objects contains a number of methods used to imperatively alter their state. These should only be used as escape hatches where the existing hooks and components do not make your life easy, or to build new abstractions in the same vein as useForm, useSubmit and friends.

useDynamicList()

A custom hook that adds on useList the ability to dynamically add, move and remove list items.

Using useDynamicList

Let's simulate a user interface that allows you add as many credit cards as you wish.

You can initialize useDynamicList the following way:

interface Card {
  id: string;
  cardNumber: string;
  cvv: string;
}

const emptyCardFactory = (): Card => ({
  id: Date.now().toString(),
  cardNumber: '',
  cvv: '',
});

const {
  fields,
  addItem,
  removeItem,
  removeItems,
  moveItem,
  reset,
  dirty,
  value,
  newDefaultValue,
  defaultValue,
} = useDynamicList([], emptyCardFactory);

You can also have a factory that produces multiple cards such as:

function generateId() {
  return Date.now().toString();
}

const emptyCardFactory = (): Card[] => {
  return [
    {id: generateId(), cardNumber: '', cvv: ''},
    {id: generateId(), cardNumber: '', cvv: ''},
  ];
};

const {
  fields,
  addItem,
  removeItem,
  removeItems,
  moveItem,
  reset,
  dirty,
  value,
  newDefaultValue,
  defaultValue,
} = useDynamicList([], emptyCardFactory);

AddItem can accept an argument which will be passed to the factory. In this case, the factory could look like:

const emptyCardFactory = ({id, cardNumber, cvv}: Partial<Card>): Card => ({
  id: id || Date.now().toString(),
  cardNumber: cardNumber || '',
  cvv: cvv || '',
});

// somwhere else you could call
addItem({cardNumber: '3131 3131 3131 3131'});

You can choose to initialize the list with no card (same as above) or with existing cards e.g. coming from your database:

const loadedCards = [
  {id: '123456', cardNumber: '4242 4242 4242 4242', cvv: '000'},
];

const {fields, addItem, removeItem, removeItems, moveItem, reset, dirty} =
  useDynamicList(loadedCards, emptyCardFactory);

You can also pass a more complex configuration object specifying a validation dictionary like in useList.

Rendering your dynamic list would look like this:

<FormLayout>
  {fields.map((field, index) => (
    <div key={field.id}>
      <TextField
        placeholder="Card Number"
        label="Card Number"
        value={field.cardNumber.value}
        onChange={field.cardNumber.onChange}
      />
      <TextField
        placeholder="CVV"
        label="CVV"
        value={field.cvv.value}
        onChange={field.cvv.onChange}
      />
      <Button onClick={() => removeItem(index)}>Remove</Button>
      <Button disabled={index === 0} onClick={() => moveItem(index, index - 1)}>
        Move Item Up
      </Button>
      <Button
        disabled={index === fields.length - 1}
        onClick={() => moveItem(index, index + 1)}
      >
        Move Item Down
      </Button>
      <hr />
    </div>
  ))}
  <Button onClick={() => removeItems([0])}>
    Remove multiple fields by index
  </Button>
  <Button onClick={() => addItem()}>Add Card</Button>
  <Button disabled={!dirty} onClick={reset}>
    Reset
  </Button>
</FormLayout>

You can use the newDefaultValue function to change the defaultValue of the dynamicList. This will also set the value and defaultValue property of the dynamicList to the newValue passed in.

const newValue = [
  {id: '123456', cardNumber: '4242 4242 4242 4242', cvv: '000'},
];
newDefaultValue(newValue);

You can iterate through the values and defaultValues of the dynamicList

value.map((card) => (
  <div>
    <p>Card number: {card.cardNumber}</p>
    <p>CVV : {card.cvv}</p>
  </div>
));

defaultValue.map((card) => (
  <div>
    <p>Card number: {card.cardNumber}</p>
    <p>CVV : {card.cvv}</p>
  </div>
));
How to use it with useForm

You can use useDynamicList with useForm in two ways:

You can directly pass one or several dynamic lists within fields. But note that the reset function returned by useForm won't reset the dynamic list to its initial value. It will only reset the value of each field for each item. So, if you remove an item, clicking on reset won't re-add it. Plus, adding or removing a field won't make the form dirty. The form will only be dirty if you edit the value of an item's field.

const {fields} = useDynamicList<Card>([], emptyCardFactory);

const form = useForm({
  fields: {
    customerCards: fields,
  },
  onSubmit: async (fieldValues) => {
    console.log(fieldValues);
    return submitSuccess();
  },
});

Or you can pass your dynamic lists within dynamicLists. This way the form reset and dirty will work as expected. Note that you still need to have one "classic field", fields cannot be optional at the moment. If you use TypeScript, you might want to pass generic types to useForm to make sure TypeScript infers the fieldValues types correctly within onSubmit.

const fields = {
  title: useField(''),
};

const customerCards = useDynamicList<Card>([], emptyCardFactory);

const form = useForm<typeof fields, typeof customerCards>({
  fields,
  dynamicLists: {
    customerCards,
  },
  onSubmit: async (fieldValues) => {
    console.log(fieldValues);
    return submitSuccess();
  },
});

useForm()

A custom hook for managing the state of an entire form. useForm wraps up many of the other hooks in this package in one API, and when combined with useField and useList, allows you to easily build complex forms with smart defaults for common cases.

Signature
const form = useForm(config);
Parameters:
  • config, An object has the following fields:

    • fields, A dictionary of Field objects, dictionaries of Field objects, and lists of dictionaries of Field objects. Generally, you'll want these to be generated by the other hooks in this package, either useField or useList. This will be returned back out as the fields property of the return value.
    • useDynamicList, optional dictionaries of DynamicList.
    • onSubmit, An async function to handle submission of the form. If this function returns an object of {status: 'fail', error: FormError[]} then the submission is considered a failure. Otherwise, it should return an object with {status: 'success'} and the submission will be considered a success. useForm will also call all client-side validation methods for the fields passed to it. The onSubmit handler will not be called if client validations fails.
    • makeCleanAfterSubmit, A boolean flag (defaults to false) indicating whether the form should "undirty" itself after a successful submission. If true, then the form fields' default values will be set to their submitted values after a successful submission. This is useful in the case where you'd want to submit the same form multiple times while "saving" the most recent submission as the new default state.
Return value:

An object representing the current state of the form, with imperative methods to reset, submit, validate, and clean. Generally, the returned properties correspond 1:1 with the specific hook/utility for their functionality.

Examples

useForm wraps one or more fields and return an object with all of the fields you need to manage a form.

import React from 'react';
import {useField, useForm} from '@shopify/react-form';

function MyComponent() {
  const {
    fields: {title},
    submit,
    submitting,
    dirty,
    reset,
    submitErrors,
    makeClean,
  } = useForm({
    fields: {
      title: useField('some default title'),
    },
    onSubmit: async (fieldValues) => {
      return {status: 'fail', errors: [{message: 'bad form data'}]};
    },
  });

  const loading = submitting ? <p className="loading">loading...</p> : null;

  const submitErrorContent =
    submitErrors.length > 0 ? (
      <p className="error">{submitErrors.join(', ')}</p>
    ) : null;

  const titleError = title.error ? (
    <p className="error">{title.error}</p>
  ) : null;

  return (
    <form onSubmit={submit}>
      {loading}
      {submitErrorContent}
      <div>
        <label for="title">Title</label>
        <input
          id="title"
          name="title"
          value={title.value}
          onChange={title.onChange}
          onBlur={title.onBlur}
        />
        {titleError}
      </div>
      <button disabled={!dirty} onClick={reset}>
        Reset
      </button>
      <button type="submit" disabled={!dirty}>
        Submit
      </button>
    </form>
  );
}

As with useField and useList, useForm plays nicely out of the box with @shopify/polaris.

import React from 'react';
import {
  Page,
  TextField,
  FormLayout,
  Card,
  Layout,
  Form,
  ContextualSaveBar,
  Frame,
  Banner,
} from '@shopify/polaris';
import {useField, useForm} from '@shopify/react-form';

function MyComponent() {
  const {fields, submit, submitting, dirty, reset, submitErrors, makeClean} =
    useForm({
      fields: {
        title: useField('some default title'),
        description: useField('some default description'),
      },
      onSubmit: async (fieldValues) => {
        return {status: 'fail', errors: [{message: 'bad form data'}]};
      },
    });

  const contextBar = dirty ? (
    <ContextualSaveBar
      message="New product"
      saveAction={{
        onAction: submit,
        loading: submitting,
        disabled: false,
      }}
      discardAction={{
        onAction: reset,
      }}
    />
  ) : null;

  const errorBanner =
    submitErrors.length > 0 ? (
      <Layout.Section>
        <Banner status="critical">
          <p>There were some issues with your form submission:</p>
          <ul>
            {submitErrors.map(({message}, index) => {
              return <li key={`${message}${index}`}>{message}</li>;
            })}
          </ul>
        </Banner>
      </Layout.Section>
    ) : null;

  return (
    <Frame>
      <Form onSubmit={submit}>
        <Page title={pageTitle}>
          {contextBar}
          <Layout>
            {errorBanner}
            <Layout.Section>
              <Card>
                <Card.Section>
                  <FormLayout>
                    <TextField label="Title" {...fields.title} />
                    <TextField
                      multiline
                      label="Description"
                      {...fields.description}
                    />
                  </FormLayout>
                </Card.Section>
              </Card>
            </Layout.Section>
          </Layout>
        </Page>
      </Form>
    </Frame>
  );
}

You can also configure fields ahead of time and pass them in to useForm afterwards. This is useful when you need one field to depend upon another for validation.

import React from 'react';
import {
  Page,
  TextField,
  FormLayout,
  Card,
  Layout,
  Form,
  ContextualSaveBar,
  Frame,
  Banner,
} from '@shopify/polaris';
import {useField, useForm} from '@shopify/react-form';

function MyComponent() {
  const title = useField('');
  const price = useField(
    {
      value: '0.00',
      validates: (value) => {
        if (title.value.includes('expensive') && parseFloat(value) < 1000) {
          return 'Expensive items must cost more than 1000 dollars';
        }
      },
    },
    [title.value],
  );

  const {submit, submitting, dirty, reset, submitErrors, makeClean} = useForm({
    fields: {title, description},
    onSubmit: async (fieldValues) => {
      return {status: 'fail', errors: [{message: 'bad form data'}]};
    },
  });

  const contextBar = dirty ? (
    <ContextualSaveBar
      message="New product"
      saveAction={{
        onAction: submit,
        loading: submitting,
        disabled: false,
      }}
      discardAction={{
        onAction: reset,
      }}
    />
  ) : null;

  const errorBanner =
    submitErrors.length > 0 ? (
      <Layout.Section>
        <Banner status="critical">
          <p>There were some issues with your form submission:</p>
          <ul>
            {submitErrors.map(({message}, index) => {
              return <li key={`${message}${index}`}>{message}</li>;
            })}
          </ul>
        </Banner>
      </Layout.Section>
    ) : null;

  return (
    <Frame>
      <Form onSubmit={submit}>
        <Page title={pageTitle}>
          {contextBar}
          <Layout>
            {errorBanner}
            <Layout.Section>
              <Card>
                <Card.Section>
                  <FormLayout>
                    <TextField label="Title" {...title} />
                    <TextField type="number" label="Price" {...price} />
                  </FormLayout>
                </Card.Section>
              </Card>
            </Layout.Section>
          </Layout>
        </Page>
      </Form>
    </Frame>
  );
}
Remarks
  • Building your own: Internally, useForm is a convenience wrapper over useDirty, useReset, validateAll, useSubmit, and propagateErrors.If you only need some of its functionality, consider building a custom hook combining a subset of them.
  • Subforms: You can have multiple useForms wrapping different subsets of a group of fields. Using this you can submit subsections of the form independently and have all the error and dirty tracking logic "just work" together.

useSubmit

useDirty

useReset

Docs for these standalone hooks are coming soon. For now check out the .d.ts files for the API.

Validation

Detailed validation docs are coming soon. For now check out the .d.ts files for the API.

Utilities

getDirtyValues

Takes FieldBag and returns object with only dirty field values, similar to the FormMapping argument passed to onSubmit.

Signature
function getDirtyValues<T extends FieldBag>(fieldBag: T) {
  return Object.entries(fieldBag).reduce(
    (acc, [fieldName, field]: [string, Field<any>]) => {
      return {
        ...acc,
        ...(field.dirty ? {[fieldName]: field.value} : {}),
      };
    },
    {},
  );
}

reduceFields

Similar to Array.reduce() it visits all fields in the form

Signature
function reduceFields<V>(
  fieldBag: FieldBag,
  reduceFn: (
    accumulator: V,
    currentField: Field<any>,
    path: (string | number)[],
    fieldBag: FieldBag,
  ) => V,
  initialValue?: V,
): V;
Parameters:
  • fieldBag is the collection of fields returned from useForm.
  • reduceFn is the reducer function to operate on each field. The return value will be passed onto the next iteration.
  • initialValues (optional) the starting value passed to the first iteration of the reducerFn.
  • reduceEmptyFn (optional) is a reducer function that acts on non-field values such as empty array, empty object, and primitives.
Return value:

A value returned by reduceFn iterating through all the fields in the form, and reduceEmptyFn for all empty or primitive values.

Examples

Here is how to calculate if the entire form is dirty (though in practice you could use useDirty to the same effect):

const {fields} = useForm(/* ... */);

const dirty = reduceFields(
  fields,
  (_dirty, field) => _dirty || field.dirty,
  false,
);

getValues

Gets a form's field values like you get in onSubmit.

Signature
function getValues<T extends object>(fieldBag: FieldBag): T;
Parameters:
  • fieldBag is the collection of fields returned from useForm.
Return value:

Field values from the form.

Examples

Here is how to get the values from a nested field object:

const {fields} = useForm({
  fields: {
    name: {
      first: useField('your'),
      last: useField('name'),
    },
    email: useField('[email protected]'),
  },
});

const formValues = getValues(fields);
// => { name: {first: 'your', last: 'name'}, email: '[email protected]' }

fieldsToArray

Normalizes nested fields to array of fields.

Signature
function fieldsToArray(fieldBag: FieldBag): Field[];
Parameters:
  • fieldBag is the collection of fields returned from useForm.
Return value:

Array of fields.

Examples
const firstName = useField('your');
const lastName = useField('name');
const email = useField('[email protected]');
const {fields} = useForm({
  fields: {
    name: {
      first: firstName,
      last: lastName,
    },
    email,
  },
});

const formValues = fieldsToArray(fields);
// Note: Ordering of fields is not guaranteed.
// => [firstName, lastName, email]

makeCleanFields

Resets dirty state for a collection of fields while maintaining the current field values. Sets all fields' default values to be their current values. Meant for use cases not covered by configuring useForm or useSubmit.

Signature
function makeCleanFields(fieldBag: FieldBag): void;
Parameters
  • fieldBag is the collection of fields returned from useForm.
Examples
const fields = {
  field1: useField('field1 default'),
  field2: useField('field2 default'),
  field3: useField('field3 default'),
};
const {
  fields: {field1, field2, field3},
  dirty,
  submit,
} = useForm({
  fields,
  onSubmit: async (values) => {
    console.log('You submitted the form!', values);
    if (someCondition) {
      makeCleanFields(fields); // set form state to 'clean'
    }
    return submitSuccess();
  },
  makeCleanAfterSubmit: false, // just for illustrative purposes, the default is false
});

FAQ

Q: Why yet another form library? Why not just include a hook version in @shopify/react-form-state? A: It turns out that hooks enable a much more extensible and composable paradigm than render props did. they also have some limitations that the render prop API does not. As such its difficult to build the best API we can for a hooks world and still have it match up to the old monolithic <FormState /> model. Since we use <FormState /> in production and will need to keep it around in maintenance mode for the foreseeable future, it makes sense to have this library available as its own import. Apart from the clean composability of hooks, the rationale otherwise remains much the same as <FormState />'s

Q: When should I use <FormState /> from @shopify/react-form-state instead of this? A: @shopify/react-form-state is now in maintenance mode only as of the release of this library. That means you're encouraged to use this library for any new work.

Q: How can I revalidate fields when a different field changes? A: Part of the fun of hooks is how easy it is to compose different ones together. Since our fields All have runValidation methods, you can easily use the built in useEffect hook to invoke a field's validation whenever any arbitrary value is changed.

import React from 'react';
import {TextField} from '@shopify/polaris';
import {useField, useForm} from '@shopify/react-form';

function MyForm() {
  const title = useField({
    value: '',
  });

  const price = useField(
    {
      value: '2.00',
      validates: (value) => {
        if (title.value.includes('expensive') && parseFloat(value) < 1000) {
          return 'Expensive items must be at least 1000 dollars.';
        }
      },
    },
    [title.value],
  );

  // whenever title changes we run the validator
  React.useEffect(price.runValidation, [title.value]);

  return (
    <div>
      <TextField {...title} />
      <TextField {...price} />
    </div>
  );
}

Q: I want to use this in a class component, how can I? A: The short answer is you can't. The long answer is with the magic of components. You can create a functional component that wraps your form hooks and calls a render prop with the generated fields. This approximates a component based API, and is more flexible to your specific needs than trying to have a monolithic version available as part of the library.

import React from 'react';
import {useField, useForm} from '@shopify/react-form';

function ProductForm({children, data}) {
  const title = useField(data.title);
  const description = useField(data.description);

  const form = useForm({
    title,
    description,
  });

  return <form onSubmit={form.submit}>{children(form)}</form>;
}

Q: I want to dynamically change how many fields I render, how can I do that? A: It's tough to do this without breaking the rules of hooks. A possible solution is building a component encapsulating your hook calls, such as above, and then using a key to reset it when you need to. Another is 'faking' it by simply not rendering all of your fields despite always initializing the same number. Finally, you could build a custom reducer-based hook similar to useList that can support adding and removing fields dynamically. As long as it returns Field objects matching the API that the hooks in this library do, you will be able to use it with useForm and friends just fine.

Q: How come {Feature X} is not supported? A: If you feel strongly about any features which are not part of this library, please open an issue or pull request.