React Form

React advises you to split up your application in many small components and compose them in bigger parts. I am fully on board with this principle, but as I mentioned in a previous blog post, it can be difficult to make the communication between these components efficient. Designing a form in React has the same problem. In this post I want to discuss the options.

A simple first approach you can do is keep everything of the form in a single component. In that case you don't really need any communication between your components, as all data is located in the component that also does the rendering. This does however mean that you might end up with a very big component, and it doesn't allow you to re-use parts of the form. For instance, parts such as address information might appear in multiple forms. It would be best to make this a separate component so it can be re-used. It is clear that this approach does not suffice.

To properly fix this issue, you can use a library such as react-hook-form. This works fine, and it does try to minimize re-renders, which is a win-win case. It does however rely heavily on certain ids which need to be agreed upon by the parent and the child component. When considering re-usability, this is not perfect as this means that you have to know these ids whenever you want to re-use the component. The typescript compiler can not detect this, nor can it make suggestions or enforce this.

My preferred way would be to pass the needed data as props, but this makes getting the data back to the parent a problem. As in my previous blog post, you don't just want to use callbacks to get the data back whenever it changes, as that would cause a lot of re-renders. You would want to get the data only when the user submits, but how do you do that? Using refs would be the default way to do this, which means you have to create a ref and initialize the default value to pass it on to the child component.

Sounds great, but even this has it's limitations, especially with regards to marking the field as being invalid. I prefer to only mark a field as invalid after a user clicks the submit button, but this means we have to update the child component and tell it to mark the fields. Whether or not the child component can decide whether a field is valid or not, is a different topic and does not matter here. In either case you have to pass extra data from the child to the parent or the other way around.

The solution I have found is a bit of a nasty one, I will admit that. I decided to use JavaScript objects, which can keep track of both the value and whether or not the field is invalid or not. I pass this object as a prop such that the child can update the value (and means it is available to the parent). The parent can handle the submit, and if needed it can pass an updated object (which unfortunately you have to do by making a copy of the object) back to the child to trigger a re-render.

An example of some code:

export type FormField<T> = {
    value: T;
    error?: boolean;
    required?: boolean;
};


export type TextInputProps = {
    name?: string;
    label: string;
    field: FormField<string>;
    type?: "email" | "password" | "tel" | "text" | "url";
};

function TextInput(props: TextInputProps): JSX.Element {
    const [value, setValue] = useState(props.field.value);
    const [error, setError] = useState(props.field.error);

    useEffect(() => {
        if (props.field.value !== value) {
            setValue(props.field.value);
        }
    }, [props.field.value]);

    useEffect(() => {
        if (props.field.error !== error) {
            setError(props.field.error);
        }
    }, [props.field.error]);

    const onChange = useCallback(
        (event: React.ChangeEvent<HTMLInputElement>): void => {
            props.field.value = event.target.value;
            props.field.error = false;

            setValue(props.field.value);
            setError(props.field.error);
        },
        [props.field]
    );

    return (
        <TextField
            name={props.name}
            label={props.label}
            type={props.type}
            value={value}
            error={error}
            required={props.field.required}
            onChange={onChange}
            InputLabelProps={{ shrink: true }}
            variant="standard"
            fullWidth
        />
    );
}

As I mentioned before, I don't think this is a perfect solution, but so far I haven't found a much better solution yet. In a follow-up blog post, I will also show how I try to create the whole form from the fields and express that in an object as well.