Welcome to the EpicWeb.dev Workshop app!

This is the deployed version. Run locally for full experience.

Complex Structures

How would you represent an array in an HTML form? Maybe you'd do something like this:
<form>
	<input type="text" name="todo[]" value="Buy milk" />
	<input type="text" name="todo[]" value="Buy eggs" />
	<input type="text" name="todo[]" value="Wash dishes" />
</form>
While that's technically valid HTML, it's not very useful. If you inspected the formData, you'd get this:
const formData = new FormData(form)
formData.get('todo') // null
formData.get('todo[]') // "Buy milk"
Not quite what you want. The FormData API is similar in some ways to the Headers API and the URLSearchParams API. They have entries that can have multiple values with the same name. So, if you wanted to represent those todos in a form, you could do something like this:
<form>
	<input type="text" name="todo" value="Buy milk" />
	<input type="text" name="todo" value="Buy eggs" />
	<input type="text" name="todo" value="Wash dishes" />
</form>
const formData = new FormData(form)
formData.getAll('todo') // ["Buy milk", "Buy eggs", "Wash dishes"]
One way to think about this is that the FormData object is as an array of key/value pairs, like this:
// this is just a visualization, form data is not an array of arrays.
const formData = [
	['todo', 'Buy milk'],
	['todo', 'Buy eggs'],
	['todo', 'Wash dishes'],
]
And the reason for this is because there's no way in HTML to represent an object or an array. Only key/value pairs.
Another problem we have with the FormData API is that forms cannot be nested.
So this is not allowed:
<form>
	<form>
		<input type="text" name="todo" value="Buy milk" />
		<input type="checkbox" name="completed" checked />
	</form>
</form>
This is not allowed. This has unfortunate implications for how you might represent more complex data structures like an object, or array of objects. Let's say you want to add a "completed" property to each todo. You could do something like this:
<form>
	<input type="text" name="todo" value="Buy milk" />
	<input type="checkbox" name="completed" checked />
	<input type="text" name="todo" value="Buy eggs" />
	<input type="checkbox" name="completed" />
	<input type="text" name="todo" value="Wash dishes" />
	<input type="checkbox" name="completed" checked />
</form>
If we visualize this as an array of key/value pairs, it would look like this:
// this is just a visualization, form data is not an array of arrays.
const formData = [
	['todo', 'Buy milk'],
	['completed', 'on'],
	['todo', 'Buy eggs'],
	['todo', 'Wash dishes'],
	['completed', 'on'],
]
Whoops, didn't I tell you, in the FormData API, a checked checkbox is represented by the string "on" and an unchecked checkbox is represented by just not appearing in the form data at all 😱
So we can't really rely on the order of elements in the form data to convert this into anything useful. We need to use more specific names for each input:
<form>
	<input type="text" name="todo[0].content" value="Buy milk" />
	<input type="checkbox" name="todo[0].complete" checked />
	<input type="text" name="todo[1].content" value="Buy eggs" />
	<input type="checkbox" name="todo[1].complete" />
	<input type="text" name="todo[2].content" value="Wash dishes" />
	<input type="checkbox" name="todo[2].complete" checked />
</form>
Then we can visualize this as an array of key/value pairs:
// this is just a visualization, form data is not an array of arrays.
const formData = [
	['todo[0].content', 'Buy milk'],
	['todo[0].complete', 'on'],
	['todo[1].content', 'Buy eggs'],
	['todo[2].content', 'Wash dishes'],
	['todo[2].complete', 'on'],
]
Then, we can use some fancy JS to convert this into a more useful object:
const data = {
	todos: [
		{ content: 'Buy milk', complete: true },
		{ content: 'Buy eggs', complete: false },
		{ content: 'Wash dishes', complete: true },
	],
}
And that is something we can work with! 😩 Phew, what a pain.

Conform

Luckily for us, this is something Conform has already thought about and has great support for! With nested objects and arrays.

Objects

To handle nested objects, you use what Conform refers to as a fieldset which actually maps nicely to the HTML <fieldset> semantic element.
Here's a quick example of this:
// example inspired from the Conform docs
import { useForm, useFieldset, conform } from '@conform-to/react'

function Example() {
	const [form, fields] = useForm<Schema>({
		// ... config stuff including the schema
	})
	const addressFields = useFieldset(form.ref, fields.address)

	return (
		<form {...form.props}>
			<fieldset>
				<input {...conform.input(addressFields.street)} />
				<input {...conform.input(addressFields.zipcode)} />
				<input {...conform.input(addressFields.city)} />
				<input {...conform.input(addressFields.country)} />
			</fieldset>
		</form>
	)
}
For the name attribute, Conform would use address.street, etc. But this is all an implementation detail for you. Ultimately after parsing and everything, you'll wind up with an object:
const data = {
	address: {
		street: '123 Main St',
		zipcode: '12345',
		city: 'New York',
		country: 'USA',
	},
}

Arrays

For arrays, you'll use Conform's useFieldList hook:
// example inspired from the Conform docs
import { useForm, useFieldList, conform } from '@conform-to/react'

function Example() {
	const [form, fields] = useForm({
		// ... config stuff including the schema
	})
	const list = useFieldList(form.ref, fields.tasks)

	return (
		<form {...form.props}>
			<ul>
				{list.map(task => (
					<li key={task.key}>
						{/* Set the name to `task[0]`, `tasks[1]` etc */}
						<input {...conform.input(task)} />
					</li>
				))}
			</ul>
		</form>
	)
}
When that gets parsed, you'll wind up with an array:
const data = {
	tasks: ['Buy milk', 'Buy eggs', 'Wash dishes'],
}
With arrays, it can be tricky because you often want to allow the user to add and remove elements of the array, so you need some extra logic to handle that. Luckily, Conform has utilities for exactly this with its intent button utils:
// example inspired from the Conform docs
import { useForm, useFieldList, conform, list } from '@conform-to/react'

export default function Todos() {
	const [form, fields] = useForm({
		// ... config stuff including the schema
	})
	const taskList = useFieldList(form.ref, fields.tasks)

	return (
		<form {...form.props}>
			<ul>
				{taskList.map((task, index) => (
					<li key={task.key}>
						<input {...conform.input(task)} />
						<button {...list.remove(tasks.name, { index })}>Delete</button>
					</li>
				))}
			</ul>
			<div>
				<button {...list.insert(tasks.name)}>Add task</button>
			</div>
			<button>Save</button>
		</form>
	)
}
What's amazingly awesomely cool about this is that it actually works without JavaScript. That's just something to nerd out a bit on, but really what's nice about this is that it allows you to build forms that are used to generate complex data structures. Combine that with Zod schema validation and you've got yourself a really powerful form solution.
As a reminder from exercise 3, conform v1 was released after and this lesson is also impacted by breaking change updates. This workshop material still uses pre-1.0 conform. You can watch the video in exercise 3 for more details on what changed.