Skip to content

Latest commit

 

History

History
864 lines (774 loc) · 30.7 KB

index.md

File metadata and controls

864 lines (774 loc) · 30.7 KB

Documentation

Overview

Enform was born while trying to deal with forms in React repetitive times with state involved in the picture as usual. Let's face it, things always end up the same. The result is a big state object to manage and a bunch of component methods to handle changes, submission and validation.

It feels like these should be somehow hidden or extracted away in another component. <Enform /> is such a component that uses the "render props" pattern. It nicely moves that state management away by taking advantage of React's superpower.

Ok, enough theory, let's see some real use cases.

Examples

All examples in this section are available in Codesandbox with the latest version of Enform. Feel free to experiment, fork or share. Ping me if you think I have messed something up 🤭.

Basic form (field and a button)

import React from "react";
import Enform from "enform";

const App = () => (
  <div>
    <h1>Simple form</h1>
    <Enform
      initial={{ name: "" }}
      validation={{ name: values => values.name === "" }}
    >
      {props => (
        <div>
          <input
            className={props.errors.name ? "error" : ""}
            type="text"
            value={props.values.name}
            onChange={e => {
              props.onChange("name", e.target.value);
            }}
          />
          <button onClick={props.onSubmit}>Submit</button>
        </div>
      )}
    </Enform>
  </div>
);

Edit Basic form with enform

Few things to note here:

  • initial (required) prop is set with the field's default value
  • validation object sets the field should not be empty
  • props.onSubmit is bound to the button click. It will submit whenever validation is passed
  • the input field is fully controlled with the help of props.values and props.onChange.

This ⚠️ note on re-rendering may save few hours of headache too.


Newsletter form

<Enform
  initial={{ email: "" }}
  validation={{
    email: values =>
      !/^[A-Za-z0-9._%+-]{1,64}@(?:[A-Za-z0-9-]{1,63}\.){1,125}[a-z]{2,63}$/.test(
        values.email
      )
  }}
>
  {props => (
    <div>
      <input
        className={props.errors.email ? "error" : ""}
        type="text"
        placeholder="Your email"
        value={props.values.email}
        onChange={e => {
          props.onChange("email", e.target.value);
        }}
      />
      <button onClick={props.onSubmit}>Submit</button>
    </div>
  )}
</Enform>

Edit Newsletter form with enform

In this example validation is set for the email field using RegEx. It will return true if email is invalid or false otherwise. All validator functions must return truthy value in case of error.


Registration form

Expand code snippet
<Enform
  initial={{
    user: "",
    email: "",
    password: "",
    repeatPassword: "",
    news: false
  }}
  validation={{
    // Other fields validation here
    password: values => {
      if (values.password.length < 6) {
        return "Password must be at least 6 chars in length!";
      } else if (values.password !== values.repeatPassword) {
        return "Password doesn't match!";
      }
      return false;
    },
    repeatPassword: values =>
      values.repeatPassword.length < 6
        ? "Password must be at least 6 chars in length!"
        : false
  }}
>
  {props => (
    <div className="Form">
      // Other fields DOM here
      <div className={errors.password ? "error" : ""}>
        <input
          type="password"
          placeholder="Password (min 6)"
          value={props.values.password}
          onChange={e => {
            props.onChange("password", e.target.value);
            if (props.errors.repeatPassword) {
              props.clearError("repeatPassword");
            }
          }}
        />
        <p>{props.errors.password}</p>
      </div>
      ...
    </div>
  )}
</Enform>

Edit Registration form with enform

This example is shortened, so that it's easy to focus on two interesting parts - password validation and clearing errors. Take a look at the full demo in the codesandbox.

This registration form displays error messages as well. In order that to work each validator function must return the error string in case of error. The password validation depends on both password and repeatPassword values, so it will display two different error messages.

Secondly, password's onChange should also clear the error for repeatPassword. props.onChange("password", e.target.value) does so for the password field, but it needs to be done for repeatPassword as well. That can be achieved by calling props.clearError("repeatPassword").


Form with dynamic elements

Expand code snippet
<Enform
  // Force Enform to reinitialize itself when adding/removing fields
  key={fieldNames.length}
  initial={{
    email: "",
    // Spread updated fields initial values -
    // stored in the component's state.
    ...this.state.fields
  }}
  validation={{
    email: values =>
      !/^[a-zA-Z0-9._%+-]{1,64}@(?:[a-zA-Z0-9-]{1,63}\.){1,125}[a-zA-Z]{2,63}$/.test(
        values.email
      ),
    // Spread the validation object for the rest of the fields -
    // stored in the component's state.
    ...this.state.fieldsValidation
  }}
