Engineering at Car & Classic · 1/4

Form inputs for a whole company: one component per field, built on composables

Why I replaced a wrapper-based form pattern at Car & Classic with self-contained inputs built on small composables, and how the select typing contract works.

On this page

I work at Car & Classic; my experience has the short version of the role and the codebase.

Every form on Car & Classic is made of the same few things: text fields, selects, autocompletes, dates, checkboxes. I built the inputs behind them from the ground up, and now every engineer in the company uses them.

This is the story of why the old pattern had to go, how the new inputs are layered, and the one typing rule that keeps them safe.

Why: too many moving parts

A field used to be three or four components that had to agree with each other: a form-field wrapper for the label and the error, the input plugged inside it, and sometimes a separate error message component. The wrapper shared an ID with the input through provide/inject.

<FormField :label="t('vehicle.make')" :error="errors.make">
  <SelectInput
    v-model="make"
    :options="options.makes"
    :has-error="!!errors.make"
  />
</FormField>

It looks like the compound component pattern, but it isn’t one. In a real compound component, like a set of tabs, the parent owns state that several children share, and provide/inject is the right tool for it. Here the wrapper only plugged an ID into the one input inside it, and everything else still had to be passed to both by hand.

It works, but it asks every developer to coordinate things the component could know by itself:

  • The error state is written twice. The wrapper gets the message, the input gets has-error, and nothing checks they agree, so it’s easy for a wrapper to show an error while its input still looks valid.
  • Provide/inject for one level. Provide/inject is built for passing data down deep trees. Here it connected a parent to its direct child, where a prop would have been simpler, faster and visible in the code.
  • It adds up. One form used this pattern more than twenty times, over 1,172 lines.
  • It’s hard to work with. To add a field, you have to know that the wrapper and the input must agree, pass the error to both, and trust an ID you can’t see in the template. When something goes wrong, you debug three components and an injection to find out why a label points nowhere. For a developer new to the codebase, or new to Vue, that’s a lot to hold in their head for a text field.
  • It’s hard to test. An input can’t be mounted on its own, because it expects the wrapper’s injection. Every test has to fake that context first.

The new inputs: one component per field

<SelectInput
  v-model="make"
  name="make"
  :label="t('vehicle.make')"
  :options="options.makes"
  :error-message="errors.make"
/>

Label, error message, help text, required state, clearing and accessibility are all part of the input. There’s nothing to coordinate, so nothing to get wrong.

Wrapper patternSelf-contained inputs
Components per field3–41
Lines per field10–153–8
Imports31
Error handlingcoordinated by handbuilt in
TypeScriptpartialend to end

That same form went from 1,172 lines to about 600. And testing got simpler with it: an input no longer needs a fake injection context to be mounted, so it’s just props in, output checked.

Why not a headless library

Libraries like Radix Vue or Headless UI solve part of this, but at Car & Classic we build our components in-house rather than pulling in packages. That meant owning the hard parts: accessibility, keyboard behaviour and typing. It also meant we could shape every one of them around our own forms, instead of around a library’s idea of a select.

Behind the components: layers of composables

Layers of the select and autocomplete inputsComponents: keyboard, dropdown, accessibility, useSelectInput, useAutocompleteInput, Select, Autocomplete, Options dropdown. Connections: keyboard → useSelectInput; dropdown → useSelectInput; useSelectInput → useAutocompleteInput; useSelectInput → Select; useAutocompleteInput → Autocomplete; accessibility → Options dropdown; Options dropdown → Select; Options dropdown → Autocomplete.keyboardnavigationdropdownfocus · scrollaccessibilityARIAuseSelectInputuseAutocompleteInputSelectcomponentAutocompletecomponentOptions dropdownlistbox
A simplified view, without the full architecture: utility composables at the bottom, the select logic built on them, the autocomplete built on the select, and thin components on top that share one dropdown.

The select and the autocomplete are the most interesting of the inputs, because a select only looks simple. It’s keyboard navigation, focus, scrolling, screen readers, static and remote options, pagination, search, loading and error states, all in one small box. Each of those is its own layer:

  • Three small foundations, one concern each. An accessibility composable generates the IDs and all the ARIA wiring (combobox, listbox, aria-describedby, aria-invalid, aria-busy). A dropdown composable handles focus without the page jumping and scrolls the highlighted option into view. A keyboard composable handles arrows, Home and End, Enter, Escape, and skips disabled options.
  • useSelectInput is the select’s logic: normalising the value it’s given, combining local options with pages loaded from an API, and cancelling a request in flight when a new one starts. There’s exactly one way to change the value: choosing an option.
  • useAutocompleteInput builds search on top of the select. Short queries filter locally, longer ones call the API, debounced, so typing doesn’t fire a request per keystroke.
  • The components are thin. The select and the autocomplete are mostly templates, sharing one options dropdown that renders the accessible listbox, the loading states and infinite scroll for long remote lists.

