In this article, we’re going to ensure that our React hooks-based forms library scales to support dynamic forms and learn a few interesting things about hook state and hook tests along the way. Our library already supports forms with a predefined set of inputs, but it doesn’t support forms where the number of inputs needs to increase or decrease over time.

In part 1 of this series we investigated using React hooks to design a useForm React forms library. Part 1 covers the motivation and some overall design goals of the library.

In part 2 of this series we created a useInput hook, integrated it into our useForm hook, reviewed our solution’s state management, incorporated some additional tests, and reviewed testing strategies in more detail.

In part 3 we covered validation, asynchronous validation, and asynchronous form submits. It gave us the key elements of a React hooks based forms API.

In part 4 we scaled out our solution to support standard HTML input types, and custom inputs.

Working example

Let’s check out a demo of everything that will be included in this part.

Demo dynamic form that we’ll develop from part 5

The full solution is found in my GitHub. There is a lot of boilerplate in the demo, but I’d rather have the examples be very explicit at this point. Please note that tests don’t pass in CodeSandbox at this time due to a CodeSandbox design decision to not support loading devDependencies from package.json .

Motivation to support dynamically changing form inputs

There are numerous form examples that I can think of where the ability to add or remove form fields would be useful. For example:

Capturing tabular data, and allowing users to add or remove rows

Your employment or residence history (I had to fill one of these types of forms recently as part of the background check for a new job that I’m starting very soon 😃)

Essentially any data input scenario that is 1:n

This feature would definitely add value, I think it would be worth ensuring that the useForm design supports it.

Designing the public API to support dynamic forms

useForm allows form developers to add new inputs with the api.addInput function. But form developers can’t remove inputs, and the current version of api.addInput breaks if a form has already been rendered.

Add api.removeInput to useForm API

api.removeInput should allow an input to be removed from an existing form. When called, it should remove all state associated with the removed input, such as input value, validation state, etc.

Fixing api.addInput bug when that didn’t allow new inputs to be added to a rendered form

Although api.addInput was the foundation to add new inputs, React hooks have one critical requirement — the number of hooks rendered must be equal to the last render cycle.

This was the root cause of the issue. Check out my article about it here. Essentially my useInput hook would not support dynamic forms since React hooks require this consistency across every render cycle. Good to know if you’re planning to use hooks to design a structure that can grow or shrink in size.

Implementation details

api.removeInput is a trivial addition to useForm . It just needs to remove all state associated to the removed input:

const removeInput = id => {

delete inputs[id];

delete inputUiState[id];

delete validators[id];

delete formValidity[id];

delete inputValues[id];

delete originalValues[id]; setInputUiState(inputUiState);

};

useInput is a hook and React won’t allow us to use it to increase or decrease inputs, due to a hooks design decision that requires the number of called hooks across render cycles to remain consistent. So we will have to remove the useInput hook, and move all of the state that useInput manages into useForm .

useInput state recap

useInput maintains the following state. All of this state will need to move to useForm :

inputValue

originalValue — for tracking pristine state

— for tracking pristine state visited — changes when an input receives focus

useForm state recap

useForm already tracks all input values. So useInput.inputValue was in a way redundant. useForm won’t have to do anything else to support useInput.inputValue .

useInput ⟹input.js

Since useInput is no longer a hook, I decided to rename it input.js . createInput is the new function that replaces useInput . There is no state in createInput . All state will be managed by useForm . For that reason, createInput ensures that for every event that it handles, it passes the event along to the passed in props .

const getInputValue = ({ type, checked, value, options }) => {

if (type === "checkbox") return checked;

if (type === "select-multiple")

return [...options]

.map(option => ({

value: option.value,

selected: option.selected

}))

.filter(option => option.selected)

.map(option => option.value);

if (type === "radio") {

if (checked) return value;

return undefined;

}

return value;

}; export const createInput = ({ id, value, props = {} }) => {

const getSharedProps = () => ({

id,

...props,

onChange: (event, inputValue) => {

const value = inputValue || getInputValue(event.target);

props.onChange && props.onChange({ event, id, value });

},

onBlur: (event, inputValue) => {

const value = inputValue || getInputValue(event.target);

props.onBlur && props.onBlur({ event, id, value });

},

onFocus: evt => {

props.onFocus && props.onFocus({ evt, id, value });

}

}); const getInputProps = inputProps => ({

...getSharedProps(),

value: value,

...(typeof inputProps === "function" ? inputProps(props) : inputProps)

}); const getCheckProps = inputProps => ({

...getSharedProps(),

checked: value,

...(typeof inputProps === "function" ? inputProps(props) : inputProps)

}); return {

id,

value,

getInputProps,

getCheckProps

};

};

