|
| 1 | +## Thinking of validation as a linear process |
| 2 | + |
| 3 | +We can think of validation as mapping from events to errors. |
| 4 | + |
| 5 | +We have two events: `blur` and `submit`. |
| 6 | + |
| 7 | +When a field is blurred, we validate whether its value is valid or not. |
| 8 | + |
| 9 | +When a whole form is submitted, we validate all of its fields at once. |
| 10 | + |
| 11 | +It would be great to write the same code to validate either a single field (when it is blurred) or a whole bunch of fields (when their form is submitted). |
| 12 | + |
| 13 | +If we were to sketch this using TypeScript: |
| 14 | + |
| 15 | +```ts |
| 16 | +function validate(event: Event): Map<string, string> { |
| 17 | + // Get the field(s) matching this event. |
| 18 | + // Get the values from the fields. |
| 19 | + // Validate each value. |
| 20 | + // Return a key-value map for each error, with the key identifying the field, and the value holding the error message. |
| 21 | +} |
| 22 | +``` |
| 23 | + |
| 24 | +So how do we do each of these steps? |
| 25 | + |
| 26 | +## Get the field(s) matching this event |
| 27 | + |
| 28 | +Each event that happens from a user interacting with some UI control has that control as part of the event. These can be accessed via the `.current` property on the event. |
| 29 | + |
| 30 | +For a `blur` event on an `<input>`, the `.current` property will refer to the input’s DOM element. This is an instance of [`HTMLInputElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement), which includes convenient properties like reading the current `.value`. |
| 31 | + |
| 32 | +For a `submit` event on a `<form>`, the `.current` property will refer to the form’s DOM element. This is an instance of [`HTMLFormElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement). |
| 33 | + |
| 34 | +Here’s an example form written in React demonstrating reading from the event: |
| 35 | + |
| 36 | +```tsx |
| 37 | +<form onSubmit={(event) => { |
| 38 | + const form = event.target; |
| 39 | + console.log(form instanceof HTMLFormElement); // true |
| 40 | +}}> |
| 41 | + <label for="f">First name</label> |
| 42 | + <input id="f" onBlur={(event) => { |
| 43 | + const input = event.target; |
| 44 | + console.log(input instanceof HTMLInputElement); // true |
| 45 | + }} /> |
| 46 | +</form> |
| 47 | +``` |
| 48 | + |
| 49 | +## Get the values from the fields |
| 50 | + |
| 51 | +So we’ve successfully been able to get a DOM element corresponding to the event. Why is this so powerful? Because we can read the current state of the form without having to store that state in React. |
| 52 | + |
| 53 | +That is, instead of adding an `onChange` handler to the form’s inputs and using that to update some React state, we can use these two events `blur` and `submit` as synchronization points to read from the DOM that the user is actively interacting with. Instead of listening to and controlling everything about the form, we let the browser do some of the work. |
| 54 | + |
| 55 | +Here are the two events we care about and the validation work we do for each. |
| 56 | + |
| 57 | +- `blur` event -> HTMLInputElement -> input value -> validate that single value. |
| 58 | +- `submit` event -> HTMLFormElement -> all input values -> validate every value. |
| 59 | + |
| 60 | +How we get all input values from a form? There’s a number of approaches, including using the [`.elements` property](https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement/elements) to get a list of child DOM elements. My favorite approach is to use [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) which has been added to browsers. |
| 61 | + |
| 62 | +If you have a HTMLFormElement, you can quickly read every single value from the form by creating a new `FormData` passing the form: |
| 63 | + |
| 64 | +```tsx |
| 65 | +const values = new FormData(form); |
| 66 | +values.get("firstField"); // "Some string" |
| 67 | +values.get("secondField"); // "Another string value" |
| 68 | + |
| 69 | +// For the given form: |
| 70 | +<form> |
| 71 | + <label for="f1">First</label> |
| 72 | + <input id="f1" name="firstField" value="Some string" /> |
| 73 | + |
| 74 | + <label for="f2">Second</label> |
| 75 | + <input id="f2" name="secondField" value="Another string value" /> |
| 76 | +</form> |
| 77 | +``` |
| 78 | + |
| 79 | +Note the keys like `firstField` are provided by setting the `name` attribute of each `<input>`. |
| 80 | + |
| 81 | +If we wanted to create an empty `FormData` and add values to it, we can also do that: |
| 82 | + |
| 83 | +```ts |
| 84 | +const values = FormData(); |
| 85 | +values.get("firstField"); // null |
| 86 | +values.set("firstField", "The value for this field"); |
| 87 | +values.get("firstField"); // "The value for this field" |
| 88 | +``` |
| 89 | + |
| 90 | +Let’s see that with our React form: |
| 91 | + |
| 92 | +```tsx |
| 93 | +function validate(values) { |
| 94 | + // TODO |
| 95 | +} |
| 96 | + |
| 97 | +<form onSubmit={(event) => { |
| 98 | + const form = event.target; |
| 99 | + const values = new FormData(form); |
| 100 | + validate(values); |
| 101 | +}}> |
| 102 | + <label for="f1">First name</label> |
| 103 | + <input id="f1" name="firstName" onBlur={(event) => { |
| 104 | + const input = event.target; |
| 105 | + const values = new FormData(); |
| 106 | + values.set("firstName", input.value); |
| 107 | + validate(values); |
| 108 | + }} /> |
| 109 | +</form> |
| 110 | +``` |
| 111 | + |
| 112 | +You can see for both events we get the associated DOM element and their relevant values and turn it into a `FormData`. I like this pattern of turning two different types of input into a consistent output, as now the code that follows can think about things in just one way, instead of requiring two branches say with an `if` statement. |
| 113 | + |
| 114 | +Now, you might think “there’s only one field here, so I’m going to have to duplicate the `onBlur` handler for every field”. |
| 115 | + |
| 116 | +Say if we added _last name_ and _email_ fields, our code now looks like this: |
| 117 | + |
| 118 | +```tsx |
| 119 | +function validate(values) { |
| 120 | + // TODO |
| 121 | +} |
| 122 | + |
| 123 | +<form onSubmit={(event) => { |
| 124 | + const form = event.target; |
| 125 | + const values = new FormData(form); |
| 126 | + validate(values); |
| 127 | +}}> |
| 128 | + <label for="f1">First name</label> |
| 129 | + <input id="f1" name="firstName" onBlur={(event) => { |
| 130 | + const input = event.target; |
| 131 | + const values = new FormData(); |
| 132 | + values.set("firstName", input.value); |
| 133 | + validate(values); |
| 134 | + }} /> |
| 135 | + |
| 136 | + <label for="f2">Last name</label> |
| 137 | + <input id="f2" name="lastName" onBlur={(event) => { |
| 138 | + const input = event.target; |
| 139 | + const values = new FormData(); |
| 140 | + values.set("lastName", input.value); |
| 141 | + validate(values); |
| 142 | + }} /> |
| 143 | + |
| 144 | + <label for="f3">Email</label> |
| 145 | + <input id="f3" name="email" type="email" onBlur={(event) => { |
| 146 | + const input = event.target; |
| 147 | + const values = new FormData(); |
| 148 | + values.set("email", input.value); |
| 149 | + validate(values); |
| 150 | + }} /> |
| 151 | +</form> |
| 152 | +``` |
| 153 | + |
| 154 | +Ugghh that’s a lot of repetition. Wouldn’† it be great it we could just have one `onBlur` handler? Turns out we can: |
| 155 | + |
| 156 | +```tsx |
| 157 | +function validate(values) { |
| 158 | + // TODO |
| 159 | +} |
| 160 | + |
| 161 | +<form |
| 162 | + onBlur={(event) => { |
| 163 | + const input = event.target; |
| 164 | + const values = new FormData(); |
| 165 | + values.set(input.name, input.value); |
| 166 | + validate(values); |
| 167 | + }} |
| 168 | + onSubmit={(event) => { |
| 169 | + const form = event.target; |
| 170 | + const values = new FormData(form); |
| 171 | + validate(values); |
| 172 | + }} |
| 173 | +> |
| 174 | + <label for="f1">First name</label> |
| 175 | + <input id="f1" name="firstName" /> |
| 176 | + |
| 177 | + <label for="f2">Last name</label> |
| 178 | + <input id="f2" name="lastName" /> |
| 179 | + |
| 180 | + <label for="f3">Email</label> |
| 181 | + <input id="f3" name="email" type="email" /> |
| 182 | +</form> |
| 183 | +``` |
| 184 | + |
| 185 | +This is a feature of JavaScript, not React. Events like `blur` bubble up, so if they aren’t handled by an event listener on the input element itself, then they bubble to its parent and then its parent, right up to the `<body>`. |
| 186 | + |
| 187 | +Since we want to handle all `blur` events within the form in the same way, it makes sense to add the `blur` event handler to the form itself. |
| 188 | + |
| 189 | +Plus we can use the same `name` attribute that `new FormData(form)` uses to identify the field’s value in our `onBlur` handler. |
| 190 | + |
| 191 | +## Validate each value |
| 192 | + |
| 193 | +So given we have a `FormData` object, how can we validate each value? |
| 194 | + |
| 195 | +For now, we’ll just validate that the fields were filled in. If the user didn’t type anything in, or only entered whitespace, we’ll flag it as an error. Otherwise, we’ll say the field is valid and therefore has no error. |
| 196 | + |
| 197 | +We’ll store our errors in a `Map`, which is similar to `FormData` with `.get()` and `.set()` methods, but we can use to store any key-value pairs. |
| 198 | + |
| 199 | +```ts |
| 200 | +const errors = new Map(); |
| 201 | +for (const [name, value] of formDataFromEvent(event)) { |
| 202 | + errors.delete(name); |
| 203 | + |
| 204 | + // TODO: add more advanced validation here |
| 205 | + if (value.trim() === "") { |
| 206 | + errors.set(name, "Required"); |
| 207 | + } |
| 208 | +} |
| 209 | +``` |
| 210 | + |
| 211 | +## Return a key-value map for each error |
| 212 | + |
| 213 | +## Making it repeatable with a reducer |
| 214 | + |
| 215 | +```tsx |
| 216 | +function formDataFromEvent(event: Event) { |
| 217 | + if (event.target instanceof HTMLFormElement) { |
| 218 | + return new FormData(event.target); |
| 219 | + } |
| 220 | + |
| 221 | + const formData = new FormData(); |
| 222 | + if (event.target instanceof HTMLInputElement) { |
| 223 | + formData.set(event.target.name, event.target.value); |
| 224 | + } |
| 225 | + return formData; |
| 226 | +} |
| 227 | + |
| 228 | +function reducer(state, event) { |
| 229 | + if (event.type === "submit") { |
| 230 | + event.preventDefault(); |
| 231 | + } |
| 232 | + |
| 233 | + const errors = new Map(state.errors); |
| 234 | + for (const [name, value] of formDataFromEvent(event)) { |
| 235 | + errors.delete(name); |
| 236 | + |
| 237 | + // TODO: add more advanced validation here |
| 238 | + if (value.trim() === "") { |
| 239 | + errors.set(name, "Required"); |
| 240 | + } |
| 241 | + } |
| 242 | + |
| 243 | + return { ...state, errors }; |
| 244 | +} |
| 245 | + |
| 246 | +function Field({ name, label, error, type = "text" }) { |
| 247 | + const id = useId(); |
| 248 | + return ( |
| 249 | + <div class="flex items-center gap-2"> |
| 250 | + <label for={id}>{label}</label> |
| 251 | + <input id={id} name={name} type={type} /> |
| 252 | + <span class="italic">{error}</span> |
| 253 | + </div> |
| 254 | + ); |
| 255 | +} |
| 256 | + |
| 257 | +export default function App() { |
| 258 | + const [state, dispatch] = useReducer(reducer, { errors: new Map<string, string>() }); |
| 259 | + |
| 260 | + return ( |
| 261 | + <form onBlur={dispatch} onSubmit={dispatch} class="flex flex-col items-start gap-4"> |
| 262 | + <p class="italic">Fields will individually validate on blur, or every field will validate on submit.</p> |
| 263 | + <fieldset class="flex flex-col gap-2"> |
| 264 | + <Field |
| 265 | + name="firstName" |
| 266 | + label="First name" |
| 267 | + error={state.errors.get("firstName")} |
| 268 | + /> |
| 269 | + <Field |
| 270 | + name="lastName" |
| 271 | + label="Last name" |
| 272 | + error={state.errors.get("lastName")} |
| 273 | + /> |
| 274 | + <Field |
| 275 | + name="email" |
| 276 | + label="Email" |
| 277 | + type="email" |
| 278 | + error={state.errors.get("email")} |
| 279 | + /> |
| 280 | + </fieldset> |
| 281 | + <button class="px-3 py-1 bg-blue-300 rounded">Save</button> |
| 282 | + </form> |
| 283 | + ); |
| 284 | +} |
| 285 | +``` |
0 commit comments