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