Skip to content

Instantly share code, notes, and snippets.

@jordangarcia
Created August 7, 2018 00:10
Show Gist options
  • Save jordangarcia/74419d4e021851aba94db0a849ff7458 to your computer and use it in GitHub Desktop.
Save jordangarcia/74419d4e021851aba94db0a849ff7458 to your computer and use it in GitHub Desktop.

React Form Abstraction

What problems are we trying to solve?

  1. There is not a generic solution for handling dirty state checking and reverts throughout our codebase.

  2. Validation is a mess, either it's duplicated as inline validation in the "Input" component and in the "Form" component. Other places it exist in the parent component and passed down through props to the "Input", this means our inputs require the parent component to know and do a ton of validation making them no longer easily portable.

Requirements for a good React Form Abstraction

  1. Generalizes and abstracts form dirty state, and reverts
  2. Allows to define validation once for an input while keeping form inputs portable and easily composeable.
  3. A standardized interface for validation functions and how errors are presented both inline and at the form level.

Simple Example

import _ from 'lodash';
import React from 'react';
import PropTypes from 'prop-types';

import { Input, Button } from 'optimizely-oui'

import SectionModule from '../../section_module/';


@Form({
  // Populates the form HoC with initial state for the entity being edited
  // accessible via `props.formState.clean` and `props.form.state.editing`
  initialStateGetter: SectionModule.getters.currentlyEditingExperiment,
})
class MyForm extends React.Component {
  constructor(props) {
    super(props);
    // global validation functino

    // form {FormInterface} is passed to wrapped component as prop
    const { form } = this.props;
    form.addValidationFn({
      keypath: 'name',

      getErrors(val) {
        // these should be composable
        let hasError
        if (val.length === 0) {
          return {
            hasError: true,
            details: {
              message: 'Name is required',
            }
          }
        }

        if (val.length > 63) {
          return {
            hasError: true,
            details: {
              message: 'Name must be less than 64 characters',
            }
          }
        }

        return {
          hasError: false,
          details: {},
        }
      },
    })
  }

  handleSave = () => {
    const { form } = this.props;
    // this call will populate errors and pass them through props to the child components
    // Question: should this return something?
    form.validateAll();
    if (!form.isFormValid()) {
      // you can also use this display global errors
      return;
    }
    const data = form.getValue().toJS();
    // do save operation with data
  }

  render() {
    const {
      form,
      formState,
    } = this.props;

    const nameField = form.field('name');

    return (
      <form>
        <p>
          is dirty:
          <strong>
            { formState.isDirty ? 'true': 'false' }
          </strong>
        </p>

        <Input
          value={ nameField.getValue() }
          onChange={ (e) => nameField.setValue(e.target.value) }
          errors={ nameField.getErrors() }
        />

        <div>
          <Button
            isDisabled={ !formState.isDirty }
            onClick={ form.revert }>
            Revert
          </Button>
          <Button
            onClick={ this.handleSave }>
            Save
          </Button>
        </div>
      </form>
    )
  }
}

Architecture

 +------------------------+
 |        Form HoC        |
 +-------+--------+-------+
         |        |
         +        +
   `form` api   `formState`

+--------------------------+
|                          |
|   Wrapped components     |
|   `props.form`           |
|   `props.formState`      |
+------------+-------------+
             |
             |
             |
             v
         FormInput

Form HoC

  • Receives a getter for initial form state
  • Passes form and formState down as props
  • exposes functionality such as revert, getValue(), setValue(val)

FormInput

  • Any component with the following interface
<FormInput
  form={{
    setValue,
    getValue,
    getErrors,
    addValidationFn,
  }}
 />
 // or for convenience

<FormInput
  form={ props.form.field('name') }
/>

A quick note on validation

We would like a system where the structure of the error object and the presentational logic for displaying inline error messages is defined in the same place, this allows predictability and better inline validation without cross-coupling form and input components.

API Reference

FormInterface

Passes as a props.form to the wrapped component of the Form HoC.

form.getValue()

returns all of the form data

form.getValue(keypath)

returns data for that particular keypath, ex: form.getValue('variations')

form.setValue(formData)

Sets the data for the entire form, if validateOnChange is enabled it will re-validate

form.setValue(keypath, value)

Sets data for a particular keypath, if validateOnChange option is true then it will revalidate that keypath.

form.revert()

Reverts the editing state to the original "initialState"

form.validateAll()

Iterates and runs all validation functions, useful for right before submission. Validation results is accessible via form.isFormValid()

form.isFormValid()

returns boolean based on the current validation state. Note: this does not do validation, so you must either call form.validateAll()

form.addValidationFn(validationFn)

validationFn

form.addValidationFn({
  keypath: 'name',
  getErrors(name, formData) {
    // can access `formData` to look at entire contents of form

    // must return an object of the shape ErrorObject
    return {
      hasErrors: false,
      details: {},
    };
  }
})

form.field(keypath)

Creates a scoped FormFieldInterface scoped to a particular keypath

FormFieldInterface

Example:

const nameField = props.form.field('name');
nameField.setValue('Alan');

nameField.getValue() // returns 'Alan'

nameField.addValidationFn({
  getErrors() {
    return {
      hasErrors: true,
      details: {
        msg: 'this is fake error'
      },
    }
  },
});

nameField.validate();

const errors = nameField.getErrors();  // would return the `getErrors() result`

In practice it's often used to pass an namespaced object to FormInputs

 <FormInput
   form={ props.form.field('name') }
 />

form.getValue()

returns the value for the particular field

form.setValue(value)

Sets the data for the particular field

form.getErrors()

returns Error object for the particular field

form.validate()

Runs any validation fn registered on this keypath and puts errors in formState.errors which is in turned passed down to components via form.field(fieldName).getErrors()

form.addValidationFn()

Adds a validation function for the scoped keypath

@jamesopti
Copy link

Overall this looks pretty reasonable. Would love to see it in a real world use case.

Couple thoughts (really just nice to haves :) ):

  • Would be nice to allow a getter or Immutable.Map for the initial state:
    @Form({
      initialState: <getter|Immutable.Map>,
    })
  • Would be good to start a low level collection of validation functions for general reuse
    form.addValidationFn({
      keypath: 'name',
      getErrors: ValidationFnsFactory.string({min: 1, max: 64, spaces: true, special_chars: false})
    })
  • Would be nice to have an option to retrieve changed fields only (plus id):
    form.getMutatedValues(<id=true>)
  • Would be nice to access the form fields as an object in the render function:
    render() {
      const {
        nameField,
        descriptionField,
        isEnabledField,
      } = this.props.form.fields;
      ....
    }

@jordangarcia
Copy link
Author

Good feedback @jamesopti James

Regarding allowing initialState to be just data, that's interesting. Do you think the use case would be primarily on create forms?

We're definitely going to start making reusable / generic validation functions and putting them somewhere, any suggestions on where int he codebase they should live?

We thought about that, in a lot of cases it also has to get the parent_entity.id in order to build the API endpoint to save. I recently fixed a bug where the view didn't have project_id and when passing it to save it didn't build the API endpoint correctly.

Regarding your last comment, this kind of means having the form know about all the fields up front, which I coudln't find a great API to do if we want to declare them explicitly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment