All articles

NextUI2.0: Custom Form Component And The Application Of Dynamic Forms

Sep 13, 2023

This August, NextUI 2.0 was officially released, and it’s the most visually appealing UI library I have come across. Although I didn’t pay much attention to it during 1.0 version, it doesn’t prevent me from loving this UI library now. It completely outshines other common UI libraries in terms of interaction because it uses Framer Motion to animate certain components. Moreover, it’s developed with TailwindCSS, which is very friendly for developers who are used to using Atomic CSS. Recently, I have a project where I prefer to use NextUI component for development. In this project, there is a requirement for dynamic forms, but NextUI doesn’t have as comprehensive components library as Antd, so I need to wrap a form component myself to collect form data. Additionally, I also need to figure out how to build dynamic forms.

Form Component

The two ways to manage form inputs in React are controlled components and uncontrolled components. Controlled components whose value are controlled and managed by React. In controlled components, the value of the form is managed by React’s state and updated through event handling functions.

import React, { useState } from "react";

function ControlledComponent() {
  const [value, setValue] = useState("");

  const handleChange = (event) => {
    setValue(event.target.value);
  };

  const handleSubmit = (event) => {
    event.preventDefault();
    console.log("Submitted value:", value);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={value}
        onChange={handleChange}
      />
      <button type="submit">Submit</button>
    </form>
  );
}

export default ControlledComponent;

For uncontrolled components, the value is managed by the DOM itself. In uncontrolled components, the value of the form is managed by the DOM node rather than React’s state, the following code:

import React, { useRef } from "react";

function UncontrolledComponent() {
  const inputRef = useRef(null);

  const handleSubmit = (event) => {
    event.preventDefault();
    console.log(
      "Submitted value:",
      inputRef.current.value
    );
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" ref={inputRef} />
      <button type="submit">Submit</button>
    </form>
  );
}

export default UncontrolledComponent;

Using useRef Hook, create an inputRef object and get the value of the input using inputRef.current.value. In comparison, uncontrolled components have greater advantages because the value of form elements in controlled components is managed by React. When the data changes, React automatically updates the component state, trigger a re-render. Additionally, uncontrolled components don’t require with writing event handling functions and state management code for each field. Instead, it direactly use the ref attribute to get the value of the form. They also don’t trigger component re-renders, resulting in better performance. After researching, Using the react-hook-form solution seems to be more concise. For example:

import React from "react";
import { useForm } from "react-hook-form";

export default function App() {
  const { register, handleSubmit, errors } =
    useForm();

  const onSubmit = (data) => {
    console.log(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input
        type="text"
        placeholder="First Name"
        name="firstName"
        ref={register({ required: true })}
      />
      {errors.firstName && (
        <span>This field is required</span>
      )}

      <input
        type="text"
        placeholder="Last Name"
        name="lastName"
        ref={register({ required: true })}
      />
      {errors.lastName && (
        <span>This field is required</span>
      )}

      <button type="submit">Submit</button>
    </form>
  );
}

At first, import the useForm hook form the react-hook-form lib. Then, use it to get the register, handleSubmit, and errors parameter. In the form, use register to register the input fields to the form and associate them with the actual input elements using ref attribute. Set a name attribute for each input field so that the value of that field can be retrieved when the form is submitted. Finally, use the handleSubmit function to handle the form submission event and associate it with the onSubmit callback function of the form. For each input field, add an error message to display in case of validation failure. Use the errors object to check the error status of a specific field and display the error message when needed. By following this process, you can easily get data, validate the form, and display error message. After reading the official docs, I also found that you can use the Controller component to wrap third-party UI lib components, which perfectly fits my needs.

import { Button, Input } from "@nextui-org/react";
import {
  useForm,
  SubmitHandler,
  Controller,
} from "react-hook-form";

interface IFormInput {
  username: string;
}

export const Form = () => {
  const {
    control,
    handleSubmit,
    formState: { errors },
  } = useForm <
  IFormInput >
  {
    defaultValues: {
      username: "",
    },
  };

  const onSubmit: SubmitHandler<IFormInput> = (
    data
  ) => {
    console.log(data);
  };

  return (
    <form
      onSubmit={handleSubmit(onSubmit)}
      className="flex flex-col gap-4"
      noValidate
    >
      <Controller
        name="username"
        control={control}
        rules={{
          required: {
            value: true,
            message: "username is required",
          },
        }}
        render={({ field }) => (
          <Input
            label="Username"
            placeholder="Enter your Username"
            type="text"
            errorMessage={
              errors?.username?.message
            }
            validationState={
              errors.username
                ? "invalid"
                : "valid"
            }
            isRequired
            {...field}
          />
        )}
      />
      <div className="flex gap-2 justify-end">
        <Button
          type="submit"
          fullWidth
          color="primary"
        >
          Submit
        </Button>
      </div>
    </form>
  );
};

A simple form can collect data through a third-party lib. Then, dynamic settings can be applied to the render in the Controller.

export const Form: React.FC<IFormProps> = ({
  defaultValues,
  children,
  className,
  submit,
}) => {
  type ItemType = typeof defaultValues;

  const {
    control,
    handleSubmit,
    formState: { errors },
  } = useForm <
  ItemType >
  {
    defaultValues,
  };

  const onSubmit: SubmitHandler<ItemType> = (
    data: ItemType
  ) => submit(data);

  return (
    <form
      onSubmit={handleSubmit(onSubmit)}
      className={className}
      noValidate
    >
      {Children.map(children, (child) => {
        const name = child?.props?.name;
        return name ? (
          <Controller
            key={name}
            name={name}
            control={control}
            rules={child?.props?.rules}
            render={({ field }) => {
              return cloneElement(child, {
                errorMessage:
                  errors[name]?.message,
                validationState: errors[name]
                  ? "invalid"
                  : "valid",
                ...field,
              });
            }}
          />
        ) : (
          child
        );
      })}
    </form>
  );
};

By using cloneElement and adding some extra props, the Children can be reused without any further operator. In this case, the child corresponds to the input component. Here, an additional prop called ‘rules’ is needed for form validation with react-hook-form. However, the Input component in NextUI doesn’t have a predefined prop for ‘rules’, so it needs to be wrapped in another component.

import { InputProps } from '@nextui-org/react'
import { Input as NextUIInput } from '@nextui-org/react'
import { forwardRef } from 'react'

export interface RuleProps {
  rules: {
    [k in string]: {
      value: boolean | string
      message: string
    }
  }
}

export const Input: React.FC<InputProps & RuleProps> =
  forwardRef((props, ref) => {
    return <NextUIInput ref={ref} {...props} />
  })

At first, there was no ‘ref’ added, and the console had the following error:

  • Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()? Check the render method of Controller.

At now, the basic form has implemented.

function App() {
  const onSubmit = (data: unknown) => {
    console.log(data);
  };
  return (
    <div className="w-full flex justify-center mt-96">
      <div className="w-[400px] border p-5">
        <Form
          submit={onSubmit}
          defaultValues={{
            username: "",
            password: "123",
            role: "",
          }}
        >
          <Input
            name="username"
            label="Username"
            variant="bordered"
            isRequired
            rules={{
              required: {
                value: true,
                message: "username is required",
              },
            }}
          />
          <Input
            name="password"
            label="Password"
            variant="bordered"
            isRequired
            rules={{
              required: {
                value: true,
                message: "password is required",
              },
            }}
          />
          <Select
            name="role"
            label="Role"
            variant="bordered"
            isRequired
            rules={{
              required: {
                value: true,
                message: "role is required",
              },
            }}
          >
            {animals.map((animal) => (
              <SelectItem
                key={animal.value}
                value={animal.value}
              >
                {animal.label}
              </SelectItem>
            ))}
          </Select>
          <Button type="submit">Submit</Button>
        </Form>
      </div>
    </div>
  );
}

Dynamic Form

After some consideration, I realized that the approach of wrapped a Form Component mentioned above is only suitable for manually configuring form. This is because in dynamic form, different fields may have certain dependencies on each other. For example, the visibility of field B is triggered only when the value of field A is a certain value. Obviously, even if we iterate through the dynamic form item, it’s difficult to maintain the dependencies between fields. Additionally, in this case, the fields are all within same component, making it inconvenient to control their visible state.

To further optimize the wrapped form method above, a form may have multiple field groups, and only the fields under each field group need to be displayed. Therefore, the Form can be divided into three components: Form, FormFieldList, and FormField. This allows the components to maintain granularity, with better resuability and maintainability. The key to handling dynamic form lies in the FormField component, where each field is a FormField component with its own state. It only needs to focus on whether it should be displayed.

export const FormField = () => {
  const [isShow, setIsShow] =
    useState < boolean > true;
  if (!isShow) return null;
  return <>Render</>;
};

How to manage the state of ‘isShow’ is what we should consider next. From the data structure we defined, we need to use ‘useWatch’ to monitor whether the dependency collection of the current component has changed.

// show: [
//     {
//         "field": "authenticationMode",
//         "type": "equals",
//         "value": [
//             "kerberos"
//         ]
//     }
// ]
const changed = useWatch({
  control,
  name: collecteDeps(show),
});

collectDeps simply needs to iterate and obtain the dependencies to be monitored. However, when the data changes, it is necessary to set the ‘isShow’ property of the current component.

useEffect(() => {
  setIsShow(
    checkVisible({ show, formValue: getValues() })
  );
}, [changed]);

The logic of whether to display the form is basically completed now, but the controll and getValues involved are defined at the Form through useForm, which requires sharing the parent component’s state to access the data.

type FormContextProps = {
  widgetComponents: {
    [k: string]: FC<any>
  }
  locales: LocalesProps | undefined
} & UseFormReturn

export const FormRenderContext =
  createContext<FormContextProps>()

The widgetComponents and locales are added as additional parameters for obtaining the corresponding form components and i18n processing.

export const widgetComponents = {
  input: Input,
  select: Select,
};

In widgetComponents, simple integration is done for the individually wrapped Input and Select components.

export const FormRenderProvider: FC<
  PropsWithChildren<FormContextProps>
> = ({
  children,
  widgetComponents,
  ...others
}) => {
  return (
    <FormRenderContext.Provider
      value={{ widgetComponents, ...others }}
    >
      <FormProvider {...others}>
        {children}
      </FormProvider>
    </FormRenderContext.Provider>
  );
};

Simply wrap it around the existing Form by using FormRenderProvider , enabling the sharing of these states.

export const Form: React.FC<
  DynamicsFromProps
> = ({ forms, submit, children, locales }) => {
  const methods = useForm({
    mode: "onChange",
  });

  return (
    <FormRenderProvider
      widgetComponents={widgetComponents}
      locales={locales}
      {...methods}
    >
      <form
        onSubmit={methods.handleSubmit(submit)}
        className={"flex flex-col gap-4"}
        noValidate
      >
        <FormFieldList widgets={forms} />
        {children}
      </form>
    </FormRenderProvider>
  );
};

I would like to mention a few point. The Select component can be synchronous or asynchronous, and in order to reuse the same component, I used the useLoadingList hook in FormField(asynchronous dropdowns). This is means that both Input and synchronous Select will execute this hook, even though I threw out the method that initiated the request and put it in useEffect only for asynchronous Select, it was still a bit inappropriate in my opinion. Currently, I haven’t come up with a better solution because I don’t want to separate synchronous Select and asynchronous Select into two components. I will explore further oprimization options in the futher.

antcao.me © 2022-PRESENT

: 0x9aB9C...7ee7d