If you’re committing to creating functional components instead of class components, then you need to dive into hooks. You’ll use a ton of them, and as your code size increases you’ll likely have to create custom hooks. React hooks provide a new way to abstract reusable logic. It’s easy to get started with them, but I’ve found that as the complexity of use cases increases, abstracting your custom hooks becomes more challenging.

In this multi-part series, we’ll design a simple useForm hook, and talk about testing strategies. I don’t recommend that you build your own custom forms API since there are numerous React form libraries, both component based, and hook based, that you can get started with. But I believe that designing and implementing a forms API is a great way to build a deeper understanding of React hooks.

In part 1, we’ll start by creating a simple useForm hook, and discuss how it might be used in a form. We’ll also discuss approaches to testing the hook. Let’s get started!

By the way, part 2, part 3, and part 4 are online 💪.

Step 1 — Design the public API

I’ve found that the best starting point to designing hooks APIs is to understand first what you want it to do, and then how will you use it. So let’s envision a hook called useForm .

What will it do?

useForm will be the main API entry point

will be the main API entry point useForm will provide input state to the consumers, i.e. name and value. Input state must be able to be passed to the useForm hook for form initializing purposes

will provide input state to the consumers, i.e. name and value. Input state must be able to be passed to the useForm hook for form initializing purposes useForm will provide the overall UI state of a form, such as “is the form getting submitted”, “has the form been submitted”, “is the form getting validated”

will provide the overall UI state of a form, such as “is the form getting submitted”, “has the form been submitted”, “is the form getting validated” useForm will attempt to provide sensible HTML attributes for forms

will attempt to provide sensible HTML attributes for forms useForm provide accessibility props that can be opted in by consumers should they wish to support an accessibility standard such as WCAG AA

provide accessibility props that can be opted in by consumers should they wish to support an accessibility standard such as WCAG AA Consumers will define forms, inputs, and validators through the useForm hook

hook useForm will support asynchronous validations and submits

will support asynchronous validations and submits useForm API should be tested

API should be tested The API will be entirely non-visual so that consumers have total control over the look and feel of their forms

For the purpose of part 1, we will create a simple initial version of the hook that includes custom onSubmit function support, and test strategies.

How will we use it?

I envision usage to look something like the following:

