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>
);
}
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({});
A handler to update the data
- We need a function that takes the
value
and thename
as a key from the inputevent.target
object and updates theformData
object
const updateValues = ({ target: { name, value } }) => {
setFormData({ ...formData, [name]: value });
};
- Bind the function to the inputs
onChange
event
<TextField ... onChange={updateValues} />
-
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 });
};
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);
};
- Bind the function to the form
onSubmit
event
<form onSubmit={submitHandler}>
Voila, now we have a form that we can use
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'
},
]
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>
);
}
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;
Which can be used in our form components as follows
const [formData, { updateValues, updateValueParams, setFormData }] = useFormData({});
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>
);
};
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;
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;
};
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;
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)
Hey @sabbin check our React library for building forms from any schema - maybe this will be something helpful for you. uniforms.tools
Will take a look over the sources. I have an idea for the re render issue. Thanks for the reply!