Accessible, Typesafe, Progressively Enhanced Modern Web Forms

Kent C. Dodds
AuthorKent C. Dodds

Building web forms is one of the more challenging problems in web development (this is both hilarious and sad). I would say the biggest reason for this is because the web browser doesn't do enough for us.

What's interesting, is the browser actually does handle forms fairly well, but for most web applications we prevent this default behavior event.preventDefault() because it just doesn't do enough. This means we lose out on much of the work the browser does do for us, putting us at even more of a disadvantage when working with forms in the browser.

Luckily, modern frameworks are starting to improve things in this area. Instead of us having to prevent the browser's default behavior and re-implement it all ourselves, frameworks like Remix emulate the browser's default behavior, but still allow us to enhance the behavior to satisfy our user experience requirements.

As a part of EpicWeb.dev, I'm going to teach you how to utilize this powerful capability while addressing the needs of complex forms. Let's talk about it a bit.

Web Form Challenges

Arrays, objects, and files are all challenging to represent in an HTML form. The browser doesn't have a clear mapping of arrays and objects to form fields (and vice-versa). The browser represents these fields using the FormData API which works ok, but isn't really something we work with directly very often. We need some kind of transformation layer to operate between us and the form APIs.

Another challenge with web forms is validation. Especially the bits that require the server to be involved like whether a username is taken in an onboarding flow. Just managing the state involved with that can be a pain (handling race conditions, etc).

Finally, we need to be able to handle the submission of the form. This is another area where the browser does a lot for us, but we often prevent that default behavior and re-implement it ourselves and often without considering re-submissions and other edge cases that the browser has predictable and consistent behavior around.

Modern solutions

Remix helps a great deal with the submission of forms. It allows us to feel like we're using the browser's default behavior, but easily enhance the user's experience with things like optimistic UI, pending states, and error handling.

For example, here's some code from my talk Bringing Back Progressive Enhancement:


function ListItem({ todo }: { todo: TodoItem }) {
const updateFetcher = useFetcher()
const toggleFetcher = useFetcher()
const deleteFetcher = useFetcher()
const complete = todo.complete
return (
<li className={complete ? 'completed' : ''}>
<div className="view">
<toggleFetcher.Form method="post">
<input type="hidden" name="todoId" value={todo.id} />
<input type="hidden" name="complete" value={(!complete).toString()} />
<button
type="submit"
name="intent"
value="toggleTodo"
className="toggle"
title={complete ? 'Mark as incomplete' : 'Mark as complete'}
>
{complete ? <CompleteIcon /> : <IncompleteIcon />}
</button>
</toggleFetcher.Form>
<updateFetcher.Form method="post" className="update-form">
<input type="hidden" name="intent" value="updateTodo" />
<input type="hidden" name="todoId" value={todo.id} />
<input
name="title"
className="edit-input"
defaultValue={todo.title}
onBlur={(e) => {
if (todo.title !== e.currentTarget.value) {
updateFetcher.submit(e.currentTarget.form)
}
}}
aria-invalid={updateFetcher.data?.error ? true : undefined}
aria-describedby={`todo-update-error-${todo.id}`}
/>
{updateFetcher.data?.error && updateFetcher.state !== 'submitting' ? (
<div
className="error todo-update-error"
id={`todo-update-error-${todo.id}`}
>
{updateFetcher.data?.error}
</div>
) : null}
</updateFetcher.Form>
<deleteFetcher.Form method="post">
<input type="hidden" name="todoId" value={todo.id} />
<button
className="destroy"
title="Delete todo"
type="submit"
name="intent"
value="deleteTodo"
/>
</deleteFetcher.Form>
</div>
</li>
)
}

Pending UI

Let's say we want to add some pending UI to the "completed" state here. This is all we'd need to do:


const isToggling = Boolean(toggleFetcher.submission)

If the toggleFetcher is currently being submitted, then we know we're in a pending state and we can style the form however we like, or render a spinner based on that and we're golden.

Optimistic UI