const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); function SampleForm(props) {

const { settings } = props; const { getFormProps, formState, formSummary } = useForm("settingsForm", {

...settings

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

await sleep(2000);

}; return (

<div>

<h2>Sample form</h2>

{formSummary.isSubmitting && <div>Submitting</div>}

<form {...getFormProps({ onSubmit })}>

/* form stuff here */

</form>

</div>

); }

Our form API will be available through the useForm hook. In part 1 of the useForm hook, formState will return the current form state (which will be an empty object at this point). getFormProps will be used by consumers set a form’s props such as onSubmit , since the the useForm hook will implement an onSubmit function that wraps the consumer’s onSubmit callback in order to provide some high level UI state to consumers.

The custom onSubmit function is just a stub that sleeps for 2 seconds. This will be useful to test forms asynchronously.

formSummary will represent the UI summary of the form. In this first UI sample, we’re looking at the isSubmitting prop and if true, we’re displaying a submitting message to the consumer. This kind of property can be implemented in other ways. Perhaps there is a global Redux store in your application that tracks when endpoints are requested and completed, and if so perhaps that could be another approach to tracking the submit state of forms. But to keep a comprehensive API, we’ll include it, but allow consumers to ignore it if they don’t use it.

First implementation

Let’s have a look at the initial version of the useForm hook:

import { useState } from "react"; export const defaultFormProps = {

autoComplete: "on"

};

export const useForm = (name, initialState = {}, props) => {

const [formState, setFormState] = useState({

values: initialState

});

const [formSummary, setFormSummary] = useState({

isSubmitting: false

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

...defaultFormProps,

...props,

onSubmit: async evt => {

evt.preventDefault();

try {

setFormSummary({ isSubmitting: true });

props.onSubmit && (await props.onSubmit({ evt, formState }));

} finally {

setFormSummary({ isSubmitting: false });

}

}

}); return {

getFormProps,

formState: formState,

formSummary

};

};

The initial version of the hook supports an API for defaultFormProps, which defaults the form autocomplete attribute to on. getFormProps returns form props which callers can expand onto their forms. onSubmit wraps the callers onSubmit so that isSubmitting can be tracked.

Testing

To start we’ll test the hooks API. We’ll save UI tests for a later point in development when we start to create UI test cases. To start testing hooks, we’ll use jest and react-hooks-testing-library. react-hooks-testing-library is recommended for testing hooks.

As we will be testing asynchronously with setTimeout, we’ll use jest.useFakeTimers , since jest globally overrides setTimeout in order to avoid blocking unit tests.

At this point, the goal of the tests is to test all of the features included in the useForm hook. These tests validate the following:

The form state passed into useForm is properly returned

is properly returned Custom onSubmit callbacks are called

callbacks are called useForm overall UI state is properly set before / after form submission ( isSubmitting ) — applicable to async submits

overall UI state is properly set before / after form submission ( ) — applicable to async submits Errors such as Promise rejections due to endpoint failures are handled gracefully

There are other tests that could and should be done, such as validating arguments to onSubmit callbacks. These will be added in future parts as the API is further built up

import { renderHook, cleanup, act } from "react-hooks-testing-library";

import { useForm } from "./use-form"; const noop = () => {}; jest.useFakeTimers(); describe("useForm tests", () => {

afterEach(cleanup); it("should return empty form state", () => {

const { result } = renderHook(() => useForm());

const { getFormProps, formState } = result.current;

expect(getFormProps).toBeDefined();

expect(formState.values).toEqual({});

}); it("should return an initial formSummary", () => {

const { result } = renderHook(() => useForm());

const { getFormProps, formSummary } = result.current;

expect(getFormProps).toBeDefined();

expect(formSummary).toEqual({

isSubmitting: false

});

}); it("should support custom form props", () => {

const { result } = renderHook(() => useForm());

const { getFormProps } = result.current;

const formProps = getFormProps({ foo: "bar" });

expect(formProps.foo).toEqual("bar");

}); it("should support custom onSubmit", async () => {

const { result } = renderHook(() => useForm());

const { getFormProps, formSummary } = result.current; const onSubmit = jest.fn(); const formProps = getFormProps({ onSubmit });

expect(formProps.onSubmit).toBeDefined();

//

act(() => {

formProps.onSubmit({ preventDefault: noop });

});

expect(formSummary).toEqual({

isSubmitting: false

});

expect(onSubmit).toHaveBeenCalledTimes(1);

}); // Could be some weirdness right now due to// https://github.com/facebook/react/issues/14769 act(() => {formProps.onSubmit({ preventDefault: noop });});expect(formSummary).toEqual({isSubmitting: false});expect(onSubmit).toHaveBeenCalledTimes(1);}); it("should support async onSubmit", async () => {

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

const { getFormProps, formSummary } = result.current; const onSubmit = evt =>

new Promise(r => {

setTimeout(() => {

r();

}, 1000);

}); const formProps = getFormProps({ onSubmit });

expect(formProps.onSubmit).toBeDefined();

//

act(() => {

formProps.onSubmit({ preventDefault: noop });

});

jest.runAllTimers();

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

isSubmitting: true

});

await waitForNextUpdate();

expect(formSummary).toEqual({

isSubmitting: false

});

}); // Could be some weirdness right now due to// https://github.com/facebook/react/issues/14769 act(() => {formProps.onSubmit({ preventDefault: noop });});jest.runAllTimers();expect(result.current.formSummary).toEqual({isSubmitting: true});await waitForNextUpdate();expect(formSummary).toEqual({isSubmitting: false});}); it("should gracefully handle onSubmit errors", async () => {

const { result } = renderHook(() => useForm());

const { getFormProps, formSummary } = result.current; const onSubmit = evt => new Error(); const formProps = getFormProps({ onSubmit });

expect(formProps.onSubmit).toBeDefined();

//

act(() => {

formProps.onSubmit({ preventDefault: noop });

});

expect(formSummary).toEqual({

isSubmitting: false

});

}); // Could be some weirdness right now due to// https://github.com/facebook/react/issues/14769 act(() => {formProps.onSubmit({ preventDefault: noop });});expect(formSummary).toEqual({isSubmitting: false});}); it("should gracefully handle async onSubmit errors", async () => {

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

const { getFormProps, formSummary } = result.current; const onSubmit = evt =>

new Promise((resolve, reject) => {

setTimeout(() => {

reject();

}, 1000);

}); const formProps = getFormProps({ onSubmit });

expect(formProps.onSubmit).toBeDefined();

//

act(() => {

formProps.onSubmit({ preventDefault: noop });

});

jest.runAllTimers();

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

isSubmitting: true

});

await waitForNextUpdate();

expect(formSummary).toEqual({

isSubmitting: false

});

});

}); // Could be some weirdness right now due to// https://github.com/facebook/react/issues/14769 act(() => {formProps.onSubmit({ preventDefault: noop });});jest.runAllTimers();expect(result.current.formSummary).toEqual({isSubmitting: true});await waitForNextUpdate();expect(formSummary).toEqual({isSubmitting: false});});});

Summary

In this first post, we’ve designed the high level useForm hook, envisioned how it can be used, and created some initial test cases to validate the useForm hook.

Let me know if you have any questions, feedback, or suggestions.

Part 2 of this series can be found here.