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 and useFormContext() hook and then testing form components with Testing Library.
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 and can initialize the form with some default values.
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 element or submit button.
Note: I'm not 100% sure this is a good idea or pattern, but it allows one to at the very least to test nested component form validation and how the UI should respond to bad form data. Maybe you can take this idea and refine it for your use cases or make it better somehow… just experimenting.
import React from "react";
import { Router } from "react-router-dom";
import { render } from "@testing-library/react";
import { useForm, FormProvider } from "react-hook-form";
/**
* 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
* @param {Array} objectParameters.toPassBack React Hook Form method names which we'd
* like to pass back and use in tests. A primary use case is sending back 'setError',
* so we can manually setErrors on React Hook Form components and test error handling
*/
export function renderWithReactHookForm(
ui,
{ defaultValues = {}, toPassBack = [] } = {}
) {
let reactHookFormMethods = {};
const Wrapper = ({ children }) => {
const methods = useForm({ defaultValues });
for (let reactHookFormItem of toPassBack) {
reactHookFormMethods[reactHookFormItem] = methods[reactHookFormItem];
}
return <FormProvider {...methods}>{children}</FormProvider>;
};
return {
...render(ui, { wrapper: Wrapper }),
reactHookFormMethods,
};
}
Then we can use it in our test like so:
On line #18 you'll notice toPassBack which is an array of React Hook Form methods, for example, setError.
On line #24–26 we're adding the methods to our helper object, reactHookFormMethods.
Then on line #32 we're including an additional object, reactHookFormMethods which gets added to what Testing Library's render() function returns (alongside methods like getByTestId, etc).
Here's a rough example of how it might be used:
import React from "react";
import { screen, fireEvent, waitFor } from "@testing-library/react";
import UIFormRelatedURL from "./RelatedURL";
import { relatedUrlSchemeMock } from "../../Work/controlledVocabulary.gql.mock";
import { renderWithReactHookForm } from "../../../services/testing-helpers";
import userEvent from "@testing-library/user-event";
const props = {
codeLists: relatedUrlSchemeMock,
name: "relatedUrl",
label: "Related URL",
};
describe("Test component, error handling", () => {
// Here's an example of how to test that a React Hook Form element
// displays error messages, without submitting the form.
it("renders appropriate error messages with invalid url or select values", async () => {
const {
findByText,
getByTestId,
reactHookFormMethods,
} = renderWithReactHookForm(<UIFormRelatedURL {...props} />, {
toPassBack: ["setError"],
});
userEvent.click(getByTestId("button-add-field-array-row"));
await waitFor(() => {
// Here we manually manipulate the form, setting an error the same way React Hook Form does
reactHookFormMethods.setError("relatedUrl[0].url", {
type: "validate",
message: "Please enter a valid url",
});
});
expect(await findByText("Please enter a valid url"));
});
});
...
// And what the input being tested may look like...
<input
type="text"
name={`${itemName}.url`}
className={`input ${
errors[name] && errors[name][index].url
? "is-danger"
: ""
}`}
ref={register({
required: "Related URL is required",
validate: (value) =>
isUrlValid(value) || "Please enter a valid URL",
})}
defaultValue=""
data-testid={`related-url-url-input`}
/>
Conclusion
Maybe you'll find this helper wrapper function helpful in some manner. React Hook Form and Testing Library are top React packages which developers are building a lot of stuff on, so it's nice to see how to make testing easier. Any thoughts/comments/opinions are more than welcome.
If you'd like to see the example code within the context of an open-source Elixir/React application, here's a link to the Github repo: