Complex form with custom components
Creating a form in React is sometimes hard work. Even more so if it contains custom components, which are stateful and controlled components.
Why am I trying to build a form this way? Why not just combine simple elements like input, button, in the form? For these questions, let me explain the background.
- I want to implement and encapsulate business rules in custom components.
- I want to Make sure that business rules have been implemented correctly by unit-testing on the custom components.
Sample Form
This is a sample form that contains two custom components and a simple text area. Think of it as an inquiry form.
"Customer Code" field requires input in a specific format. Server-side validation may be performed.
"Contract Code" field is also with a specific business rule implementation.
Decomposition
When thinking about form specification, the first thing, which usually comes to mind, is the data that the form will build and submit.
However, since this is a technical article, I will think of it in the opposite way, start by looking at the smallest component.
ValidatedInput
Before talking about CustomerCodeInput, I need to touch on the ValidatedComponent because CustomerCodeInput is a specialized version of ValidatedComponent.
import React, { ForwardedRef, forwardRef, HTMLProps } from 'react';
import { useValidatedInput } from '@/components/elements/validated-input/useValidatedInput';
export interface ValidatedInputProps
extends Omit<HTMLProps<HTMLDivElement>, 'onChange' | 'value'>,
ReturnType<typeof useValidatedInput> {
showError?: boolean;
ref?: ForwardedRef<HTMLInputElement>;
}
export const ValidatedInput = forwardRef<HTMLInputElement, ValidatedInputProps>((props, ref) => {
const {
className,
maxLength,
label,
placeholder,
value,
errorMessage,
onChange,
showError = true,
...rest
} = props;
return (
<div {...rest} className={className}>
<label className="flex w-full flex-col gap-2">
<span>{label}</span>
<input
ref={ref}
className="w-full rounded border border-gray-400 px-3 py-2"
type="text"
maxLength={maxLength}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
{showError && errorMessage && (
<span role="alert" className="-mt-1 text-sm text-red-500">
{errorMessage}
</span>
)}
</label>
</div>
);
});
ValidatedInput.displayName = 'ValidatedInput';
The point is useValidatedInput hook. The custom hook externalizes the state management of this component. It allows us to lift the state management to the parent component.
CustomerCodeInput
import React, { forwardRef } from 'react';
import clsx from 'clsx';
import { twMerge } from 'tailwind-merge';
import {
ValidatedInput,
ValidatedInputProps,
} from '@/components/elements/validated-input/ValidatedInput';
type Props = Omit<ValidatedInputProps, 'label' | 'maxLength'>;
export const CustomerCodeInput: React.FC<Props> = forwardRef<HTMLInputElement, Props>(
(props, ref) => {
const { className, ...rest } = props;
return (
<ValidatedInput
{...rest}
ref={ref}
label="Customer Code"
className={twMerge(clsx('', className))}
placeholder="000-12345"
maxLength={9}
/>
);
}
);
CustomerCodeInput.displayName = 'CustomerCodeInput';
CustomerCodeInput is implemented with a specific business rule. Since this is just an example, it looks simple, but in the real world it should be more complicated.
The business rule is implemented in useCustomerCodeInput custom hook and tested by its unit test.
ContractCodeInput
The structure of ContractCodeInput is the same as CustomerCodeInput, but the business rule implemented is different. Since this is an example, it looks similar though.
The rule can be tested individually in the unit test on the custom hook.
FormContent
import React, { useEffect, useRef, useState } from 'react';
import { useFormContext } from '@/app/form/useFormContext';
import { Button } from '@/components/elements/Button';
import { TextBox } from '@/components/elements/TextBox';
import { ContractCodeInput } from '@/components/widgets/contract-code-input/ContractCodeInput';
import { CustomerCodeInput } from '@/components/widgets/customer-code-input/CustomerCodeInput';
export const FormContent: React.FC = () => {
const { customerCode, contractCode, comment, validForm, reset, submit } = useFormContext();
const [inputStarted, setInputStarted] = useState(false);
const customerCodeRef = useRef<HTMLInputElement>(null);
const contractCodeRef = useRef<HTMLInputElement>(null);
const TextBoxRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
customerCodeRef.current?.focus();
customerCodeRef.current && (customerCodeRef.current.onblur = () => setInputStarted(true));
contractCodeRef.current && (contractCodeRef.current.onblur = () => setInputStarted(true));
TextBoxRef.current && (TextBoxRef.current.onblur = () => setInputStarted(true));
}, []);
return (
<form className="flex flex-col gap-8">
<CustomerCodeInput {...customerCode} ref={customerCodeRef} showError={inputStarted} />
<ContractCodeInput {...contractCode} ref={contractCodeRef} showError={inputStarted} />
<TextBox placeholder="Comment" label="Comment" {...comment} ref={TextBoxRef} />
<div className="mt-4 flex gap-4">
<Button
type="submit"
disabled={!validForm}
onClick={(e) => {
e.preventDefault();
submit();
}}
>
Submit
</Button>
<Button
type="reset"
variant="secondary"
onClick={(e) => {
e.preventDefault();
setInputStarted(false);
customerCodeRef.current?.focus();
reset();
}}
>
Cancel
</Button>
</div>
</form>
);
};
The is the form implementation. In this implementation, the point is Context, useFormContext.
The state lifted from each component is being managed in this Context. So this form needs to be wrapped by ContextProvider.
export const Form: React.FC = () => {
return (
<FormContextProvider customerCode="500-11235" contractCode="">
<FormContent />
</FormContextProvider>
);
};
There are two reasons to use Context.
The first is to make it possible to implement and test the logic to verify the integrity of the entire form in that.
The other is to provide a service hatch from one component to another within the form. (But I think using the service hatch like this example is not so good. Basically, each component should be unaware of the others).
Recap
Now let's look back and summarize what I wrote.
Form
- Form should have Context.
- Context manages state lifted from each component.
- Form data integrity should be checked in this Context.
- Check logic should be tested by a unit test on the Context.
Custom components in Form
- Custom component should use forwardRef
- Custom component should externalize its state management in a custom hook. This makes it easy to lift the state up to the parent.
Encapsulation
- A specific business rule can be encapsulated in a custom component. And it can also be tested by a unit test on a custom hook.
Testing
- Testing compound conditions is very hard, which is implemented in one place.
- Testing hooks or Context is easier and less expensive than UI testing with rendering.
Very large form is hard to test. It is difficult to test whether the combined business rules have been implemented correctly if they are in one place.
So, in my opinion, modularizing them into custom components is a good way to handle complicated requirements.
Thank you!
Top comments (0)