React Hook Form is an incredibly useful package for building out simple to complex web forms. This article illustrates our team's approach to organizing and testing nested form components, using React Hook Form's
Standard React Hook Form setup
When our forms were small and being prototyped, it was reasonable to initialize React Hook Form in the standard way according to its docs.
import React from "react";
import { useForm } from "react-hook-form";
export default function App() {
const { register, handleSubmit, watch, errors } = useForm();
const onSubmit = data => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input name="firstName" defaultValue="Zoe" ref={register} />
<input type="submit" />
</form>
);
}
Organizing a complex form
As data gathering applications grow, so might the amount of deeply nested child components within each form. Creating nested components is useful to organize form content, reuse code, maintain accessibility, and reinforce a consistent styling across an application.
Here's a generic example of how some of our forms are set up. Our repository application contains metadata forms which have 30–50 elements, from simple inputs, to typeahead drop downs, to field array (multi-valued) inputs, and more.
We organize complex, singular forms into sections.
// The form
<form onSubmit={handleSubmit(onSubmit)}>
{/* Won't repeat, but initially we explicitly drilled React Hook Form props down to any and all child components which may need them. Not good:) */}
<CoreMetadata register={register} errors={errors} {...etc} />
<ControlledTerms />
<DescriptiveMetadata />
...
<input type="submit" />
</form>
A section component:
// CoreMetadata.js
...
return (
<>
<SubCategory1 register={register} errors={errors} {...etc} />
<SubCategory2 />
<SubCategory3 />
</>
);
A nested component:
// SubCategory1.js
...
return(
<>
<UIFormInput name="yo" required register={register} errors={errors} />
<UIFormSelect name="bigList" options={listOfOptions} required />
{/* Maybe some element goes here which dynamically renders depending on form values or state of the form? */}
</>
);
And eventually a “leaf-level”, child component where we wire up React Hook Form to the form element.
// UIFormInput.js
function UIFormInput({name, type, register, errors}) {
return (
<>
<input
name={name}
type={type}
ref={register && register({ required })}
className={`input ${errors[name] ? "is-danger" : ""}`
{...passedInProps}
/>
{errors[name] && (
<p data-testid="input-errors" className="help is-danger">
{label || name} field is required
</p>
)}
</>
);
}
The initial pattern of drilling down React Hook Form methods as props to every child component in a component stack, got copied over and over again and we duplicated this inefficient pattern because well, it worked but didn't feel quite right.
useFormContext to the rescue
Recently we transitioned our React Hook Form implementations and child components to use useFormContext. Since we're gravitating towards using our own component libraries and looking for a consistent solution, now we set up our forms with Context:
// Updated Form
import React from "react";
import { useForm, FormProvider } from "react-hook-form";
export default function App() {
const methods = useForm();
const onSubmit = data => console.log(data);
return(
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)}>
<CoreMetadata {...PASS_WHATEVER_PROPS_HERE} />
<ControlledTerms />
<DescriptiveMetadata />
</form>
</FormProvider>
);
}
This approach flows with React Context/Provider patterns, and any child component in the ancestry tree can grab React Hook Form Context if it needs it. Mid-level components which don't care about register or error are set free and liberated from baggage props.
import React from "react";
import { useFormContext } from "react-hook-form";
const UIFormInput = ({ name, required, ...passedInProps}) => {
// All these values are from the component's parent <form />
const { control, errors, register } = useFormContext();
return (
<>
<input
name={name}
ref={register({ required })}
className={`input ${errors[name] ? "is-danger" : ""}`
{...passedInProps}
/>
{errors[name] && (
<p data-testid="input-errors" className="help is-danger">
{label || name} field is required
</p>
)}
</>
);
}
So what if you have multiple forms in your application? With React Hook Form Context, whichever form a component lives in, is the form data the component receives via the hook. This also sets up components to be more easily tested by passing whatever form context one wants, into each test.
Testing
Ok, now we have way less code in our components. Check out this PR. Whoa, much better. Let's move onto testing React Hook Form useContext() components with Testing Library.
/**
* Testing Library utility function to wrap tested component in React Hook Form
* @param {ReactElement} ui A React component
* @param objectParameters
* @param {Object} objectParameters.defaultValues Initial form values to pass into
* React Hook Form, which you can then assert against
*/
export function renderWithReactHookForm(ui, { defaultValues = {} } = {}) {
let reactHookFormMethods = {};
const Wrapper = ({ children }) => {
const methods = useForm({ defaultValues });
return <FormProvider {...methods}>{children}</FormProvider>;
};
return {
...render(ui, { wrapper: Wrapper }),
};
}
This renderWithReactHookForm helper function acts the same way other Testing Library recipes work, by returning what Testing Library's render() function would return. For example here's Testing Library's recipe for wrapping with React Router.
A sample test using renderWithReactHookForm may look like:
import React from "react";
import { screen } from "@testing-library/react";
import { renderWithReactHookForm } from "./services/testing-helpers";
import UIFormRelatedURL from "./RelatedURL";
const props = {
name: "relatedUrl",
label: "Related URL",
};
describe("standard component behavior", () => {
beforeEach(() => {
renderWithReactHookForm(<UIFormRelatedURL {...props} />, {
// Add some default values to our form state, using Reach Hook Form's "defaultValues" param
defaultValues: {
relatedUrl: [
{
url: "http://www.northwestern.edu",
label: {
id: "HATHI_TRUST_DIGITAL_LIBRARY",
label: "Hathi Trust Digital Library",
scheme: "RELATED_URL",
},
},
],
},
});
});
it("renders component and an add button", () => {
expect(screen.getByTestId("related-url-wrapper"));
expect(screen.getByTestId("button-add-field-array-row"));
});
it("renders existing related url values", () => {
expect(screen.getAllByTestId("related-url-existing-value")).toHaveLength(
1
);
expect(
screen.getByText(
"http://www.northwestern.edu, Hathi Trust Digital Library"
)
);
});
it("renders form elements when adding a new related url value", () => {
const addButton = screen.getByTestId("button-add-field-array-row");
fireEvent.click(addButton);
fireEvent.click(addButton);
expect(screen.getAllByTestId("related-url-form-item")).toHaveLength(2);
...
});
});
In our test above, we wrap the component we're testing with React Hook Form's
Why would this help?
Rendering with default values
Say you have a form that collects a list of values, but has starting values. (We also have a decent amount of complex form implementations which make use of React Hook Form's useFieldArray hook).
So the form data could look like this:
// Super simplified example HTML
<input name="multiValueField[0].url" />
<select name="multiValueField[0].category>
...
</select>
// How React Hook Form keeps track of the form data
multiValueField[0].url
multiValueField[0].category
multiValueField[1].url
multiValueField[1].category
...and so forth
The <UIFormRelatedURL />
form component displays a list of existing values fetched from the API which a user could remove. The user is also free to add as many additional values as they wish. In tests, we inject default values into React Hook Form, the same way the code actually does. So we can test that our component actually displays the proper starting values.
Combining
with other Providers
Say you use other tools in your application like GraphQL w/ Apollo Client, or React Router and your application looks something like this:
...
export default class Root extends React.Component {
render() {
return (
<ReactiveBase
app={ELASTICSEARCH_INDEX_NAME}
url={ELASTICSEARCH_PROXY_ENDPOINT}
>
<AuthProvider>
<BatchProvider>
<BrowserRouter>
<Switch>
<Route exact path="/login" component={Login} />
<PrivateRoute exact path="/project/list" component={ScreensProjectList}
/>
...
If you are testing a component which gets wrapped in other testing Providers like Apollo Client, React Router, ElasticSearch, etc. we can re-purpose the renderWithReactHookForm pattern as a Higher Order Component which returns a regular Component instead of React Testing Library's render() function.
/**
* Higher order helper function which wraps a component w/ React Hook Form
* @param {React Component} WrappedComponent to pass into
* @param {*} restProps any other remaining props
* @returns {React Component}
*/
export function withReactHookForm(WrappedComponent, restProps) {
const HOC = () => {
const methods = useForm();
return (
<FormProvider {...methods}>
<WrappedComponent {...restProps} />
</FormProvider>
);
};
return HOC;
}
Then we can use the Higher Order Component in our test like so:
And we'd use it as follows:
import React from "react";
import { screen } from "@testing-library/react";
import {
renderWithRouterApollo,
withReactHookForm,
} from "../../../services/testing-helpers";
import ControlledMetadata from "./ControlledMetadata";
describe("Some component", () => {
beforeEach(() => {
// Wrap with React Hook Form's Provider
const Wrapped = withReactHookForm(ControlledMetadata);
// Wrap with any other Providers you may be using, like ApolloProvider, React Router, etc.
return renderWithRouterApollo(<Wrapped />,
{
mocks: [],
}
);
});
// Your tested component will be wrapped with React Hook Form's provider (and others)
it("renders controlled metadata component", async () => {
expect(await screen.findByTestId("controlled-metadata"));
});
...
});
Extending renderWithReactHookForm()
Now that we can set up individual form context when testing components, we could also extend renderWithReactHookForm to test how a component responds to certain form context values, without submitting the form, which is not possible when testing a deeply nested component which doesn't render the