Disclaimer: Sorry for all possible typos and potentially confusing info. I just wanted to show my approach for implementing forms using React, without spending too much time
For one of my projects, I had to implement a simple, but a relatively long form ( 40 fields ). In this post I'm going to show you the approach that I've taken.
Requirements
My form has to be simple, but flexible. It has various input fields. A lot of different validation requirements.
Technologies
I've decided not to reinvent the wheel and used standard tech:
- react-hook-form ( because it's easy to extend on your native form)
- yup for validation ( because some validation on my project is tricky)
Like in many other projects in my standard setup, I'm using
- eslint with airbnb styleguide
- prettier for code formatting
All code is written using react/typescript.
Approach
What I end up doing is to develop a custom input components.
I can use this components anywhere ( deeply nested ) anywhere on my form.
// components/form/TextInput.tsx
// example of text input component, I've similar for other inputs
import React from 'react';
import { ErrorMessage } from '@hookform/error-message';
import { UseFormReturn } from 'react-hook-form';
import { CustomInputField } from 'utils/types';
import classnames from 'classnames';
import ConnectForm from './ConnectForm';
import ErrorPrompt from './ErrorPrompt';
export const TextInput = ({
name,
label,
required,
...rest
}: CustomInputField & React.HTMLProps<HTMLInputElement>) => (
<ConnectForm>
{({ register, formState: { errors } }: UseFormReturn) => (
<div className="mb-3 row">
<label htmlFor={`text-field-${name}`} className="form-label col-sm-2">
{label}
{required && <span className="required"> * </span>}
</label>
<div className="col-sm-10">
<input
id={`text-field-${name}`}
{...register(name)}
{...rest}
className={classnames('form-control', { 'is-invalid': errors[name] })}
/>
<ErrorMessage errors={errors} name={name} render={ErrorPrompt} />
</div>
</div>
)}
</ConnectForm>
);
export default TextInput;
ConnectForm component is designed as per react-hook-form documentation
https://react-hook-form.com/advanced-usage/#ConnectForm.
So my final form structure is very simple:
const methods = useForm({
resolver: yupResolver(FormValidationSchema),
mode: 'onSubmit',
reValidateMode: 'onChange',
});
return (
<div className="registration-form container-sm">
<h1>Registration Form</h1>
<FormProvider {...methods}>
<form
onSubmit={methods.handleSubmit(onSubmit)}
className="row g-3 needs-validation"
noValidate
>
<fieldset>
<legend>User Details:</legend>
<TextInput label="Given name" name="givenName" placeholder="e.g. Jane" required />
<TextInput label="Family name" name="surname" placeholder="e.g. Doe" required />
<SingleDateInput label="Date of birth" name="dateOfBirth" />
<RadioInput
label="Gender"
name="gender"
options={['Male', 'Female', 'Another gender', 'Unknown']}
required
/>
Validation
I validate my form using validation resolver and validation schema, which I setup in a separate file
// form.tsx
const methods = useForm({
resolver: yupResolver(FormValidationSchema),
mode: 'onSubmit',
reValidateMode: 'onChange',
});
// validationSchema.ts
export const FormValidationSchema = yup
.object({
givenName: yup
.string()
.required(VALIDATION_MESSAGE_REQUIRED)
.max(30, VALIDATION_MESSAGE_MAX_CHAR),
surname: yup
.string()
.required(VALIDATION_MESSAGE_REQUIRED)
.max(30, VALIDATION_MESSAGE_MAX_CHAR),
dateOfBirth: yup
.date()
.transform(parseDateString)
.min(subYears(today, 140), 'Date of Birth can\'t be more than 140 years in the past') // eslint-disable-line
.max(today),
Unit Tests
I've also developed it using TDD approach, so I've written tests first and have a good coverage.
describe('Registration Form', () => {
test('renders correctly', async () => {
const { findByText } = render(<RegistrationForm />);
expect(await findByText(/User Details/)).toBeTruthy();
});
test('has all the fields', async () => {
const { findByText } = render(<RegistrationForm />);
expect(await findByText(/User Details/)).toBeTruthy();
expect(screen.getByText('Given name')).toBeInTheDocument();
expect(screen.getByText('Family name')).toBeInTheDocument();
expect(screen.getByText('Date of birth')).toBeInTheDocument();
});
test.skip('validation works', async () => {
render(<RegistrationForm />);
userEvent.click(await screen.findByText('Submit'));
await wait();
expect(screen.getAllByText(VALIDATION_MESSAGE_REQUIRED).length).toBe(3);
});
Conclusion
In my view final product is clear and can be picked up by any other developer without too much learning. Flexible html allows it to structure in any way when this form will get a custom design from an another developer ( CSS expert )
I hope this content was useful for some people.
I cut corners on certain implementation details, but let me know if you want me to elaborate on certain stuff.
Happy to answer any questions.
Top comments (0)