Skip to content
This repository has been archived by the owner on Jul 5, 2019. It is now read-only.

zero-plus-x/neoform

Repository files navigation

travis travis


Better form state management for React where data state is directly mapped to form fields, so form becomes just a representation and changing interface for that data state.

Usage

Intro

Let's say you have some data and you want to represent it as an HTML form with an Input for each data field.

"user": {
  "name": "Pepe",
  "status": "sad",
  "friends": [
    "darkness"
  ]
}

Each data field can be referenced with a "key" or "property" path. You might be familiar with this concept from working with immutable data structures or helpers like lodash.get().

"user": {
  "name": "Pepe",  // "user.name"
  "status": "sad", // "user.status"
  "friends": [
    "darkness"     // "user.friends.0"
  ]
}

The first core idea of NeoForm is to map data to form fields using these key/property paths. We'll refer to this data as "form state" below.

Let's see how it works with a step-by-step example. First, we need to install the following set of dependencies:

yarn add prop-types recompose neoform neoform-validation neoform-plain-object-helpers

We'll start with creating a simple input:

field

const MyInput = () => (
  <input/>
);

export default MyInput;

After wrapping this input with field HOC from NeoForm we'll have:

value and onChange props

A value from a form state (can be used in checkbox as a checked attribute if it's boolean, and so on) and onChange handler to let NeoForm know that value should be changed:

import { field } from 'neoform';

const MyInput = ({ value, onChange }) => (
  <input
    value={value}
    onChange={(e) => onChange(e.target.value)}
  />
);

export default field(MyInput);

Use (e) => e.target.checked if you have a checkbox or just (value) => value if you have some custom/3rd-party field implementation.

form

Now when the input is ready we can use it in a form:

import MyInput from '../MyInput';

const MyForm = () => (
  <form>
    <MyInput name="user.name"/>
    <MyInput name="user.status"/>
    <MyInput name="user.friends.0"/>
  </form>
);

export default MyForm;

Let's connect this form to NeoForm by wrapping it with a form HOC:

import { form } from 'neoform';

import MyInput from '../MyInput';

const MyForm = () => (
  <form>
    <MyInput name="user.name"/>
    <MyInput name="user.status"/>
    <MyInput name="user.friends.0"/>
  </form>
);

export default form(MyForm);

App

Finally, we assemble everything together:

import { setValue, getValue } from 'neoform-plain-object-helpers';

import MyForm from '../MyForm';

class App extends Component {
  constructor(props) {
    super(props);

    this.state = {
      data: props.data
    };
    this.onChange = this.onChange.bind(this);
    this.onSubmit = this.onSubmit.bind(this);
  }

  onChange(name, value) {
    this.setState((prevState) => setValue(prevState, name, value));
  }

  onSubmit() {
    console.log('submit:', this.state.data);
  }

  render() {
    <MyForm
      data={this.state.data}
      getValue={getValue}
      onChange={this.onChange}
      onSubmit={this.onSubmit}
    />
  }
}

What's going on here? As you may guessed, all fields in NeoForm are controlled. So, in order to update them, we need to update data state:

getValue

First, we need to specify getValue prop to tell NeoForm how exactly it should retrieve field value from data state. The reason to do that is because you might have a plain object data, Immutable or something else with a different "interface".

Instead of writing your own getValue function, you can use one from neoform-plain-object-helpers or neoform-immutable-helpers package.

getValue arguments:

  • data — form data state
  • name — field name

setValue

Second, we have only one onChange handler for the entire form instead of multiple ones for each field. So, whenever some field requests a change, we need to update form data by updating the state so updated value is passed to that field with a new render.

ℹ️ Consider using Recompose pure() HOC or React.PureComponent for fields to avoid unnecessary renders and get performance boost in some cases.

Instead of writing your own handler, you can use setValue helper from neoform-plain-object-helpers or neoform-immutable-helpers package.

setValue arguments:

  • data — form data state
  • name — field name
  • value — new field value
+--------------+
|              |
|              |
|    +---------v---------+
|    |                   |
|    |    MyForm.data    |
|    |                   |
|    +---------+---------+
|              |
|       name   |
|              |
|    +---------v---------+
|    |                   |
|    |   MyInput.value   |
|    |                   |
|    +---------+---------+
|              |
|              |
|    +---------v---------+
|    |                   |
|    | MyInput.onChange  |
|    |                   |
|    +---------+---------+
|              |
|       name   |  value
|              |
|    +---------v---------+
|    |                   |
|    |  MyForm.onChange  |
|    |                   |
|    +---------+---------+
|              |
|       name   |  value
|              |
+--------------+

Validation

Validation in NeoForm is always asynchronous.

fieldValidation

fieldValidation is another HOC:

import { field } from 'neoform';
import { fieldValidation } from 'neoform-validation';

const MyInput = ({
  validate,
  validationStatus,
  validationMessage,
  ...props
}) => (
  <input {...props} onBlur={validate} />
  {
    validationStatus === false && (
      <span>{validationMessage}</span>
    )
  }
)

export default field(fieldValidation(MyInput));

Where the props are:

  • validate – validation action, can be called whenever you want (onChange, onBlur, etc)
  • validationStatustrue | false | undefined status of field validation
  • validationMessage – an optional message passed from validator

formValidation

import { form } from 'neoform';
import { formValidation } from 'neoform-validation';

import MyInput from '../MyInput';

const MyForm = ({
  /* data, */
  validate,
  validationStatus,
  onInvalid,
  onSubmit
}) => (
  <form onSubmit={(e) => {
    validate(onSubmit, onInvalid)
    e.preventDefault();
  }}>
    <MyInput name="user.name"/>
    <MyInput name="user.status"/>
    <MyInput name="user.friends.0"/>
  </form>
);

export default form(formValidation(MyForm));

Where:

  • validate – entire form validation action: it will validate all fields and if they're valid it will invoke a first provided callback (onSubmit handler in most cases) or second callback (something like onInvalid) if they're invalid
  • validationStatustrue | false | undefined status of entire form validation

Validators

"Validator" is just a Promise. Rejected one is for validationStatus: false prop and resolved is for validationStatus: true. An optional argument passed to a rejected or fulfilled Promise becomes validationMessage prop.

export const requiredValidator = (value, type) => {
  if (value === '') {
    return Promise.reject('💩');
  }

  return Promise.resolve('🎉');
};

Where:

  • value – field value for validation
  • type – event type. Can be submit, change, blur or anything you will provide to field validate method

It's up to you how to manage multiple validators — with a simple Promise.all() or some complex asynchronous sequences — as long as validator returns a single Promise.

To use a validator you should just pass it in a validator prop to an individual field:

import { requiredValidator } from '../validators'

// …

<form>
  <MyInput name="user.name" validator={requiredValidator} />
  <MyInput name="user.status"/>
  <MyInput name="user.friends.0"/>
</form>

// …

📺 Check out live demo.

FAQ

But this is just like my entire form is a single component with a single onChange!

Right.

Does it affect performance because of re-rendering entire form on every field change?

Probably in some cases it does. But as it was mentioned here before consider using Recompose pure() HOC or React.PureComponent to avoid that.

What about Redux?

Absolutely same approach: call an action on form onChange and then use plain/immutable helper to return updated data state from a reducer.

Status

This is a monorepo composed of these packages:

package version description
neoform npm Core toolkit with form and field HOCs
neoform-validation npm formValidation and fieldValidation HOCs
neoform-plain-object-helpers npm getValue and setValue helpers for plain object state
neoform-immutable-helpers npm getValue and setValue helpers for Immutable state

Development

  1. Create a new folder in packages/, let's say neoform-foo.
  2. See package.json in already existing packages and create new neoform-foo/package.json.
  3. Put source code in neoform-foo/src/, it will be transpiled and bundled into neoform-foo/dist/, neoform-foo/lib/ and neoform-foo/es/.
  4. Put tests written with Jest in neoform-foo/test/.
  5. Put demo in neoform-foo/demo/, it will be rendered and wrapped with HMR.

Available scripts using Start:

yarn start build <package>
yarn start demo <package>
yarn start test
yarn start testWatch
yarn start lint

Available demos:

yarn start demo neoform
yarn start demo neoform-validation