That's easy stuff though. What about optimistic UI? In case you're unfamiliar, optimistic UI is a pattern that allows you to solve the problem of a slow UI based on network latency. For example, if you're on 𝕏 and you like a post, you'll notice the heart icon updates instantly even if you're on a slow connection.

Doing that is easy enough. Your action will almost always be successful. However, occasionally it will not be successful. For example, what if the post was deleted? In this situation, you need to reliably roll-back. This is what makes optimistic UI so hard and likely the reason many apps don't implement it.

Well, Remix makes this trivial in most cases as well. Because Remix manages the form submission and we have access to it, we can optimistically display the next state quite easily:


const isToggling = Boolean(toggleFetcher.submission)
const complete = isToggling
? toggleFetcher.submission?.formData.get('complete') === 'true'
: todo.complete

The formData.get('complete') will read the value the user's submitting from the form:


<input type="hidden" name="complete" value={(!complete).toString()} />

And that's it! The UI can just reference complete to know how to render properly. And because Remix handles form resubmissions and race conditions in a predictable and consistent way (modeled after the browser), we can reliably use the submission's formData to know what the user's trying to do and display optimistic UI based on that!

Not all optimistic UI is quite that simple, but most of it is, and it's awesome.

And then if there's an error from the server, we can retrieve that from toggleFetcher.data and display the error to the user... Speaking of which...

Error handling, type safety, and accessibility

I've been in this forms game for many years and writing a function to validate every form field is a huge pain. On top of that, to make things as accessible as possible you need to also handle native HTML attributes for validation, like required, max, minlength, etc. And then when there is an error, properly associating that error to the relevant form field (or the form as a whole) and moving the user's focus to the field at fault is like, a whole thing.

You wind up implementing the code twice for this important reason:

Server-side validation is required to keep yourself safe. Client-side validation is required to give the user a good experience.

This is a huge pain without the right tools.

Zod

As I've been around the block, it's become incredibly obvious that having schema-based validation is where it's at. Back in the day when I was maintaining angular-formly, I built my own schema-based validation library called api-check (before prop-types was extracted from React). People also put together conversion libraries that would convert a json-schema into the angular-formly field template so you could have persist-able form configuration and people built their own form CMS. It was pretty cool!

Bottom line: schema validation is powerful.

This is one reason I'm bullish on using zod for forms. It makes it easy to represent business logic as a validation schema that can be largely shared between the client and the server. For the pieces that don't work on both sides (like: the username/password they submitted is valid), you can have a base schema (validate the username and password fields independently) which you further refine on the server.

Here's an example of the login from the Epic Stack:


const UsernameSchema = z
.string({ required_error: 'Username is required' })
.min(3, { message: 'Username is too short' })
.max(20, { message: 'Username is too long' })
.regex(/^[a-zA-Z0-9_]+$/, {
message: 'Username can only include letters, numbers, and underscores',
})
// users can type the username in any case, but we store it in lowercase
.transform((value) => value.toLowerCase())
const PasswordSchema = z
.string({ required_error: 'Password is required' })
.min(6, { message: 'Password is too short' })
.max(100, { message: 'Password is too long' })
const LoginFormSchema = z.object({
username: UsernameSchema,
password: PasswordSchema,
redirectTo: z.string().optional(),
remember: z.boolean().optional(),
})

All of that logic is shared between the client and the server. But of course the logic behind the username and password being correct has to happen only on the server. So in our server-side action, we can do that by adding a transform to the LoginFormSchema:


LoginFormSchema.transform(async (data, ctx) => {
const session = await login(data)
if (!session) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Invalid username or password',
})
return z.NEVER
}
return { ...data, session }
})

If no session is created, then we add an issue to the context and return a value which will never be valid. This will cause the form to be invalid and the user will see the error message we added to the context.

Zod is an incredibly powerful tool for building validation logic.

You can learn more about how to use a zod transform with a form in the tip "Use Zod for All Form Validation".

Conform

But zod was not built specifically for forms and is missing a number of really critical features to make it work nicely for forms. Zod was built with objects and arrays in mind (as it should be). But form data comes from FormData which requires a bit of a transformation layer.

