I have an app with some components that uses forms. Each form is a bit different, but they have many things in common, so I’ve decided I’m going to make one form for all of them:
Signup
<Form userData={userToCreate} type="signup" />
CreateUser
<Form setUserDialog={setUserDialog} userData={userToCreate} type="create" />
UpdateUser
<Form
setUserDialog={setUpdateUserDialog}
userData={userData}
setToast={setToast}
onUpdateSuccess={onUpdateSuccess}
type="update"
/>
UpdateProfile
<Form userData={userData} setToastProfile={setToast} type="profile"/>
But I’ve ended up creating a bit larger Form
component with 500 lines of code. Now I can continue to divide this. Large form component into many smaller components, and as it grows, there will be more and more smaller components, or I can just go back to the old plan and create a form for each component. On its own, this way it will be easier to maintain and follow and surely less complicated, but will be large components as well, I’m really confused what is the best solution here, I feel it’s a battle between maintainability and reusability, who wins?
It of course depends on what you are doing in the Form
component, but having 500 lines of code is, as you recognize, an indication that it is about time to break it down or move it out from the component. That being said, a form could be a complex thing.
One setup I often end up with is to keep what is truly common in the component and then use a combination of child components and passing callback functions as props. This way, you can compose the the page specific inputs wrapped in the Form
component and you can handle the submit logic, which probably differs between the cases anyway, in it’s parent. There will be some boilerplate and repeated code for each page, but easier to customize and maintain in the long run.
So what is truly common logic in the case of a form? One example is validation. While what to validate and which rules to apply is very specific for each case, the handling of validation errors and updating the visual state could be the same across all forms.
Here is an example.
// page.jsx
import React from 'react';
import Form from './Form';
function Page() {
const handleFormSubmit = (formData) => {
console.log('Form submitted with data:', formData);
// Handle the form data, e.g., send it to an API
};
// Define a validation schema for the form fields
const validationSchema = {
username: {
required: true,
minLength: 3,
errorMessage: 'Username is required and should be at least 3 characters long.',
},
email: {
required: true,
pattern: /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+.[a-zA-Z]{2,}$/,
errorMessage: 'A valid email address is required.',
},
};
return (
<div>
<h1>My Page</h1>
<Form onSubmit={handleFormSubmit} validationSchema={validationSchema}>
<input type="text" name="username" placeholder="Username" />
<input type="email" name="email" placeholder="Email" />
<button type="submit">Submit</button>
</Form>
</div>
);
}
export default Page;
The inputs are passed as children and the validation schema as prop. Also, the handling of form submission is a callback function passed as prop. This allows the action to pop back from the component and be handled in the parent.
// Form.jsx
import React, { useState } from 'react';
function Form({ onSubmit, validationSchema, children }) {
const [formData, setFormData] = useState({});
const [errors, setErrors] = useState({});
const handleInputChange = (event) => {
const { name, value } = event.target;
// Update form data state
setFormData((prevData) => ({
...prevData,
[name]: value,
}));
// Validate the input field
validateField(name, value);
};
const validateField = (name, value) => {
const rules = validationSchema[name];
let error = '';
if (rules) {
if (rules.required && !value) {
error = rules.errorMessage || `${name} is required.`;
} else if (rules.minLength && value.length < rules.minLength) {
error = rules.errorMessage || `${name} should be at least ${rules.minLength} characters long.`;
} else if (rules.pattern && !rules.pattern.test(value)) {
error = rules.errorMessage || `Invalid ${name}.`;
}
}
// Update errors state
setErrors((prevErrors) => ({
...prevErrors,
[name]: error,
}));
};
const handleSubmit = (event) => {
event.preventDefault();
let hasErrors = false;
// Validate all fields before submission
Object.keys(validationSchema).forEach((field) => {
validateField(field, formData[field]);
if (!formData[field] || errors[field]) {
hasErrors = true;
}
});
// If no errors, submit the form
if (!hasErrors) {
onSubmit(formData);
}
};
// Render children with additional props
const renderChildrenWithProps = () =>
React.Children.map(children, (child) => {
if (React.isValidElement(child) && child.props.name) {
return React.cloneElement(child, {
onChange: handleInputChange,
value: formData[child.props.name] || '',
'aria-describedby': `${child.props.name}-error`,
});
}
return child;
});
return (
<form onSubmit={handleSubmit}>
{renderChildrenWithProps()}
{Object.keys(errors).map(
(key) =>
errors[key] && (
<div key={key} id={`${key}-error`} style={{ color: 'red' }}>
{errors[key]}
</div>
)
)}
</form>
);
}
export default Form;
In the component, before returning a submit to the parent, it intercepts with it’s own handleSubmit
function. This checks the submitted data against the passed validationSchema
in the function validateField
. For this, we need to control the input changes in state, by setting it in handleInputChange
. Since the inputs are passed as children, we need to override them with the additional props by calling renderChildrenWithProps()
instead of simply passing the children by {children}
. This calls the handleInputChange
for each onChange
. The validation could also be retried by each change, not only on submission. Another prop could be to tie the error set in state to the input. When present, errors are looped through and displayed. If no errors are present after validation, handleSubmit
pop the formData
to the parent component.
This setup provides a balance between flexibility and reusability.