>
  {props => (
    <div>
      {/* Map your newly added fields to render in the DOM */}
      {Object.keys(this.state.fields).map(field => (
        <div key={field}>
          <input
            className={props.errors[field] ? "error" : ""}
            type="text"
            placeholder="Email"
            value={props.values[field]}
            onChange={e => {
              props.onChange(field, e.target.value);
            }}
          />
          <button className="remove">Remove</button>
        </div>
      ))}
      <input
        className={props.errors.email ? "error" : ""}
        type="text"
        placeholder="Email"
        value={props.values.email}
        onChange={e => {
          props.onChange("email", e.target.value);
        }}
      />
      <button className="add">Add more</button>
      <button className="save">Save</button>
    </div>
  )}
</Enform>

Edit Dynamic form fields with enform

Enfrom does not automatically handle dynamic form elements (adding or removing felds), but it is possible to make it aware of such changes with few adjustments. The example above is a short version of the codesandbox demo.

Let's start with the basics: Enform wraps the form DOM and helps with handling its state. But it's required to make Enform aware when new DOM is added, just because it needs to be controlled being a form element. In this example Enform is forced to reinitialize whenever more fields are added or removed. It is done by setting the key={fieldNames.length} prop.

Next logical step would be to update the initial and validation props with up to date fields data. The data could be stored in the consumer's component state fx. Last thing to do - render all these newly added fields. Enform will do the rest as usual.


Full-featured form

Expand code snippet
<Enform
  initial={{
    email: "",
    password: "",
    age: "",
    frontend: false,
    backend: false,
    fullstack: false,
    devops: false,
    gender: "male",
    bio: "",
    news: false
  }}
  validation={{
    email: ({ email }) =>
      !/^[A-Za-z0-9._%+-]{1,64}@(?:[A-Za-z0-9-]{1,63}\.){1,125}[A-Za-z]{2,63}$/.test(
        email
      )
        ? "Enter valid email address!"
        : false,
    password: ({ password }) =>
      password.length < 6
        ? "Password must be at least 6 chars in length!"
        : false,
    age: ({ age }) => (age === "" ? "Select age range" : false),
    bio: ({ bio }) => (bio.length > 140 ? "Try to be shorter!" : false)
  }}
>
  {props => (
    <div className="form">
      <div>
        <input
          type="text"
          placeholder="Email"
          value={props.values.email}
          onChange={e => {
            props.onChange("email", e.target.value);
            // This will validate on every change.
            // The error will disappear once email is valid.
            if (props.errors.email) {
              props.validateField("email");
            }
          }}
        />
        <p>{props.errors.email}</p>
      </div>
      <div>
        <input
          type="password"
          placeholder="Password (min 6)"
          value={props.values.password}
          onChange={e => {
            props.onChange("password", e.target.value);
          }}
        />
        <p>{props.errors.password}</p>
      </div>
      <div>
        <select
          value={props.values.age}
          onChange={e => {
            props.onChange("age", e.target.value);
          }}
        >
          <option value="">What is your age</option>
          <option value="10-18">10 - 18</option>
          <option value="19-25">19 - 25</option>
          <option value="26-40">26 - 40</option>
          <option value="41-67">41 - 67</option>
        </select>
        <p>{props.errors.age}</p>
      </div>
      <label>You are:</label>
      <div>
        <input
          type="checkbox"
          id="frontend"
          checked={props.values.frontend}
          onChange={e => {
            props.onChange("frontend", e.target.checked);
          }}
        />
        <label htmlFor="frontend">front-end</label>
        <input
          type="checkbox"
          id="backend"
          checked={props.values.backend}
          onChange={e => {
            props.onChange("backend", e.target.checked);
          }}
        />
        <label htmlFor="backend">back-end</label>
        <input
          type="checkbox"
          id="fullstack"
          checked={props.values.fullstack}
          onChange={e => {
            props.onChange("fullstack", e.target.checked);
          }}
        />
        <label htmlFor="fullstack">full-stack</label>
        <input
          type="checkbox"
          id="devops"
          checked={props.values.devops}
          onChange={e => {
            props.onChange("devops", e.target.checked);
          }}
        />
        <label htmlFor="devops">dev-ops</label>
      </div>
      <label>Gender:</label>
      <div>
        <input
          type="radio"
          id="male"
          name="gender"
          value="male"
          checked={props.values.gender === "male"}
          onChange={() => {
            props.onChange("gender", "male");
          }}
        />
        <label htmlFor="male">male</label>
        <input
          type="radio"
          id="female"
          name="gender"
          value="female"
          checked={props.values.gender === "female"}
          onChange={() => {
            props.onChange("gender", "female");
          }}
        />
        <label htmlFor="female">female</label>
      </div>
      <div>
        <textarea
          type="text"
          placeholder="Short bio (max 140)"
          value={props.values.bio}
          onFocus={() => {
            // Clear the error on field focus
            props.clearError("bio");
          }}
          onChange={e => {
            props.onChange("bio", e.target.value);
          }}
        />
        <p>{props.errors.bio}</p>
      </div>
      <div>
        <input
          id="news"
          type="checkbox"
          checked={props.values.news}
          onChange={e => {
            props.onChange("news", e.target.checked);
          }}
        />
        <label htmlFor="news">
          Send me occasional product updates and offers.
        </label>
      </div>
      <button
        disabled={!props.isDirty()}
        type="reset"
        onClick={() => {
          // Reverts fields back to initial values
          // and clears all errors
          props.reset();
        }}
      >
        Clear
      </button>
      <button
        onClick={() => {
          props.onSubmit(values => {
            // Call your own handler function here
            alert(JSON.stringify(values, null, " "));
          });
        }}
      >
        Send
      </button>
    </div>
  )}
