How to fix a busted radio input

Published:

Here is a story about how I burned through four hours because I forgot about some basic HTML form characteristics.

Once upon a time, I’m configuring a set of radio buttons for customising the colour of a product for purchase. This product has a primary colour and a secondary colour, and we want the user to be able to configure it however they want.

(We’ve long been a fan of radio buttons instead of dropdowns for selecting one option out of a list of <6. For more reading, check out Adam Silver’s great summary of good form design.)

Anyway, I’ve got a component that consumes some JSON and builds the form for me, since I don’t like fiddling around with writing <label> and <input> over and over again. First off, here’s the product variant JSON that generates the radio buttons section of the form:

[
  {
      "title": "Primary Colour",
      "name": "primary",
      "choices": [
          {
              "label": "Blue",
              "value": "blue"
          },
          {
              "label": "Red",
              "value": "red"
          }
      ]
  },
  {
      "title": "Secondary Colour",
      "name": "secondary",
      "choices": [
          {
              "label": "Blue",
              "value": "blue"
          },
          {
              "label": "Red",
              "value": "red"
          }
      ]
  },
  {
      "title": "Size",
      "name": "size",
      "choices": [
          {
              "label": "Small",
              "value": "sm"
          },
          {
              "label": "Medium",
              "value": "md"
          },
          {
              "label": "Large",
              "value": "lg"
          }
      ]
  }
]

If you’ve put together HTML forms before, everything here should be pretty self explanatory. We’re going to have three inputs: one for the primary colour, one for the secondary, and one for the size of the product.

Meantime, here’s a pared-back version of the component:

const options = require('./options.json');

function Form() {
  function submit() {
    // do something with the data here
  }

  return (
    <form onSubmit={submit}>
      {options.map(option => (
        <div key={option.name} className="form-section">
          <h4 className="form-section__title">{option.title}</h4>
          <div className="form-section__fields">
            {option.choices.map(choice => (
              <div key={choice.value} className="field">
                <input
                  type="radio"
                  name={option.name}
                  id={choice.value}
                  className="field__input"
                />
                <label htmlFor={choice.value} className="field__label">
                  {choice.label}
                </label>
              </div>
            ))}
          </div>
        </div>
      ))}
    </form>
  );
}

This should look pretty standard if you’re used to building forms in React. The basic idea here is that we’re mapping over each of the options (primary colour, secondary colour), printing out a title for each option, and then mapping over each of the option’s choices to generate an input and a label. We’ve hooked up the inputs and labels by using an id on the input and htmlFor on the label, so that when we click on the label, the appropriate input will be selected.

What went wrong

Unfortunately, all was not well in selector-land. For some reason, the primary colour selector was working just fine, but when you tried to select a secondary colour, it just re-selected the primary colour instead.

Can you see what’s wrong here? It might look familiar. It might actually be jumping out to you, but it wasn’t to me. I spent some four hours across two days hooking up a change event listener, console.log-ing the values of input elements, combing through the React devtools, and desperately searching the Internet for an answer. This was an older build of React, so could it be something that’s since been patched? The correct values were all being passed to the input attributes, so why were they passing the wrong values to the change event?

The breakthrough happened when I realised that the key values in the inner map were conflicting. Since the colors were the same for both primary and secondary selectors, the page had two elements with key="red" and key="blue" on them. I hadn’t really thought about this because these were keys in different .map() loops, but I guessed that this was confusing React and passing through the wrong values.

And yet, after fixing the keys, the problem still existed.

Turns out that the issue was even more basic than that: the form id/for combination. See, <input> elements can be associated with their corresponding <label> elements by setting the for (or in React’s case, htmlFor, since for is a keyword in Javascript) attribute on the <label> to the same value as the id on the <input>. Like so:

<label for="blue">Blue</label>
<input type="radio" id="blue" name="primary" value="blue" />

Why would you want to associate labels with their inputs? Well, besides the easy accessibility wins you get for users who might not use a mouse + keyboard + high-res screen combo, this also allows your users to select the input by clicking on the label. You’ll notice in the gif above that I never once clicked on the radio element itself—but on the label instead.

You may also notice that not only were the keys duplicated, but the ids on the inputs and the htmlFors on the labels. For that reason, when I clicked on the red label under the ‘Secondary Colour’ heading, the browser checked through the DOM to find an input with an id of ‘red’, found the first one (under the ‘Primary colour’ heading), and selected it.

That’s why the change event listener was receiving the wrong values. That’s why I couldn’t figure out what was going wrong. And that’s why the wrong element was being selected. It was a silly mistake. And it wasn’t like I didn’t know about this bit of browser functionality beforehand. In fact, I implemented a fix for this on a website we inherited just a couple of months ago, driving the client crazy.

But something about the abstraction that modern Javascript frameworks afford over writing plain ol HTML took this idea out of my mind. I thought that I could offload the radio selection button to a piece of state in my application and I wouldn’t have to worry about it.

It just highlights the fact that you can fight browsers all you want—you can throw megabytes of polyfills and fallbacks at the browser—but you can’t, and shouldn’t, ignore basic browser features. They’ll always be the fastest, easiest, most accessible ways to interact with the Internet. Today I remembered not to build around the browser, but on it.