Ignore every form library for a second
Where React shines is in its ability to define, build & compose components. So, lets start with defining our form components. This is really simple, we have ainput
element, so we have a Input
component. We have a select
element, so we have a Select
components, etc. When defining these, they should behave like the native elements but apply your core styles to them.
If you’re using typescript, React exposes a
ComponentProps
type to make prop definitions easier.label
, fieldset
, etc. The idea here is to build your layer of abstraction on top of the native elements. This allows you to build your own components that are composable and reusable.
IMPORTANT: Notice that we are not referencing a library or managing state or anything in these components, this is very very important, it keeps them simple, and allows us to compose them in any way we want, regardless of library or state requirements.
input.tsx
forwardRef
does make the component a little more complex to read, but not too bad in the end as this should be a set and forget type of component.
The best thing about this is, once you’ve created this small library of native abstractions you can continue to transfer them across projects. Since they contain no business logic or library dependencies, they are very portable.
What if I use something like Headless UI?
Headless UI and similar libraries provide primitives for you to build more complex components without needing to worry about their styling interferring with yours, among other tasty things. These are further from the native elements, but we can still apply the same principles. The main one to keep in mind is, keep it simple. In the case of a Listbox which will replace yourselect
element, keep state out of it, and keep it to forwarding props and state.
listbox.tsx
Composition
As I mentioned before;Where React shines is in its ability to define, build & compose components. — me, just beforeSo lets compose some elements to make building our form a bit easier and quicker. When building forms, you usually have a few components sitting next to each other, for example a
label
and input
/select
. I like to call these control
components, form-{element}-control
, the props they take are the same as your abstraction we created above. This is a good place to put fancy radio/checkbox controls as well.
form-input-control.tsx
forwardRef
, but I’ll leave that up to you. This is also the time you can add a small bit of UI rendering logic if you need to. For example, you might want to add a required
indicator to the label if the required
prop is passed to the Input
component.
form-input-control.tsx
Again, this is another abstraction on top of our native element abstraction. We don’t manage state at this level, we are simplify building a solid foundation to build on top of. Again, these are simple components with no external dependencies other than the layer we wrote, making it easy to transfer between projects.
Question if you need a form library
A bit cheeky but a honest question, what exactly are your form needs?- Is it a login form or register form? You probably don’t need a library.
- Is it a contact form? You probably don’t need a library.
- Is it a donation form with money validation? You probably don’t need a library.
- Is it a multi-step form with complex validation before you can continue? You probably need a library.
Utilise native behaviour
You can get quite far using built in native validations to make sure the data being sent to your backend if fairly correct, you will 100% still need to validate the data on the backend regardless if you decide to have a library with on-the-fly validation. Lets take the login form as an example.login-form.tsx
type="email"
will ensure the value is email-esque and the min="6"
will ensure the password is at least 6 characters long. You can also use the pattern attribute if you want to add some more “complex” validation on the field. But this approach will get you quite far, without needing to reach for 2 libraries to do the same thing, a form library (Formik, react-hook-form, etc) and a validation library (zod, yup, etc).
On submission you can use FormData to pull the data out and send that to your backend to validate & process.
login-form.tsx
catch(err) { console.error(err) }
, shouldn’t be your error handling approach. If you’re in charge of the backend you can should return a 400
error with a JSON body containing the errors. If you’re using a validation library, they usually go a good job at giving you a key/value
pair of field name and the error message. You can use this on the frontend to display the error message next to the field, or you can use it to display a generic error message at the top/bottom of the form.
login-form.tsx
200
with a status code in the body of 400
. Please… I beg you.
Extract complex fields
I mentioned the donation form above, this is a good example of where you might need more control over the value and what is accepted as a valid value. Here you can extract that field into its own component and manage the state of that component.price-control.tsx
FormData
in the same way as before. Using the form data to start a Stripe Checkout flow or something similar.
donation-form.tsx
password
and confirm password
field. You can extract these into their own component and manage the state of them together. Moving these outside of the form component also allows you to use them in other places, and keeps the form component simple.
I need a library
We’ll look at react-hook-form as an example, but the same principles apply to other libraries. The best part of RHF is that it interfaces really we’ll with native elements.rhf-example.tsx
input
for our Input
or FormInputControl
component.
rhf-example.tsx
rhf/FormInputControl.tsx
rhf-context.tsx
PriceControl
example above, you can take a similar approach but instead of managing state through built-in React methods, you’d use the library’s methods to control how the value is managed. For example, useFormContext
exposes a setValue
method, you can override the onChange
behaviour and call this method to update the value.
rhf/price-control.tsx
I don’t need a library, but I want to manage state for the entire form
Let’s say you have a complex form but doesn’t need to be too reusable in terms of state management & submit functionality. You don’t want to introduce a library for this, but you still want to manage the state of the form in a single place. For this I’d recommend using React Context mixed with useReducer. Context allows you to create a place to store your state so that all parts of the form will have access to. WhileuseReducer
gives you a nice dispatch
pattern to update the state, providing more control to perform complex state updates when required without needing to introduce any useEffect
calls to monitor value changes.
There are a few steps in this process so check out this how-to guide for more information.
Context with useReducer
Learn how to configure a context provider with useReducer to manage the state of your form.
useEffect
, I recently worked on a project with a donation form. When the user switched between a One-time and Monthly payment we wanted to clear any pricing information they’ve set. I create a dispatch action called SET_FREQUENCY
which would handle this for me.
reducer-action.tsx
I was using immer to allow me to write these mutating statements in my reducer.
useEffect
, since that can cause some weird side effects if you’re not careful. Additionally with StrictMode running useEffect
twice, it can sometimes cause issues depending on what you’re doing in the effect. If you can avoid a useEffect
it is always a win, one less thing to worry about and keep track of.
There are some other patterns relating to forms that I haven’t covered here, but I think these are the most important ones to get right. In the future I will cover some patterns for Next.js and how to handle submissions using server actions. For now, just remember to keep it simple, don’t over complicate things, and don’t reach for a library unless you really need it. Focus on building a solid foundation with your core components and build on top of that.