Building Type-Safe Forms in React
Introduction
Brendan Allan introduces himself and Spacedrive, an open-source, cross-platform file explorer. He discusses the challenges of managing forms in web development and sets the stage for building a robust form system using React.
Basic React Forms and Their Limitations
Brendan starts with a basic React form example, highlighting its simplicity but also its drawbacks when dealing with larger forms and complex validation logic.
Introducing React Hook Form
Brendan introduces React Hook Form as a solution for managing form state and validation. He walks through integrating the library, simplifying state management and data collection.
Adding Type Safety with Zod
To enhance type safety, Brendan brings in Zod, a library for data validation and type inference. He demonstrates how to define a schema for form data, ensuring type consistency and providing runtime validation.
Creating a Custom Hook: useZodForm
Brendan introduces a custom hook, useZodForm, to abstract away the integration of Zod and React Hook Form. This hook streamlines form setup and promotes code reusability.
Displaying Validation Errors
Brendan leverages React Hook Form's built-in error handling to display validation errors to the user. He creates a custom Input component to encapsulate input rendering and error display logic.
Utilizing Context for Shared Form State
Brendan utilizes React's Context API to provide access to the form state across all child components. This eliminates the need for prop drilling and simplifies error handling within form fields.
Creating a Reusable Form Component
Brendan introduces a Form component to wrap the form elements and provide a consistent structure for all forms within the application. He also leverages the "disabled" property of fieldsets to disable form elements during submission.
Adding Labels to Form Fields
Brendan adds labels to form fields, emphasizing the importance of proper labeling for accessibility and user experience. He uses the useId hook to generate unique IDs for labels and inputs.
Creating a Reusable Select Component
Brendan extends the form system by creating a Select component using a similar approach to the Input component. He demonstrates how to derive options from the Zod schema for type-safe option rendering.
Abstracting Common Form Field Logic
Brendan further refactors the code by extracting the common logic of Input and Select components into a reusable FormField component. This reduces code duplication and improves maintainability.
Creating a Custom Hook for Form Field Props
Brendan introduces a custom hook, useFormField, to manage the props required by both FormField and its child components. This promotes code organization and separation of concerns.
Putting it All Together
Brendan summarizes the complete form system, highlighting its type safety, reusability, and composability. He emphasizes the benefits of a well-defined form abstraction for building robust and maintainable forms.
Code Demonstration
Brendan provides a live code demonstration showcasing the form system in action. He demonstrates how to create a dialog with an integrated form, handle form submission, display loading indicators, and automatically close the dialog upon successful submission.
Conclusion and Call to Action
Brendan concludes the talk by sharing his blog post and resources for further learning. He encourages the audience to explore Spacedrive and its open-source codebase.
Why am I here to talk to you about forms today?
I work for a company named Spacedrive.
You may have heard of us on GitHub.
We blew up for a while, last year, but if you haven't heard of us, we are building an open source cross platform file explorer that builds an index of all of your files across all of your devices and synchronizes it without needing any central server.
We use Rust for our backends, so all of our networking and file system operations.
And WebTech for our front end, including TypeScript and React.
And as with any app, we have a lot of forms.
We have settings forms, we have forms inside dialogues, we have multi step wizard forms.
Which is pretty par for the course, but you may take some pity on us, since forms tend to get a bit of a bad rap, especially online.
Managing state and validating fields and handling submission logic can get annoying, especially if you're trying to do a lot of it on the client for, which can develop a, bleh, which can deliver a better user experience, but often at the cost of a good developer experience.
But at SpaceDrive, we have a system that I think works pretty well, but instead of showing you directly what we do, I'm going to show, build it with you from the ground up, from a look, sorry, from a basic React form, and hopefully by the end of this talk you'll be equipped not have to think like this.
So, let's start off with a basic React form.
This is just a blank sign up form that takes email and a password.
Each field gets its own useState.
Each input gets two way bindings to said state.
And we have a submit button, because obviously.
And each form has an on submit handler which calls prevent default so that the form doesn't reload the whole page when we submit.
And then our form's values are captured into the callback from our useState.
This gets the job done, but it's cumbersome, and it could be possible to hook up the bindings incorrectly, and I'm not a fan of having mistakes spread all over the place.
Keep in mind here, we're just talking about client controlled forms.
Some tools have their own ways of managing forms.
What we're talking about here isn't necessarily mutually exclusive to those, but I'm not going to consider them as part of this.
So, overall, what do I think of this?
It's alright for small forms.
It could get hairy with more fields and more logic, like communicating with a server in the on submit handler, which you will probably do.
And also, I wouldn't say this is very type safe.
Now, what do I regard as type safe in the context of a form?
One, our form data, as I said, is spread over each useState, rather than having the type centralized in one place.
Which I think is much preferable because, it's easier to, just look at the form and know what's going on.
And also if you want to create derived types.
From that original type, it's much easier to do so for that.
But what can we do to make this form a bit better?
First, I will introduce a library called Reactook Form.
You may have heard of this one.
It is a great library that helps with form state management, but it also provides, built in validation utilities, and will even help us with type safety later on, but we'll get there.
So to integrate Reactook Form, we do something like this.
Individual useStates.
Get to become a single call to use form that will manage all of our forms state From now on our handle, our on submit, handler, we wrap with handle submit, that will call prevent default for us, so we don't have to remember to do that anymore.
And it also collects all of the forms data into a single object.
So no more capturing in from multiple sources of state to right there in our callback.
Our name prop, and data bindings are all handled by a single call to form dot register.
And now, in this case, we actually have to be careful with the name that we provide to our inputs, because if we get that wrong, then the value, or the way it shows up in the data object we get in HandleSubmit will actually not what we expect, so type safety here will be very important.
I'm not sure about you, but I much prefer how this looks compared to our original form.
It's not a whole lot less code, especially for this example, since we only have two inputs, but we are only just getting started.
So, I would say this scales much better, as we don't need to add more state storage for every field.
And all the data gets passed through a single handle submit, but it's still not type safe.
Name could be wrong, and we don't actually know the type of data that is in our submit handler at this point in time.
Whereas even with our previous example, we did, because our useStates would have been typed.
So, let's add some types.
We can declare the shape of our form in a single type at the top of our form.
We then provide that as a generic to our use form.
Now our form type becomes the single source of truth for our whole form and anything that we want to do with it.
Now we can know the type of the data that is in HandleSubmit, which is super handy.
And then the field names that we use in register will be checked.
So if we try to register a field that we haven't declared initially, TypeScript will not be happy with that.
But this actually isn't entirely reliable just yet.
Take this example where we have a single number field and input with type number.
According to TypeScript, this is fine.
But this code actually has a bug.
Because React hook form will use an input's value property, by default.
But value, if is always a string, even if you have type number on your input.
So you have to tell it to use value as number, instead of value.
This is not type safe at all, frankly, it could be very easy to forget to put value as number.
And God knows I've done it before.
So what if we could validate the data, that we are getting in our form before our Submit Handler is run?
Because, we could detect this problem by seeing an error in our Submit Handler, but God knows at what point that will trigger and debugging that would just be a nightmare.
It'd be much better to just know before the Submit Handler is even called.
Whether we have a problem.
To use that, we're going to introduce another library, called Zod.
It's an excellent library for doing all sorts of data validation, and it also allows us to infer types.
From the validators that we define, which is, awesome, and it has great integration with React Hook Form.
We can define a single validator, and use it at compile time for type checking, and at run time for validating our form's fields.
Doing so, looks like this.
We declare a schema for our form.
This represents the shape of our HandleSim data, and defines any extra validations that aren't specifically type related.
So here we can, validate that the string is an email, you can use, HTML validations to do this, sure, but there is a whole load of extra validation, systems, validations you can do with Zod.
You can have, custom validations.
Custom error messages, you can have async validations if you want, it gets crazy.
Second, we infer the type of form from the schema, effectively killing two birds with one stone here.
It's great.
And last, we set the schema as our form's resolver.
This is a crucial step because it's what tells ReactilkForm to actually validate the data.
Without that, we just have a different way of declaring a type.
All in all, this is what that looks like.
Schema, oh, sorry, Schema replaces type, and it's not crazy different, and in the case of our number input example, we can't use the number validator from Zod directly, because we still have the problem with Reactor Hook Form, using a string for number inputs, but Zod provides a set of APIs called Coerce, which will expect a string as an input rather than the type that it's validating itself.
So it will do the string to number conversion for us.
And I would say this is better and more type safe since you will get a validation error before any of your submit handle logic runs, which is preferable than having to deal with the error in your submission handler, but really the best solution is just make a number input with an on change number, instead of dealing with strings at all.
But this could be better.
We have code duplication on every form.
Wouldn't it be great here to have a custom hook that can wrap schema in ZodResolver for us and provide that generic to use form?
Let's make one.
Here's useZodForm.
This calls useForm for us and automatically provides the schema as ZodResolver.
And these types may look a bit scary, but don't worry.
useXZoFormProps is just the type that UseForm expects.
But we're just removing Resolver since we provided ourselves, and adding a schema field since that's the only thing that we want to actually provide.
And the generic T is necessary, since otherwise the hook won't know the specific type of the schema that we're providing.
It'll just be like, oh, it's some schema, I don't know what it actually looks like.
You'll just, it could just be any old object.
So, this is much better, I reckon.
Now, all our logic is hidden behind a custom hook, and we have no more manual types.
If you haven't noticed, this is just a JavaScript file.
We don't even need the TypeScript here.
And I think that is what a good TypeScript, experience should look like.
The less types that you define in your application, the better.
You should just define them once and if possible have utilities that will distribute them throughout your whole app rather than having to redefine them in lots of different places.
I think that's where a lot of people tend to get tripped up the TypeScript.
But what if validation doesn't succeed here?
That's a very possible scenario.
React Hook Form can help us here too.
It gives us a handy errors object as part of the form state, which we can then use to display errors for each field.
By the way, errors here is also type safe.
TypeScript will yell at us if we try to get errors for a field that doesn't exist.
It's great, but this can get a bit repetitive, having to specify the rendered error for each input.
Eh, it's not great.
A better solution would be to group the input and the error into a separate component.
So let's create a custom input component, and that will render both the input and the error.
But, where do we get form from here?
We need access to the form state to be able to render the error.
We could pass form to every input, but that could get annoying, especially if your form has a lot of inputs.
If only there was a way to make a value available to all of a component's children.
Wait a minute, we do have that!
It's called context!
React Hook Form actually gives us this form provider already that we can consume with useFormContext.
We can then use that to get the field state and render the error.
And while we're here, let's do something about these.
Since every form is going to need the form context and an actual form element, we can chuck them in their own form component, and we now provide the form via a form prop, and then it's just propagated to all child fields.
Another thing I like to do in this component, is go down here, unwrap all the children in a field set, as it has a special property.
As if disabled on the field set is true, then all of the form elements within it will also be marked as disabled.
So not only will the user not be able to interact with buttons and inputs in our form, but the disabled CSS selector will also be automatically applied.
And this will apply whenever our form is submitting, and even if handleSubmit contains like an async function.
The form will be disabled until that async function resolves.
It's, it just ties together so nicely.
I love it.
All in all, here is what our form looks like now.
Here's a basic demo of what this could look like using some of SpaceGrav's UI.
I'd say this looks pretty sleek, but we are missing something.
How do we know what each, input of our form is?
We only know here because there's some values filled in.
We need labels.
We can just pass them as a prop to our inputs, and then render the label above the input.
And since it's a good practice to include the label, we'll make it a required prop.
And we need to set the HTML4 for our label to the same value as the input's ID.
The input's name here could work, but what if name isn't unique?
See, this form system we're building doesn't stop us from having multiple forms on one page.
And ID is supposed to be unique on the whole page.
If you really want to over engineer your form of abstraction, like me, name just simply isn't good enough for you.
But React actually gives us a built in solution here with a hook called useID.
This is one that you may not have heard of, but it generates a unique ID that is stable across re renders.
So it's assigned once on mount, and then we'll stay that way, and you can just rely on it, to be unique, as opposed to our name field, which, as with anything provided by a user, in this case, us, you just can't trust.
Okay, so surely, we're done working on our forms now.
This is all we need for our abstraction.
There's more to forms than just text inputs.
Okay, we can use a similar approach to make a select component.
And, using it inside a basic form here that just lets you pick between your favourite fruit.
I'm just saying apples and oranges.
Our type for our form, type for our select field is just a string union.
Since it's going to be one of multiple, values.
This is fine, but the options that we're rendering are type checked.
What we can do is actually derive the options that we're rendering from the schema that we define.
Like this, we can map over the options from the favorite fruit union, but we need a more nicely formatted value to show the user than it all just being lowercase.
In this case, we could capitalize the first letter, and maybe for your use case, that's fine.
Not for me.
I want a type safe map with a display name for each option.
This, yeah, you may not need it.
I like it.
That's enough messing around with that.
Back to the select component itself.
If we put input and select side by side, we'll notice that these are the only differences between the two.
Everything else is reused code.
So let's move the label and the error into their own component.
That is much nicer already.
Here is our form field component.
But it has a lot of missing data right now.
We'll get back to that.
While we're here, we'll just move the form context related stuff into form field as well, since that's all going to be shared.
That's great.
And we can take in everything else as props.
That's fine.
That's pretty good, but I'm not a fan of use ID being here in the input itself.
Maybe you're fine with that.
I like it, since it's going to be called in multiple components, like our input and our select.
And rather than calling this hook multiple times, I'd be more comfortable calling a custom hook, since I consider this to be more of an implementation detail of our form abstraction, rather than the input knowing about the ID itself.
So, let's chuck that in another hook.
This hook will provide props for both the form field, and the child.
This may seem like an odd decision, but stick with me.
Custom hook can actually provide all of the props to the form field and the child.
We're essentially just splitting the props in two to make spreading it easier.
Feeds may not be entirely necessary, but I sleep easier knowing that I'm only passing props that are actually needed to my components.
UseFormField requires the name and the label, so I'll just throw them in a props type.
But we have to use a generic on UseFormField so that we actually pass through the original component's props type to child props.
And we can actually do something neat with our UseFormField props type and use it within input props itself.
And not only is this convenient, I think it reads quite nicely since we can see that our props object must satisfy the needs of both useFormField and input, and the type just reflects that.
I love it.
And doing the same thing with select, we get these two, two components that are still pretty similar, but just much smaller because of the underlying form field abstraction.
And you could go even further with this, implementing it for a text area, or an auto completing drop down, or a date picker.
Good luck.
The overall, this is the, unless nothing else comes up, hopefully not, alright, this constitutes most of the form layer that we use at SpaceDrive.
We just don't care about your favourite fruit.
The schema provides types and validations for the whole form.
We have one call to use zodForm that manages all state, inputs, render errors automatically, and our form submission logic is fully type safe.
Now, to, this was my second last slide.
I've gone a bit fast, but I do have a code demonstration that I will show you all.
So that here, I have a little app that is rendering a dialogue.
And this dialogue is similar to something we do in SpaceDrive, where it automatically creates a form for us.
And then we can just pass the form into the dialog and the dialog's open and close logic is plumbed into, the form's submission handling.
So if we open this dialog, we can enter values.
I'll just do, brendon at, at spacedrive.
Com.
I'll count here.
I've put a just custom validator on it saying it has to be between 10 and 20.
If I say 5, Then it will complain that it must be greater than or equal to 10.
Thanks, zod.
And our fruit, we have to put a value.
So, I love apples.
And we have to change this count, because of our validator.
If we hit submit here, this will trigger our, a mutation from TanstackQuery, which, TanstackQuery is a great library.
If you don't use it, I implore you to, please.
All of Tanner's libraries are brilliant.
It just provides an easy way to do server side communication.
Here, we're not actually reaching out to a server, so we set timeout, and we're returning the data that we passed in.
And making this mutation function type safe is really easy because we, we can have the type of our form encoded in a single schema.
We can just infer the types from that schema like we do in useZodForm, and, that's it.
We don't have it spread over multiple different types.
Now if we hit Handle Submit, or if we hit the Submit button, sorry, we automatically get a loading indicator built into our dialog.
We can do that because we have access to the form state within the dialog to just render a loader.
This is the type of thing that, we try to do when we're designing, component systems at SpaceDrive.
I'm very heavy on, allowing our components and our hooks to be composable.
So that they'll all, they all define small, behaviors on their own that we're able to combine into larger systems that play really nicely with each other.
And I think this is a great example of that happening.
And you also noticed, you may have noticed, that the dialogue automatically just closed when the form submission handler completed.
Because we can just, we just go into on submit, we run the normal submit handler.
But they can just close the dialogue when it's done.
This might have a bit of a bug 'cause if an error occurs, we probably don't want to close the dialogue.
I probably wanna try and catch here, but it's good enough for a demonstration.
But yeah, this is, I am, I still have quite a bit of time left, but I don't have much left to talk to you guys about, I'm sorry, , but this is, yeah, what we do and it works really great for us.
I wrote a blog post about this, about 12 months ago.
Did pretty well, decided to turn it into a talk and it's honestly crazy that I'm here right now talking to all of you.
Very surprised.
If you wanna learn more about me here, my links, space drive.
I implore you to checkout.
We released our alpha a couple of weeks ago, and if you want any information from this talk, it's all, online as well.
Yeah.
Thank you all for listening.
Sorry I don't have more for you, but yeah.