Two problems were worth solving carefully:

Scrolling to an option the composable can’t see. Keyboard navigation lives in a composable, but the option elements live inside the dropdown component. The dropdown exposes a small method that returns an option’s element by index, and the dropdown composable uses it to scroll that exact element into view. The logic stays out of the template, and the template knows nothing about keyboards.

Keyboard state that goes stale while you type. When the autocomplete filters its options, keyboard navigation doesn’t know the list changed, so the highlight can point at an option that isn’t there any more. Whenever the filtered list changes, the autocomplete tells the navigation to recompute which options can be highlighted, and highlights the first one. Typing and arrowing always agree.

The typing contract

A select is generic: it can hold any kind of option, and hand back either the whole option or one field of it. TypeScript can check that, as long as everyone follows one rule:

The type of your v-model must match what your value mapper returns.

interface Country { code: string; name: string; continent: string }

// the mapper returns the whole object, so the model holds a Country
const country = ref<Country | null>(null);
// mappers: { optionLabel: (c) => c.name, optionValue: (c) => c }

// the mapper returns the code, so the model holds a string
const countryCode = ref<string | null>(null);
// mappers: { optionLabel: (c) => c.name, optionValue: (c) => c.code }

// no mappers: options and model use the { label, value } shape
const option = ref<SelectOption<string> | null>(null);

The rest of the guidelines follow from it:

  • Prefer mappers over converting everything to { label, value }: you keep your own types, and get your own objects back.
  • Start empty with null. It’s simple and type-safe.
  • Pre-fill with the whole option when the options come from an API. A bare primitive like 'PT' can only be matched against options the component already has. If they arrive later, from a search or a paginated request, give the model the full object and let the component normalise it to the mapped value, which is why that model is typed as a union like Country | string | null.
  • Use track-by for objects that should be compared by a key rather than by reference.
  • Async functions take an AbortSignal, so the input can cancel what it no longer needs.

How I approached it

The goal was never the cleverest input. It was inputs that the rest of the team would find easy to use, and that would stay easy to maintain and to test long after I’d moved on to something else.

That came down to a few choices:

  • Small pieces. Composables were the key. Each one owns one concern, can be read in a few minutes and tested on its own, and none of them knows about the component that uses it.
  • The right layers, and no more. Utility composables, then the select logic, then the autocomplete on top of it, then thin components. Each layer exists because something above it needs it. I kept asking whether a new layer made the code easier to follow or only more abstract, and left it out when it was the second.
  • Easy to use comes first. One component per field, standard props and v-model. If using an input needs a page of documentation, the input is wrong.
  • Tested from the start. The tests weren’t added at the end. They’re what made it safe to change the internals while the API stayed the same.

What it gives the rest of the team

  • Accessible by default. Nobody has to remember ARIA attributes.
  • Typed end to end. A select of countries hands you a country, not any.
  • Remote data without extra code. Pass a function that fetches a page, and loading, pagination, cancellation and infinite scroll come with it.
  • Tested in pieces. 241 tests across the inputs, and each composable is tested on its own.
  • Easier for everyone. Standard props and v-model instead of a custom injection system: easier for people still growing into Vue, and still extensible for people who want more.

The trade-offs

  • What it protected: maintainability, testability and accessibility, and the developer experience of everyone who builds a form. Each composable has one job, so it can change and be tested on its own.
  • What it cost: a migration. Every existing form built on the old pattern has to be moved over, and until it is, both ways exist side by side.
  • What it asks for: care with the API. A component every engineer uses can’t change shape casually, which is why the typing contract and the tests matter as much as the components themselves.

Spot it in your own app

  • Count the components it takes to render one field. If a label, an input and an error each need their own component, and have to be kept in sync by hand, the field wants to be one component.
  • Look for provide/inject connecting a parent to its direct child. A prop is usually simpler, and visible in the code.
  • Try mounting one of your inputs in a test on its own. If it needs a fake parent or a fake injection first, that’s a sign of the same coupling.

The lesson

Components used by everyone are infrastructure. They deserve the same care as a backend service: small parts with one job each, clear contracts between them, and defaults that make the right thing the easy thing. Get that right once, and nobody has to think about their form fields again.

Thank you

To Adrian Castillo, for the iterations and the reviews through the whole process. Many of the decisions above got better because he questioned them first.