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 added effort to solve a solved problem?

  • It’s flexible. Project specs have a tendency to change rapidly no matter how firmly they have been defined before. 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:

const formSettings = { fields: [ { name: 'fullName', type: 'text', defaultValue: null, }, { name: 'password', type: 'password', defaultValue: null, }, { name: 'email', type: 'email', defaultValue: null, }, ], }

…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.

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> ); };

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.

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; ); };

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.

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; ); };

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.

November 15, 2021

Dmitriy Efimov

Frontend engineer