React FlexyForm

Overview

Maintaining many complex forms (especially multi step ones) and having them follow the same priciples in a large project is not easy. Ideally, we would like to reuse the same components, styles, spacings, errors and common form-related logic in every form.

This package offers an abstraction for our forms to solve these problems. Additionally, it offers solutions in a standardized way for most of the typical form-related user flows that would be time consuming to implement from scratch.

The main idea is that every form should have the same state structure, the same way of consuming the state and use the same ways of modifying the state for the same features. This way we can create a set of components that can be reused in every form.

These components are not just for capturing user input, but they can also be used for triggering form-related events (for example buttons to change the current step, submitting, etc.) or displaying the form's steps, errors, loading indicators, etc.

To increase the consistency between our forms, we would use a type-safe configuration object to compose them.

This approach is more strict than a headless solution (such as TanStack Form or react-hook-form), but it offers a more consistent way of working with forms in a large project. While it might seem that this approach is too limiting, it offers ways to implement custom logic for edge cases.

Installation

Install the library via npm, yarn or pnpm.

npm install react-flexyform

Define the form components

In order to lay down the foundation for our project's form system, we need to define the components we will use in our forms.

The components that we will use in our forms will need to consume the state exposed by the react-flexyform state store (which is a zustand store in the background).

The most fundamental way to consume this state is by using the useParentFormStore in combination with the useFormComponentName hook, but there are more convenient ways like useField that selects only a slice of the state for a common use case.

Text field component example

Here is a basic example of a TextField component that is using the state from react-flexyform:

import { useField, useFormComponentParams } from 'react-flexyform'
 
type Params = {
  label?: string
  placeholder?: string
  containerClassName?: string
}
 
const FormIntegratedTextField = () => {
  const field = useField()
  const params = useFormComponentParams<Params>().value
 
  const formIntegratedInputControls = {
    id: field.configuration.id,
    name: field.configuration.name,
    value: field.state.value,
    onChange: field.methods.handleChange,
    onBlur: field.methods.handleBlur,
    placeholder: params.placeholder,
  }
 
  if (!field) {
    return null
  }
 
  return (
    <div className={params.containerClassName}>
      {params.label && (
        <label htmlFor={field.configuration.id} className="field-label">
          {params.label}
        </label>
      )}
      <input
        type="text"
        className="text-input"
        {...formIntegratedInputControls}
      />
      {field.validationError && (
        <p className="field-error">{field.validationError[0]}</p>
      )}
    </div>
  )
}

Define the mappings of the form components

After we created our form components, we need to pass them to the FormComponentMappingsProvider component that will wrap our application.

The FormComponentMappingsProvider component accepts 4 different categories of form component mappings that we need to supply:

  • Field components: These are the typical form components that capture user input and go through validation.
  • UI components: These components can fulfill many essential roles in the form such as buttons to trigger events like submission, a tracking indicator of the form's steps, confirmation modals, white spaces, etc.
  • Wrapper components: These components can used to wrap other components to create the responsive layouts for our forms. If we use a grid system for our <FormWrapper>, we can define the layout of our form fields efficiently.
  • Internal components: These components are not defined in any single form's configuration, but they are part of every form by default. There are 3 internal components that we can define:
    • <FormWrapper>: This component is the default wrapper for every form. It is recommended to set this up globally to use the same layout strategy for all of our forms. Ideally we want to use a grid system (for example Mantine Grid) to define the layout of our form fields. We must render props.children inside this component.
    • <ComponentWrapper>: This component will wrap every field component in the form. It is recommended to define them in a way that these accept layout related parameters through componentParams (like margins, grid positioning, etc.). We must render props.children inside this component.
    • <InitialDataLoadingIndicator>: This component will be rendered only if an async data loading operation defined in the form's initialData configuration to load the initial values of the fields is still loading.
    • <InitialDataLoadingError>: This component will be rendered only if the async data loading operation defined in the form's initialData configuration to load the initial values of the fields threw an error.

Component mappings example