</Enform>

Edit Full-featured form with enform

Demonstration of Enform handling full-featured form, using all API props and methods.

Few interesting points:

  • Passing custom callback to onSubmit. The handler is attached to the submit button and simply alerts all field values in pretty format.
  • Resetting the form. With the props.reset() call fields are reverted back to initial values and all errors are cleared.
  • Clear error on focus. This is done by calling props.clearError("bio") when focusing the bio field.
  • Validate while typing. Calling props.validateField("email") as part of the onChange handler will trigger validation for each change.

API

<Enform /> component wraps the form DOM (or custom component) and enables state control via props. They are split in two groups - props set to <Enform /> directly and props that it passes down to the consumer component (DOM).

Enform component props

Two props can be set to the component itself - initial and validation.

<Enform
  initial={{ username: "" }}
  validation={{ username: values => values.username.length === 0 }}
>
  ...
</Enform>

initial: { fieldName: value } - required

The prop is the only required one. It is so, because it needs to tell Enform what is the current field structure. The initial value of each field should be a valid React element's value. That means if there is a checkbox fx. it make sense for its initial value to be boolean. The structure could be something like { email: "", password: "", newsletter: false }.

validation: { fieldName: function(values) => bool|string) }

It is used for specifying validation conditions and error messages. Don't set it if no validation is needed. The key (field name) should be the same as in the initial. The value is a validator function which accepts all field values. Validators should return an error message when field is invalid or just true if no messages are needed. The following: { username: values => values.username.length === 0 } returns a boolean simply telling if the field is empty. Such a condition may be useful when setting an error class to a field is enough. Setting up error messages is achieved with something like { username: values => values.username.length === 0 ? "This field is required" : "" }. If validation is passing it will always default to false value for that field in the errors object.


Enform state API

Enform manages the form's state and provides access to it by exposing several props and methods. These are passed down to the component (DOM) via the props object.

<Enform initial={{ name: "" }}>
  {props => (
    <form />
      ...
    </form>
  )}
</Enform>

props.values: { fieldName: value }

Object containing all field values. The signature is { fieldName: value } where fieldName is the field name as defined in the initial and value is the current value of the element.

props.values get updated when calling:

  • props.onChange
  • props.reset

props.errors: { fieldName: value }

Object containing errors for all fields. These are either the error messages or simply the boolean true. fieldName is the same field name defined in the initial while value is returned from the validator function (error message or boolean) defined in validation. In case of no error props.errors.<fieldName> will be false.

props.errors get updated when calling:

  • props.onChange
  • props.onSubmit
  • props.validateField
  • props.clearError
  • props.clearErrors
  • props.setErrors
  • props.reset

props.onChange: (fieldName, value) => void

Handler method used for setting the value of a field.

<Enform initial={{ email: "" }}>
  {props => (
    ...
    <input onChange={e => {
      props.onChange("email", e.target.value);
    }}>
  )}
</Enform>

As a side effect calling this method will also clear previously set error for that field.

props.onSubmit: (function(values) => void) => void

