Cookies managing
We use cookies to provide the best site experience.
Cookies managing
Cookie Settings
Cookies necessary for the correct operation of the site are always enabled.
Other cookies are configurable.
Essential cookies
Always On. These cookies are essential so that you can use the website and use its functions. They cannot be turned off. They're set in response to requests made by you, such as setting your privacy preferences, logging in or filling in forms.
Analytics cookies
Disabled
These cookies collect information to help us understand how our Websites are being used or how effective our marketing campaigns are, or to help us customise our Websites for you. See a list of the analytics cookies we use here.
Advertising cookies
Disabled
These cookies provide advertising companies with information about your online activity to help them deliver more relevant online advertising to you or to limit how many times you see an ad. This information may be shared with other advertising companies. See a list of the advertising cookies we use here.
Engineering

Low Effort DIY Form Components

Working in an environment with tight deadlines and a lot to do, it’s easy to see the benefits of existing libraries, kits and components. 90% of the time, if you need a problem solved, there’s already a package that solves it and has 10 thousand stars on GitHub. Forms especially have become an almost inevitable dependency. So why take on the added effort to solve a solved problem?

  • It’s flexible. Project specs tend to change rapidly no matter how firmly they have been defined. Vendor components can get increasingly difficult to keep working for you.
  • It can be as simple or complicated as you need, and it won’t be as bulky as a works-for-every-use-case library.
  • Customisability. With every dependency, you’re adopting someone else’s conventions and design decisions. Maintaining a cohesive app where parts are designed to work together is a much more pleasant experience.

To do anything from scratch the right way is quite an undertaking. But, depending on your process and project requirements, this approach can save you both time and pain. There are a lot of ways to do this, so treat it as a demonstration rather than a prescription.
How it's done

A good place to start with developing an isolated piece of code is (a little counterintuitively) to consider its API. You could throw together a form component that accepts a configuration object and ignore inner markup and styling.

So, something like this:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>title</title>
    <link rel="stylesheet" href="style.css">
    <script src="script.js"></script>
  </head>
  <body>
    <const formSettings = {
  fields: [
    {
      name: 'fullName',
      type: 'text',
      defaultValue: null,
    },
    {
      name: 'password',
      type: 'password',
      defaultValue: null,
    },
    {
      name: 'email',
      type: 'email',
      defaultValue: null,
    },
  ],
}>
  </body>
</html>
…could be parsed by our system into a set of fields straight off the bat. This is a very useful approach when you are building something with a lot of forms and a complicated layout isn’t really a consideration.

What I’d like to go over instead is component composition.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>title</title>
    <link rel="stylesheet" href="style.css">
    <script src="script.js"></script>
  </head>
  <body>
    <const fallbackData = {
  name: null,
  email: null,
  password: null,
};

export const UserSettingsForm = (onSubmit) => {
  const [formData, setFormData] = useState(fallbackData);

  const handleSubmit = () => {
    onSubmit(formData);
  }

  return (
    <Form onChange={setFormData} value={formData}>
      <TextInput name="fullName">
      <TextInput name="password" mask="password" type="password">
      <TextInput name="fullName" mask="email">
      <Button onClick={handleSubmit}>Save</Button>
    </Form>
  );
};>
  </body>
</html>
Anything going on within the first level of children passed down to your form is accessible and readable to it. So long as the nested components adhere to a set of simple conventions, most importantly controlled components.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>title</title>
    <link rel="stylesheet" href="style.css">
    <script src="script.js"></script>
  </head>
  <body>
    <export const Form = ({
  children, data, onChange: onFormDataChange,
}) => {
  const handleChange = useCallback(async (field, value) => onFormDataChange({ ...formData, [field]: value }), [data]);

  const childMapper = useCallback((child) => {
    if (!child.props) return child;

    const { name, children: nestedChildren } = child.props;
    const newChildren = React.Children.map(nestedChildren, childMapper);

    if (name in Object.keys(formData)) {
      return React.cloneElement(child, {
        onChange: async (val) => { handleChange(name, val); },
        value: formData[namePart],
        children: newChildren,
      });
    } else if (newChildren) {
      return React.cloneElement(child, { children: newChildren });
    }

    return child;
  }, [formData, children]);

  const mappedChildren = React.Children.map(children, childMapper);

  return mappedChildren;
  );
};>
  </body>
</html>
What’s going on here is the Form maps its children and checks if they have a “name” prop in there set to one of the field names of the object to be mutated by the form. If they do, they are passed a value and an onChange. That’s the gist of it. You don’t even need to manage submits. The cool thing about doing it like this is that the form itself can be one big controlled component, unlike a black box that spits out its state only on submit.

You could instead of passing input components, pass a wrapper with a “type” prop. This will have the added benefit of allowing you to discriminate between components via 'child.type'.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>title</title>
    <link rel="stylesheet" href="style.css">
    <script src="script.js"></script>
  </head>
  <body>
    <export const UserSettingsForm = (onSubmit) => {
  const [formData, setFormData] = useState(fallbackData);

  const handleSubmit = () => {
    onSubmit(formData);
  }

  return (
    <Form onChange={setFormData} value={formData}>
      <FormField name="fullName" type="text">
      <FormField name="password" mask="password" type="password">
      <FormField name="fullName" mask="email" type="text">
      <Button onClick={handleSubmit}>Save</Button>
    </Form>
  );
};

export const Form = ({
  children, data, onChange: onFormDataChange,
}) => {
  const handleChange = useCallback(async (field, value) => onFormDataChange({ ...formData, [field]: value }), [data]);

  const childMapper = useCallback((child) => {
    if (child.type !== FormField) return child;

    const { name } = child.props;

    if (name in Object.keys(formData)) {
      return React.cloneElement(child, {
        onChange: async (val) => { handleChange(name, val); },
        value: formData[namePart],
        children: newChildren,
      });
    }

    return child;
  }, [formData, children]);

  const mappedChildren = React.Children.map(children, childMapper);

  return mappedChildren;
  );
};>
  </body>
</html>
For the most part, once you’ve got something like this going, it’s a functional, useable form. Things to work on going forward could include error handling and highlighting, a wrapper that could build a form from a config object like I mentioned earlier and probably a few other features before you can use it in production.
Dmitriy Efimov
Frontend Engineer