React, creating a form with custom components
Tags: react, testing, form
Published: 2024-02-12 09:20:03

## 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.

![Form I am going to create](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/nb16nbnzdsltn7blxkut.png)

"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

![A custom component in the form](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ac57ibgn3ql30j4szb10.png)

Before talking about [CustomerCodeInput](https://github.com/yamamotok/ui-components-demo/blob/main/src/components/widgets/customer-code-input/CustomerCodeInput.tsx), I need to touch on the [ValidatedComponent](https://github.com/yamamotok/ui-components-demo/blob/main/src/components/elements/validated-input/ValidatedInput.tsx) because CustomerCodeInput is a specialized version of ValidatedComponent.

```tsx
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](https://github.com/yamamotok/ui-components-demo/blob/main/src/components/elements/validated-input/useValidatedInput.ts) hook. The custom hook externalizes the state management of this component. It allows us to lift the state management to the parent component.

### CustomerCodeInput

```tsx
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](https://github.com/yamamotok/ui-components-demo/blob/main/src/components/widgets/customer-code-input/useCustomerCodeInput.ts) custom hook and tested by its [unit test](https://github.com/yamamotok/ui-components-demo/blob/main/src/components/widgets/customer-code-input/useCustomerCodeInput.test.ts).

### ContractCodeInput

The structure of [ContractCodeInput](https://github.com/yamamotok/ui-components-demo/blob/main/src/components/widgets/contract-code-input/ContractCodeInput.tsx) 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](https://github.com/yamamotok/ui-components-demo/blob/main/src/components/widgets/contract-code-input/useContractCodeInput.test.ts) on the custom hook.

### FormContent

```tsx
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](https://github.com/yamamotok/ui-components-demo/blob/main/src/app/form/useFormContext.tsx).

The state lifted from each component is being managed in this Context. So this form needs to be wrapped by ContextProvider.

```tsx
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](https://github.com/yamamotok/ui-components-demo/blob/main/src/app/form/CommentBox.tsx#L11) 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.

[Demo page](https://ui-components-demo-two.vercel.app/form)

Thank you!