By calling props.onSubmit() Enform will do the following: trigger validation on all fields and either set the corresponding errors or call the successCallback if validation is passed. successCallback accepts the values object as an argument.

<Enform initial={{ email: "" }}>
  {props => (
    <form onSubmit={e => {
      e.preventDefault();
      props.onSubmit(values => { console.log(values); });
    }}>
      ...
    </form>
  )}
</Enform>

props.reset: () => void

Clears all fields and errors. Calling props.reset() will set the fields back to their initial values. As a side effect it will also clear the errors as if props.clearErrors() was called. The following full-featured form uses props.reset() on button click.

props.isDirty: () => bool

Calling props.isDirty() reports if form state has changed. It does so by performing comparison between fields current and initial values. Since it is an expensive operation Enform does't keep track of dirty state internally. That's why isDirty is method instead.

props.validateField: (fieldName) => bool

It triggers validation for a single field (ex. props.validateField("email")). As a result the validator function (if any) for that field will be executed and the corresponding value in props.errors set. Common use case would be if a field needs to be validated every time while user is typing.

<Enform initial={{ email: "" }}>
  {props => (
    <input
      onChange={e => {
        props.onChange("email", e.target.value);
        props.validateField("email");
      }}
    />
  )}
</Enform>

props.clearError: (fieldName) => void

Clears the error for a single field. (ex. props.clearError("email")). Calling props.onChange() will do that by default, but props.clearError is built for other cases. An example is clearing an error as part of onFocus.

<Enform initial={{ email: "" }}>
  {props => (
    <input
      onFocus={e => {
        props.clearError("email");
      }}
      onChange={e => {
        props.onChange("email", e.target.value);
      }}
    />
  )}
</Enform>

props.clearErrors: () => void

Calling this method will clear all errors for all fields.

props.setErrors: ({ fieldName: errorMessage|bool }) => void

This method can be used to directly set field errors.

Consider this use case: a valid form has been submitted, but based on some server side validtion one of the field values appeared to be invalid. The server returns back this field's specific error or error message. Then in the form it is also necessary to display the error message next to the corresponding field. For such scenarios props.setErrors() comes to the rescue.

setErrors({
  username: "Already exists",
  password: "Password too long",
  ...
})

If a key part of the error object passed to setErrors does't match any field name (defined in the initial prop) it will simply be ignored.


How to

The idea of these short guides is to elaborate a little bit more on specific areas. Something that is done often when handling forms - validation, resetting/submitting, button states and so on. It will also touch few non trivial uses cases like handling contentEditables, third party integrations and form with <div /> elements.

Handle validation

Quick validator function examples.

Simple error indication

<Enform
  initial={{ name: "" }}
  validation={{ name: values => values.name.length === "" }}
>

If name field is empty props.errors.name will be set to true. Otherwise it will go false.

With error message

<Enform
  initial={{ name: "" }}
  validation={{ name: values => (
    values.name.length === "" ? "This field can not be empty" : ""
  )}}
>

If name field is empty the "This field can not be empty" message will be stored in props.errors.name. Otherwise it will be false.

Validation while typing

<Enform
  initial={{ name: "" }}
  validation={{ name: values => values.name.length < 3 }}
>
  {props =>
    <input
      type="text"
      value={props.values.name}
      onChange={e => {
        props.onChange("name", e.target.value);
        props.validateField("name");
      }}
    />
  }
</Enform>

The name field validator will be called every time user is typing in the field. This will cause props.errors.name to be updated constantly and cleared (with props.onChange) once the value reaches at least 3 chars.

Password validation

Typical example is a signup form with password and repeatPassword fields. Find more details in this full codesandbox demo.

<Enform
  initial={{ name: "" }}
  validation={{
    password: values => {
      if (values.password.length < 6) {
        return "Password must be at least 6 chars in length!";
      } else if (values.password !== values.repeatPassword) {
        return "Password doesn't match!";
      }
      return false;
    },
    repeatPassword: values =>
      values.repeatPassword.length < 6
        ? "Password must be at least 6 chars in length!"
        : false
  }}
>

Current validation sets error messages for both password and repeatPassword if their values are less than 6 chars. props.errors.password will also store error message for values missmatch. This validator is an example on how several field values could be combined.


Reset a form

Let's see how to reset a form on button click:

<Enform initial={{ name: "John" }}>
  {props =>
    <button onClick={props.reset}>
      Clear
    </button>
  }
</Enform>

Resetting a form with Enform will reset all fields and clear all errors. The action is similar to re-initializing. See this full form demo for usage example.


Submit a form