useForm changes

The biggest change in this iteration of useForm was to migrate all of the state that was managed by useInput hook. api.removeInput was added.

Asynchronous validation code issue and fix

I was previously using a useRef hook to try to maintain the most up to date state with asynchronous validations. I had forgotten that useState is mostly the same as setState , so if you need to change state to a value based on the current state, you need to use the function version of useState. For example:

const [formValidity, setFormValidity] = useState({}); ... setFormValidity(current => ({

...current,

[id]: { isValidating: true, value }

}));

The full source for useForm.js is below:

import { useState, useRef } from "react";

import { createInput } from "./input";

import { runValidators, validateInputEvents } from "./validators"; export const defaultFormProps = {

autoComplete: "on"

}; export const useForm = ({ id, initialState = {} }) => {

const [inputValues] = useState({

...initialState

});

const [formValidity, setFormValidity] = useState({});

const [validators] = useState({});

const [uiState, setUiState] = useState({

isValidating: false,

isValid: true,

isSubmitting: false

});

const [inputs] = useState({});

const [inputUiState, setInputUiState] = useState({});

const [originalValues] = useState({}); const validationRuntimeMap = useRef(new Map()); const validateAll = async eventType => {

const promises = [];

let newUiState = { ...uiState }; Object.keys(validators).forEach(async field => {

promises.push(

runValidators({

field,

validators: validators[field],

eventType,

value: inputs[field].value,

inputValues

})

);

}); newUiState = {

...newUiState,

isValidating: true

};

setUiState(newUiState); const results = await Promise.all(promises).catch(() => {

// Do nothing, validation library handles errors.

});

results.forEach(result => {

formValidity[result.field] = result;

}); setUiState({ ...newUiState, isValidating: false });

}; const onSubmit = async (evt, props) => {

evt.preventDefault();

let newUiState = { ...uiState };

try {

await validateAll(validateInputEvents.onSubmit, evt.timeStamp);

const isFormValid = !Object.keys(formValidity).some(

field => !formValidity[field].valid

); newUiState = {

...newUiState,

isSubmitting: true,

isValid: isFormValid

};

setUiState(newUiState);

if (props.onSubmit) {

await props.onSubmit({ evt, inputValues });

}

} catch (e) {

setUiState({ ...newUiState, isSubmitting: false });

} finally {

setUiState({ ...newUiState, isSubmitting: false });

}

}; const getFormProps = (props = {}) => ({

...defaultFormProps,

...props,

onSubmit: evt => {

onSubmit(evt, props);

}

}); const onInputChange = ({ event, id, value }) => {

inputValues[id] = value;

setInputUiState({

...inputUiState,

[id]: { ...inputUiState[id], pristine: value === originalValues[id] }

}); runInputValidations({

id,

value,

eventType: validateInputEvents.onChange,

timeStamp: event.timeStamp

});

}; const isValidatorAlreadyRunning = (id, value) =>

formValidity[id] &&

formValidity[id].isValidating &&

formValidity[id].value === value; // Discard oldest async validations on a given input

const isCurrentValidationRunLatest = (runtimeMap, id, timeStamp) =>

runtimeMap.get(id) === undefined || runtimeMap.get(id) <= timeStamp; const runInputValidations = async ({ id, value, eventType, timeStamp }) => {

validationRuntimeMap.current.set(id, timeStamp);

const filteredValidators = validators[id].filter(validator => {

return validator.when.some(whenItem => whenItem === eventType);

});

if (filteredValidators.length === 0) return; if (validators[id]) {

const isCurrentRunLatest = () =>

isCurrentValidationRunLatest(

validationRuntimeMap.current,

id,

timeStamp

); if (isValidatorAlreadyRunning(id, value)) {

// No need to do anything at this point since validator is already running,

return;

}

if (!isCurrentRunLatest()) return; setFormValidity(current => ({

...current,

[id]: { isValidating: true, value }

})); if (!isCurrentRunLatest()) return; if (!isCurrentRunLatest()) return; setFormValidity(current => ({

...current,

[id]: { ...current[id], isValidating: true, value }

})); if (!isCurrentRunLatest()) return; try {

const validationResults = await runValidators({

field: id,

validators: validators[id],

eventType: eventType,

value,

runId: timeStamp,

inputValues

}); if (!isCurrentRunLatest()) return; setFormValidity(current => ({ ...current, [id]: validationResults }));

} catch {

// Do nothing, validation library handles errors

}

}

}; const onInputBlur = async ({ event, id, value }) => {

runInputValidations({

id,

value,

eventType: validateInputEvents.onBlur,

timeStamp: event.timeStamp

});

}; const onInputFocus = ({ event, id }) => {

setInputUiState({

...inputUiState,

[id]: { ...inputUiState[id], visited: true }

});

}; const isBlurWithinRadioGroup = (event, id) =>

event.relatedTarget && event.relatedTarget.getAttribute("name") === id; const onRadioGroupBlur = async ({ id, value, event }) => {

if (isBlurWithinRadioGroup(event, id)) return; runInputValidations({

id,

value,

eventType: validateInputEvents.onBlur,

timeStamp: event.timeStamp

});

}; const addInput = ({

id,

value,

validators: inputValidators = [],

inputProps = {

onChange: onInputChange,

onBlur: onInputBlur,

onFocus: onInputFocus

}

}) => {

originalValues[id] =

typeof originalValues[id] === "undefined" ? value : originalValues[id];

inputValues[id] =

typeof inputValues[id] === "undefined" ? value : inputValues[id]; const input = createInput({

id,

value: inputValues[id],

props: inputProps

});

inputs[id] = input;

inputUiState[id] = inputUiState[id] || {

pristine: true,

visited: false

};

validators[id] = inputValidators;

return input;

}; const addRadioGroup = ({ id, value, validators = [], inputProps }) => {

const input = addInput({

id,

value,

validators,

inputProps: {

...inputProps,

onChange: onInputChange,

onBlur: onRadioGroupBlur,

onFocus: onInputFocus

}

});

return input;

}; const removeInput = id => {

delete inputs[id];

delete inputUiState[id];

delete validators[id];

delete formValidity[id];

delete inputValues[id];

delete originalValues[id]; setInputUiState(inputUiState);

}; return {

id,

getFormProps,

formValidity,

uiState,

inputs,

inputValues,

inputUiState,

api: {

addInput,

addRadioGroup,

removeInput

}

};

};

