DEV Community

Keisuke Yamamoto
Keisuke Yamamoto

Posted on

React, creating a form with custom components

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

"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

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';
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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.

Demo page

Thank you!

Top comments (0)