There are few ways to handle form submission with Enform.

<Enform initial={{ name: "John" }}>
  {props =>
    <button onClick={() => { props.onSubmit(); }}>
      Submit
    </button>
  }
</Enform>

Calling props.onSubmit() as part of button onClick handler.

or

<Enform initial={{ name: "John" }}>
  {props =>
    <form onSubmit={e => {
      e.preventDefault();
      props.onSubmit();
    }}>
      ...
    </form>
  }
</Enform>

Calling props.onSubmit() as part of <form />'s onSubmit handler. Note that it is often reasonable to also prevent form default behavior when submitting. This is because Enform works with controlled form elements.

What if calling an Api endpoint or some other action to deal with the form values is required on submission? Passing a custom successCallback will help in that case:

<button onClick={() => {
  props.onSubmit(values => {
    console.log(values);
    // or pass the values to your own handler
  });
}}>
  Submit
</button>

If success callback function is provided to props.onSubmit() it will be called only if all fields are passing their validation. Check the demo here with full code example.


Disable button based on dirty state

It is a common use case to enable/disable form buttons when dirty state changes.

<Enform initial={{ name: "John" }}>
  {props =>
    <button disabled={!props.isDirty()}>Submit</button>
  }
</Enform>

The submit button will render as disabled if form is not dirty - has no changes. Or in other words - all fields are displaying their initial values.


Handle contentEditable elements

ContentEditable elements are sometimes very quirky. With React it is often required to additionally manage cursor position and console warnings. Below is a basic example of contentEditable div:

<Enform initial={{ name: "Click me, I'm contentEditable" }}>
  {props => (
    <div
      contentEditable
      onInput={e => {
        props.onChange("name", e.target.innerText);
      }}
    >
      {props.values.name}
    </div>
  )}
</Enform>

There are few differences with the standard <input />. Instead of onChange changes are registered via onInput, the text resides in e.target.innerText and the value is placed as a child of the div.


Handle form-like DOM

All examples so far show how Enform works with controlled elements and components. It doesn't need to know about DOM structure at all. An interesting idea emerges - is it possible to manage the state of something that is not a form? That is possible. Let's see the following example:

<Enform initial={{ label: "I am a label!" }}>
  {props => (
    <label
      onClick={() => {
        props.onChange("label", this.state.data.label);
      }}
    >
      {props.values.label}
    </label>
  )}
</Enform>

The <label /> element takes it's default text from the initial object and updates it on click with some data that comes from the consumer component's state. Such cases could be expanded further more, but the idea is it should be possible to use Enform for state management of anything that deals with values and validation.


⚠️ Note on re-rendering

It is important to note that <Enform /> will do its best to re-render when initial values change. That is done by comparing the current and previous initial values.

Usage like this fx is guaranteed to work:

class ConsumerComponent extends Component {
  ...

  render() {
    {/* newly created initial object is passed on each render */}
    <Enform initial={{ name: this.state.name }}>
      {props => (
        {/* input will render correct value whenever state changes */}
        <input value={props.values.name} />
      )}
    </Enform>
  }
}

Something like that should also work:

class ConsumerComponent extends Component {
  constructor(props) {
    super(props);
    // Defining it just for the sake of causing re-render
    this.state = { loading: false };
    // name with default value of ""
    this.initial = { name: "" };
  }

  componentDidMount() {
    this.setState({ loading: true });

    this.getName().then(name => {
      this.setState({ loading: false });
      // An API call that will set name to "Justin Case"
      this.initial.name = name;
    });
  }

  render() {
    <Enform initial={this.initial}>
      {props => (
        {/* ⚠️ NOTE: the input will still render "" */}
        <input value={props.values.name} />
      )}
    </Enform>
  }
}

Using Javascript Set and Map as values

This is considered more as an edge case and may cause issues in some cases. Fx. Enform uses sorting and stringification to compare initial values, but JSON.stringify doesn't transform Set and Map structures correctly. It may lead to the state not being updated correctly.

Ensure these values are transformed to an Object or Array before passing down.

What is the solution?

Recommended technique would be to use the special key prop forcing that way Enform to update. Let's see the changes in the render() method:

render() {
  <Enform
    {/* key's value will change causing re-initializing */}
    key={this.state.name}
    initial={this.initial}
  >
    {props => (
      {/* input will render "Justin Case" */}
      <input value={props.values.name} />
    )}
  </Enform>
}

The only difference is that now the key prop is set to this.state.name telling React to create a new component instance rather than update the current one.


The end!