DEV Community

Sabin Pandelovitch
Sabin Pandelovitch

Posted on

Forms in React, a tale of abstraction and optimisation

Table of contents

The basics
Abstraction
Optimisation

In my example I use the Material-UI library, and mostly the TextField component.

It can be removed and adapted to any library or no library at all.

The basics

Below is an example of a basic form with a few inputs (fullWidth is used just for view purposes only)

const Form = () => {
  return (
    <form>
      <TextField label="Name" name="name" type="text" fullWidth />
      <TextField label="Age" name="age" type="number" fullWidth />
      <TextField label="Email" name="email" type="email" fullWidth />
      <TextField label="Password" name="password" type="password" fullWidth />
      <Button type="submit" fullWidth>
        submit
      </Button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

CodeSandbox example

In order to use the data and do something with it, we would need the following:

An object to store the data

For this we will use the useState hook from React

const [formData, setFormData] = useState({});
Enter fullscreen mode Exit fullscreen mode
A handler to update the data
  • We need a function that takes the value and the name as a key from the input event.target object and updates the formData object
const updateValues = ({ target: { name, value } }) => {
    setFormData({ ...formData, [name]: value });
};
Enter fullscreen mode Exit fullscreen mode
  • Bind the function to the inputs onChange event
<TextField ... onChange={updateValues} />
Enter fullscreen mode Exit fullscreen mode
  • Extra: Usually in forms there are components have some logic and not update the values via the event object and have their own logic, for example an autocomplete component, image gallery with upload and delete, an editor like CKEditor etc. and for this we use another handler
const updateValuesWithParams = (name, value) => {
    setFormData({ ...formData, [name]: value });
};
Enter fullscreen mode Exit fullscreen mode
A handler to submit the data
  • The function that does something with the data. In this case it displays it in the console.
const submitHandler = e => {
    e.preventDefault();

    console.log(formData);
};
Enter fullscreen mode Exit fullscreen mode
  • Bind the function to the form onSubmit event
<form onSubmit={submitHandler}>
Enter fullscreen mode Exit fullscreen mode

Voila, now we have a form that we can use

CodeSandbox example

Abstraction

The main idea with abstraction for me is not to have duplicate code or duplicate logic in my components, after that comes abstraction of data layers and so on...

Starting with the code duplication the first thing is to get the inputs out into objects and iterate them.

We create an array with each field as a separate object

const inputs = [
  {
    label:'Name',
    name:'name',
    type:'text'
  },
  {
    label:'Age',
    name:'age',
    type:'number'
  },
  {
    label:'Email',
    name:'email',
    type:'email'
  },
  {
    label:'Password',
    name:'password',
    type:'password'
  },
]
Enter fullscreen mode Exit fullscreen mode

And just iterate over it in our form render

const Form = () => {
  ...

  return (
    <form onSubmit={submitHandler}>
      {formFields.map(item => (
        <TextField
          key={item.name}
          onChange={updateValues}
          fullWidth
          {...item}
        />
      ))}
      <Button type="submit" fullWidth>
        submit
      </Button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

CodeSandbox example

So far so good, but what happens if we have more than one form? What happens with the handlers? do we duplicate them also?

My solution was to create a custom hook to handle this. Basically we move the formData object and handlers outside the components.

I ended with a useFormData hook

import { useState } from "react";

const useFormData = (initialValue = {}) => {
  const [formData, setFormData] = useState(initialValue);

  const updateValues = ({ target: { name, value } }) => {
    setFormData({ ...formData, [name]: value });
  };

  const updateValuesParams = ({ target: { name, value } }) => {
    setFormData({ ...formData, [name]: value });
  };

  const api = {
    updateValues,
    updateValuesParams,
    setFormData
  };

  return [formData, api];
};

export default useFormData;
Enter fullscreen mode Exit fullscreen mode

Which can be used in our form components as follows

const [formData, { updateValues, updateValueParams, setFormData }] = useFormData({});
Enter fullscreen mode Exit fullscreen mode

The hook one parameter when called.

  • initialFormData: An object with initial value for the formData state in the hook

The hook returns an array with two values:

  • formData: The current formData object
  • api: An object that exposes the handlers outside the hook

Our component now looks like this

const Form = () => {
  const [formData, { updateValues }] = useFormData({});

  const submitHandler = e => {
    e.preventDefault();

    console.log(formData);
  };

  return (
    <form onSubmit={submitHandler}>
      {formFields.map(item => (
        <TextField
          key={item.name}
          onChange={updateValues}
          fullWidth
          {...item}
        />
      ))}
      <Button type="submit" fullWidth>
        submit
      </Button>
    </form>
  );
};
Enter fullscreen mode Exit fullscreen mode

CodeSandbox example

Can we go even further? YES WE CAN!

Let's take the example with two forms, what do we have duplicated now?

Well for starters we have the submitHandler and the actual <form> it self. Working on the useFormData hook, we can create a useForm hook.

import React, { useState } from "react";
import { Button, TextField } from "@material-ui/core";

const useForm = (
  initialFormDataValue = {},
  initalFormProps = {
    fields: [],
    props: {
      fields: {},
      submitButton: {}
    },
    handlers: {
      submit: () => false
    }
  }
) => {
  const [formData, setFormData] = useState(initialFormDataValue);

  const updateValues = ({ target: { name, value } }) => {
    setFormData({ ...formData, [name]: value });
  };

  const updateValuesParams = ({ target: { name, value } }) => {
    setFormData({ ...formData, [name]: value });
  };

  const formFields = initalFormProps.fields.map(item => (
    <TextField
      key={item.label}
      defaultValue={initialFormDataValue[item.name]}
      onChange={updateValues}
      {...item}
      {...initalFormProps.props.fields}
    />
  ));

  const submitForm = e => {
    e.preventDefault();

    initalFormProps.handlers.submit(formData);
  };

  const form = (
    <form onSubmit={submitForm}>
      {formFields}
      <Button type="submit" {...initalFormProps.props.submitButton}>
        Submit
      </Button>
    </form>
  );

  const api = {
    updateValues,
    updateValuesParams,
    setFormData,
    getFormFields: formFields
  };

  return [form, formData, api];
};

export default useForm;

Enter fullscreen mode Exit fullscreen mode

It takes the useFormData hook from before and adds more components to it. Mainly it ads the form component and the formFields to the hook.

The hook now has 2 parameters when called.

- initialFormData

An object with the value that we want to initialise the formData with

- initalFormProps

An object with the configurations for the form

  • fields: Array with the fields objects
  • props: Object with props for the fields components(TextField in our case) and the submitButton component
  • handlers: The handler for submit in this case

The hook is called as followed

const Form = () => {
  const [form] = useForm(
    {},
    {
      fields: formFields,
      props: {
        fields: {
          fullWidth: true
        },
        submitButton: {
          fullWidth: true
        }
      },
      handlers: {
        submit: formData => console.log(formData)
      }
    }
  );

  return form;
};
Enter fullscreen mode Exit fullscreen mode

CodeSandbox example

The advantage of this custom hook is that you can override all of the methods whenever you need it.

If need only the fields from the from and not the plain form you can get them via the api.getFormFileds method and iterate them as you need.

I will write an article explaining and showing more example of this custom hook

Optimisation

My most common enemy was the re rendering of the components each time the formData object was changed. In small forms that is not an issue, but in big forms it will cause performance issues.

For that we will take advantage of the useCallback and useMemo hooks in order to optimise as much as we can in our hook.

The main idea was to memoize all the inputs and the form since it is initialised with a value, it should change only when the value is changed and not in any other case, so it will not trigger any unnecessary renders.

I ended up with the following code for the hook

import React, { useState, useMemo, useCallback } from "react";
import { Button, TextField } from "@material-ui/core";

const useForm = (
  initialFormDataValue = {},
  initalFormProps = {
    fields: [],
    props: {
      fields: {},
      submitButton: {}
    },
    handlers: {
      submit: () => false
    }
  }
) => {
  const [formData, setFormData] = useState(initialFormDataValue);

  const updateValues = useCallback(
    ({ target: { name, value, type, checked } }) => {
      setFormData(prevData => ({
        ...prevData,
        [name]: type !== "chechbox" ? value : checked
      }));
    },
    []
  );

  const updateValuesParams = useCallback(
    (name, value) =>
      setFormData(prevData => ({
        ...prevData,
        [name]: value
      })),
    []
  );

  const formFields = useMemo(
    () =>
      initalFormProps.fields.map(item => (
        <TextField
          key={item.label}
          defaultValue={initialFormDataValue[item.name]}
          onChange={updateValues}
          {...item}
          {...initalFormProps.props.fields}
        />
      )),
    [updateValues, initalFormProps, initialFormDataValue]
  );

  const submitForm = useCallback(
    e => {
      e.preventDefault();

      initalFormProps.handlers.submit(formData);
    },
    [initalFormProps, formData]
  );

  const formProps = useMemo(
    () => ({
      onSubmit: submitForm
    }),
    [submitForm]
  );

  const submitButton = useMemo(
    () => (
      <Button type="submit" {...initalFormProps.props.submitButton}>
        Submit
      </Button>
    ),
    [initalFormProps]
  );

  const form = useMemo(
    () => (
      <form {...formProps}>
        {formFields}
        {submitButton}
      </form>
    ),
    [formFields, formProps, submitButton]
  );

  const api = useMemo(
    () => ({
      updateValues,
      updateValuesParams,
      setFormData,
      getFormFields: formFields
    }),
    [updateValues, updateValuesParams, setFormData, formFields]
  );
  return [form, formData, api];
};

export default useForm;
Enter fullscreen mode Exit fullscreen mode

CodeSandbox example

Above and beyond

If we run the above example we would still have a render issue because of the submitForm callback, due to its formData dependency.

It's not the perfect case scenario but it's a lot better than no optimisation at all

My solution for this was to move the formData in the store. Since my submitHandler is always dispatch and I only send the action, I was able to access the formData directly from Redux Saga and therefore remove the formData from the hook and also from the dependency array of sumbitForm callback. This may not work for others so I did not include this in the article.

If someone has any thoughts on how to solve the issue with the formData from the submitForm I would be glad to hear them

Top comments (2)

Collapse
 
vazcoltd profile image
Vazco - specializing in future proof technologies

Hey @sabbin check our React library for building forms from any schema - maybe this will be something helpful for you. uniforms.tools

Collapse
 
sabbin profile image
Sabin Pandelovitch

Will take a look over the sources. I have an idea for the re render issue. Thanks for the reply!