import { FormComponentMappingsProvider } from 'react-flexyform'
import { TextField, TextFieldParams } from '@my-project/components/TextField'
import {
  SelectField,
  SelectFieldParams,
} from '@my-project/components/SelectField'
import {
  SubmitButton,
  SubmitButtonParams,
} from '@my-project/components/SubmitButton'
import {
  BorderedSection,
  BorderedSectionParams,
} from '@my-project/components/BorderedSection'
import {
  FormWrapper
} from '@my-project/components/FormWrapper'
import {
  ComponentWrapper,
  ComponentWrapperParams,
} from '@my-project/components/ComponentWrapper'
import {
  FormInitialDataLoadingIndicator
} from '@my-project/components/FormInitialDataLoadingIndicator'
import {
  FormInitialDataLoadingError
} from '@my-project/components/FormInitialDataLoadingError'
 
export const fieldComponentMappings = {
  text: TextField,
  select: SelectField,
  // ...Rest of the components
}
 
export const uiComponentMappings = {
  submitButton: SubmitButton,
  // ...Rest of the components
}
 
export const wrapperComponentMappings = {
  borderedSection: BorderedSection,
  // ...Rest of the components
}
 
export const internalComponentMappings = {
  formWrapper: FormWrapper,
  componentWrapper: ComponentWrapper,
  formInitialDataLoadingIndicator: FormInitialDataLoadingIndicator,
  formInitialDataLoadingError: FormInitialDataLoadingError,
}
 
// Typescript only:
declare global {
  interface FormFieldComponentMappings {
    text: TextFieldComponentParams
    select: SelectFieldComponentParams
    // ...Rest of the components
  }
 
  interface FormUiComponentMappings {
    submitButton: SubmitButtonParams
    // ...Rest of the components
  }
 
  interface FormWrapperComponentMappings {
    sectionWithBorder: SectionWithBorderParams
    // ...Rest of the components
  }
 
  interface ComponentWrapperParams {
    wrapperParams?: ComponentWrapperParams
  }
}
 
export const App = () => (
  <FormComponentMappingsProvider
    fieldComponentMappings={fieldComponentMappings}
    uiComponentMappings={uiComponentMappings}
    wrapperComponentMappings={wrapperComponentMappings}
    internalComponentMappings={internalComponentMappings}
  >
    {/* ...Your application code */}
  </FormComponentMappingsProvider>
)

Creating our first form

To create a form, we first need to create the formStore instance for our form using the useCreateFormStore hook (learn more about the form store and configuration). This will initiate the state store of the form based on the configuration and it will return a formStore instance that we will need to pass to the <Form> component.

Service

import React from 'react'
import {
  CreateStoreMultiStepConfiguration,
  Form,
  FormStore,
  useCreateFormStore,
} from 'react-flexyform'
import { format } from 'date-fns'
 
export type FormStoreFormFields = {
  service: string
  withGuide: boolean
  guide?: string
  bookingDate: Date
  bookingTime: string
  fullName: string
  email: string
  phone: string
  otherParticipants: { fullName: string; email: string; phone: string }[]
}
 