Additionally, converting the zod schema into something I can use to create my form is challenging because keeping things in sync between the native HTML attributes (like name and validation attributes), properly associating zod's errors to their fields, and focusing the user where they're supposed to go in the event of an error is definitely outside the scope of a more general-purpose library like zod.

This is where conform comes in. Conform behaves as the transformation layer between our zod schema and the form so we can generate a typesafe form with proper validation and error handling (including focus management). And then Conform allows us to parse the submitted FormData into the data we can use for our application.

Conform works great with Zod and Remix, and also works with other frameworks (even vanilla JS) and schema validation libraries. Here's an example of our zod LoginFormSchema with Conform:


import { Form, Link, useActionData, useSearchParams } from '@remix-run/react'
import { conform, useForm } from '@conform-to/react'
import { getFieldsetConstraint, parse } from '@conform-to/zod'
// ...
const actionData = useActionData<typeof action>()
const [searchParams] = useSearchParams()
const redirectTo = searchParams.get('redirectTo')
const [form, fields] = useForm({
id: 'login-form',
constraint: getFieldsetConstraint(LoginFormSchema),
defaultValue: { redirectTo },
lastSubmission: actionData?.submission,
onValidate({ formData }) {
return parse(formData, { schema: LoginFormSchema })
},
shouldRevalidate: 'onBlur',
})

From there, we can apply form.props to our <form> element, conform.input(fields.username) will give us the props appropriate to apply to our username input (for accessibility and HTML validation attributes derived from the schema), and errors can be found in fields.username.errors. In the Epic Stack, we have a Field component that gives us a consistent way to render fields, but here's an example of how you'd render the username field without that:


<label htmlFor={fields.username.id}>Username</label>
<input {...conform.input(fields.username)} />
<ul id={fields.username.errorId}>
{fields.username.errors.map((error) => (
<li key={error.code}>{error.message}</li>
))}
</ul>

Here's an example of what could be rendered (copied and slightly modified from the live site):


<label for="login-form-username">Username</label>
<input
id="login-form-username"
name="username"
form="login-form"
required=""
minlength="3"
maxlength="20"
pattern="^[a-zA-Z0-9_]+$"
data-conform-touched="true"
aria-invalid="true"
aria-describedby="login-form-username-error"
/>
<ul id="login-form-username-error">
<li>Username is required</li>
</ul>

Check out those attributes! They're all derived from the zod schema. So if we added a .optional() to our zod schema, the required attribute would be removed. Isn't that awesome? No need to duplicate the rules. Which leads into the next important point...

Thanks to Remix actions, we get progressive enhancement. The form will work before the JavaScript shows up because we validate everything in the action:


const formData = await request.formData()
const submission = parse(formData, { schema: LoginFormSchema })
if (submission.intent !== 'submit') {
return json({ status: 'idle', submission } as const)
}
if (!submission.value) {
return json({ status: 'error', submission } as const, { status: 400 })
}
const { username, password, redirectTo, remember } = submission.value

But most of the time, the user's experience will be enhanced by the JavaScript which includes client-side validation of the form with error handling as shown above with the onValidate callback.

To top it off, all of this is typesafe. If we decided to change from username to email, then we would get type warnings everywhere. From creating the form itself, to the submission.value in the action.

And conform supports all kinds of complexities with forms. You need a set of fields that represent an object? useFieldset. You need a dynamically sized array of fields? useFieldList. And Conform supports file uploads without breaking a sweat as well.

Dive in deep in the 📝 Professional Web Forms workshop.

Conclusion

We've got some fantastic tools for building typesafe, progressively enhanced, accessible forms for the web. Let me know what you think when you try these tools out!

Kent C. Dodds
Written by Kent C. Dodds

A world renowned speaker, teacher, open source contributor, created epicweb.dev, epicreact.dev, testingjavascript.com. instructs on egghead.io, frontend masters, google developer expert.

Join 40,000+ developers in the Epic Web community

Get the latest tutorials, articles, and announcements delivered to your inbox.

I respect your privacy. Unsubscribe at any time.