Testing updates

There were many changes made to tests as a part of this update. I was not using act() consistently throughout my tests. I learned a few good tips from threepointone’s react-act-examples that helped to improve my use of act() .

During my hooks testing I was mocking timers. And the passage of time updates state in some of my tests. For example, an asynchronous test to simulate a long running validation uses setTimeout, and so the validation library will first set the useForm.uiState validation state to isValidating , which I want to test, and then once time passes, uiState.isValidating is set to false , and the validation results are set.

My calls to jest.runAllTimers modify the hook’s state, and anytime hook state is modified, the function that you call should be wrapped in an act i.e. act(() => jest.runAllTimers()); . This can alleviate test warnings in your terminal such as the following figure:

Test warning due to jest.runAllTimers() modifying hook state but not being called within an act()

it("should be able to add an input with valid asynchronous validation and get correct formValidity input state", async () => {

const customValidator = createValidator({

validateFn: async ({ value }) =>

await new Promise(resolve => {

setTimeout(() => resolve(true), 1);

}),

error: "CUSTOM_ASYNC_ERROR"

}); const { result, waitForNextUpdate } = renderHook(() =>

useForm({ id: "test" })

); act(() => {

result.current.api.addInput({

id: "test",

value: "",

validators: [{ ...customValidator, when: [validateInputEvents.onBlur] }]

});

});

act(

async () =>

await result.current.inputs.test.getInputProps().onBlur({

preventDefault: noop,

target: {

value: ""

}

})

);

expect(result.current.formValidity).toEqual({

test: { isValidating: true, value: "" }

}); act(() => jest.runAllTimers());

await waitForNextUpdate(); expect(result.current.formValidity).toEqual({

test: { field: "test", valid: true }

});

});

Summary

In this post, we refactored our solution to support dynamic forms and created a dynamic forms example.

In later parts we’ll cover other topics such as reducing boilerplate, adding validations from JSON schemas, promise cancellations, debounce, hook performance considerations, and other design optimizations.

At this point, the library is probably just about “good enough” to use in production 😃. Keep in mind the effort and thought that needs to go into making a forms library just “good enough” before you embark on the journey of making a forms library yourself!

Unless you have an extremely simple form that requires no validation logic, yes, you can cobble a form together with React, but it will be very limited. Once you need to get past simple forms, use a forms library. Otherwise, you might not be making the best use of your time. Unless you’re doing it for your own learning purposes of course, which I always recommend learning by developing something that you’re interested in.

Let me know if you have any questions, feedback, or suggestions, or if you would like me to cover anything else within this series.