export const getBookingFormStoreBase = (
  getFormStore: () => FormStore<FormStoreFormFields>
): CreateStoreMultiStepConfiguration<FormStoreFormFields> => ({
  events: {
    onGoToNextStep: async () => {
      // In case of going to the previous step, not modifying anything and going to the next step, there will be no need to send a request with the unmodified values
      if (getFormStore().getIsStepDirty()) {
        // Send request to save the state on the server when going to the next step
        // Access field values with getFormStore().getAllFieldValues() or getFormStore().getStepFieldValues()
        await saveBookingStep(
          getFormStore().currentStepName,
          getFormStore().getStepFieldValues()
        )
      }
    },
  },
  steps: [
    {
      name: 'Service',
      components: [
        {
          type: 'ui',
          formComponentMappingKey: 'stepProgress',
          componentParams: {
            steps: [
              { label: 'Service' },
              { label: 'Date & time' },
              { label: 'Personal info' },
            ],
          },
        },
        {
          type: 'ui',
          formComponentMappingKey: 'title',
          componentParams: {
            children: 'Service',
          },
        },
        {
          name: 'service',
          type: 'field',
          formComponentMappingKey: 'select',
          componentParams: {
            label: 'Choose a service for your booking',
            data: [
              {
                value: 'mountainClimbing',
                label: 'Mountain climbing',
              },
              {
                value: 'kayaking',
                label: 'Kayaking',
              },
            ],
          },
          validationRules: {
            required: {
              message: 'Required',
            },
          },
        },
        {
          name: 'withGuide',
          type: 'field',
          formComponentMappingKey: 'toggle',
          componentParams: {
            label: 'I want a guide',
          },
          defaultValue: false,
        },
        {
          name: 'guide',
          shouldShowOnlyIf: { withGuide: true },
          type: 'field',
          formComponentMappingKey: 'select',
          componentParams: {
            label: 'Choose a guide (optional)',
            placeholder: 'Any guide is okay for me',
            clearable: true,
            data: [
              {
                value: 'johnWood',
                label: 'John Wood',
              },
              {
                value: 'christopherRock',
                label: 'Christopher Rock',
              },
              {
                value: 'annaWater',
                label: 'Anna Water',
              },
            ],
          },
        },
        {
          type: 'ui',
          formComponentMappingKey: 'goToPreviousStepButton',
          componentParams: {
            wrapperParams: {
              span: 6,
            },
          },
        },
        {
          type: 'ui',
          formComponentMappingKey: 'goToNextStepButton',
          componentParams: {
            wrapperParams: {
              span: 6,
            },
          },
        },
      ],
    },
    {
      name: 'bookingDetails',
      components: [
        {
          type: 'ui',
          formComponentMappingKey: 'stepProgress',
          componentParams: {
            steps: [
              { label: 'Service' },
              { label: 'Date & time' },
              { label: 'Personal info' },
            ],
          },
        },
        {
          type: 'ui',
          formComponentMappingKey: 'title',
          componentParams: {
            children: 'Date & time',
          },
        },
        {
          name: 'bookingDate',
          type: 'field',
          formComponentMappingKey: 'date',
          componentParams: {
            label: 'Choose a day for your booking',
          },
          validationRules: {
            required: {
              message: 'Required',
            },
          },
        },
        {
          name: 'bookingTime',
          type: 'field',
          formComponentMappingKey: 'select',
          componentParams: {
            value: async () => {
              const bookingDate = getFormStore().getFieldValue('bookingDate')
 
              if (!bookingDate) {
                return {
                  label: 'Choose a time for your booking',
                  description:
                    'Depends on the date chosen, please choose a date first',
                  data: [],
                  disabled: true,
                }
              }
 
              return {
                label: 'Choose a time for your booking',
                description:
                  'Depends on the date chosen, please choose a date first',
                data: await getAvailableTimeslotsForDate(bookingDate),
                disabled: false,
              }
            },
            dependencies: () => [getFormStore().getFieldValue('bookingDate')],
          },
          reactToChanges: {
            functionToRun: () => {
              getFormStore().setFieldValue('bookingTime', '')
            },
            dependencies: () => [getFormStore().getFieldValue('bookingDate')],
          },
          validationRules: {
            required: {
              message: 'Required',
            },
          },
        },
        {
          type: 'ui',
          formComponentMappingKey: 'goToPreviousStepButton',
          componentParams: {
            variant: 'outline',
            wrapperParams: {
              span: 6,
            },
          },
        },
        {
          type: 'ui',
          formComponentMappingKey: 'goToNextStepButton',
          componentParams: {
            wrapperParams: {
              span: 6,
            },
          },
        },
      ],
    },
    {
      name: 'personalDetails',
      shouldGoToNextStepOnEnter: true,
      components: [
        {
          type: 'ui',
          formComponentMappingKey: 'stepProgress',
          componentParams: {
            steps: [
              { label: 'Service' },
              { label: 'Date & time' },
              { label: 'Personal info' },
            ],
          },
        },
        {
          type: 'ui',
          formComponentMappingKey: 'title',
          componentParams: {
            children: 'Personal info',
          },
        },
        {
          name: 'fullName',
          type: 'field',
          formComponentMappingKey: 'text',
          componentParams: {
            label: 'Full name',
          },
          validationRules: {
            required: {
              message: 'Required',
            },
          },
        },
        {
          name: 'email',
          type: 'field',
          formComponentMappingKey: 'text',
          componentParams: {
            label: 'Email',
          },
          validationRules: {
            required: {
              message: 'Required',
            },
            email: {
              message: 'Must be a valid email',
            },
          },
        },
        {
          name: 'phone',
          type: 'field',
          formComponentMappingKey: 'text',
          componentParams: {
            label: 'Phone',
            description: 'Include country code too',
          },
          validationRules: {
            required: {
              message: 'Required',
            },
            phoneNumber: {
              message: 'Must be a valid phone number',
            },
          },
        },
        {
          name: 'otherParticipants',
          type: 'field',
          formComponentMappingKey: 'nestedArray',
          componentParams: {
            label: 'Other participants (3 max)',
            emptyStateText: 'No other participants added yet',
          },
          validationRules: {
            maxLength: {
              message: 'Cannot add more than 3 participants',
              value: 3,
            },
          },
          nestedArrayComponents: [
            {
              name: 'fullName',
              type: 'field',
              formComponentMappingKey: 'text',
              componentParams: {
                label: 'Full name',
              },
              validationRules: {
                required: {
                  message: 'Required',
                },
              },
            },
            {
              name: 'email',
              type: 'field',
              formComponentMappingKey: 'text',
              componentParams: {
                label: 'Email',
              },
              validationRules: {
                required: {
                  message: 'Required',
                },
                email: {
                  message: 'Must be a valid email',
                },
              },
            },
            {
              name: 'phone',
              type: 'field',
              formComponentMappingKey: 'text',
              componentParams: {
                label: 'Phone',
                description: 'Include country code too',
              },
              validationRules: {
                required: {
                  message: 'Required',
                },
                phoneNumber: {
                  message: 'Must be a valid phone number',
                },
              },
            },
            {
              type: 'ui',
              formComponentMappingKey: 'removeNestedArrayItemButton',
            },
          ],
        },
        {
          type: 'ui',
          formComponentMappingKey: 'goToPreviousStepButton',
          componentParams: {
            wrapperParams: {
              span: 6,
            },
          },
        },
        {
          type: 'ui',
          formComponentMappingKey: 'goToNextStepButton',
          componentParams: {
            wrapperParams: {
              span: 6,
            },
          },
        },
      ],
    },
    {
      name: 'success',
      components: [
        {
          type: 'ui',
          formComponentMappingKey: 'stepProgress',
          componentParams: {
            steps: [
              { label: 'Service' },
              { label: 'Date & time' },
              { label: 'Personal info' },
            ],
          },
        },
        {
          type: 'ui',
          formComponentMappingKey: 'title',
          componentParams: {
            children: 'Successful booking',
            size: 'xl',
          },
        },
        {
          name: 'successMessage',
          type: 'ui',
          formComponentMappingKey: 'paragraph',
          componentParams: {
            value: () => {
              const fieldValues = getFormStore().getAllFieldValues()
 
              return {
                children: `Your booking has been confirmed for ${format(fieldValues?.bookingDate, 'yyyy-MM-dd')} at ${fieldValues?.bookingTime}. We look forward to seeing you then!`,
              }
            },
            dependencies: () => [
              getFormStore().getFieldValue('bookingDate'),
              getFormStore().getFieldValue('bookingTime'),
            ],
          },
        },
        {
          type: 'ui',
          formComponentMappingKey: 'button',
          componentParams: {
            onClick: () => {
              getFormStore().resetFormState()
            },
            children: 'New booking',
          },
        },
      ],
    },
  ],
})
 
export const BookingFormExample = () => {
  const bookingFormStore = useCreateFormStore<FormStoreFormFields>(
    'bookingForm',
    getBookingFormStoreBase
  )
 
  return <Form formStore={bookingFormStore} />
}

Conclusion

Although there is an initial boilerplate necessary, there are serveral advantages of working with forms in this way:

  • Consistency: each of our forms will have a consistent way of handling common requirements like validation, conditional fields, nested fields, multiple steps, conditional steps, etc.
  • Component system: as our project grows, the component mapping categories (ui, wrapper, field) will enforce a more systemized way of maintaining our form's components
  • Typescript: type safety and IDE autocompletion will be available in our project's form configurations
  • Integrations: since we have an object representation of our forms, it will be easier to set up dynamic forms that can be configured from remote data sources like a headless CMS

Even though it might seem that there are limitations in flexibility for some edge cases, every form's state is accessible via the return value of their specific useCreateFormStore invocation (or useFormStore) so that it can be used by any component even outside the components defined in the mappings.

On this page