Using the HTML Data List Element for Simple Combo Boxes
September 12, 2020 (updated September 20, 2020)
Table of Contents
Use Case
For a search feature, we’re storing the 100 most recently used (“MRU”) search terms. In the search box, we want to auto suggest based on the MRU terms, but not restrict the input to just those terms.
Submitted term:
Solution: HTML <datalist>
Until recently, I didn’t know about the <datalist>
element until I
read Peter Bengtsson’s article. According to MDN:
The HTML
<datalist>
element contains a set of<option>
elements that represent the permissible or recommended options available to choose from within other controls.
Sounds exactly like what we need.
Version 1
Let’s start with a basic implementation:
const [term, setTerm] = React.useState("");const [mruTerms, setMruTerms] = React.useState([]);
React.useEffect(() => { fetch("https://random-word-api.herokuapp.com/word?number=100&swear=0") .then((response) => response.json()) .then((data) => setMruTerms(data));}, []);
return (
<main>
<form
autoComplete="off"
onSubmit={(e) => {
e.preventDefault();
const term = e.currentTarget.term.value;
if (term.trim() !== "") {
setTerm(term.trim()); setMruTerms([...new Set([term.trim(), ...mruTerms])].slice(0, 100)); }
}}
>
<label htmlFor="term">Search term</label>
<input list="mru-terms" id="term" name="term" />
<datalist id="mru-terms" key={term}> {mruTerms.map((term) => ( <option key={term} value={term} /> ))} </datalist>
<button>Search</button>
</form>
<p>Submitted term: {term}</p>
</main>
);
- We keep track of two pieces of context data: what the user submits (
term
), and the list of MRU terms (mruTerms
). - We initialize
mruTerms
when the component is first rendered. In a real app, this can be fetched from a service, upstream app state, orlocalStorage
. <option>
elements of the<datalist>
are generated frommruTerms
. Notice the entire<datalist>
component is replaced every timeterm
changes since we bind it to thekey
prop.- You can think of
mruTerms
as a detail ofterm
, since every timeterm
changes, we are generating a new array of associatedmruTerms
. - As a bonus, this also works around a longstanding Firefox bug with dynamic datalists.
- You can think of
- Each time the user performs a search, both
term
andmruTerms
are updated (and persisted in a real app).
👩💻 Try It Out
Version 2
With very little code, our autosuggest feature already works pretty well. Let’s refactor our code so all context data is handled within a single object:
const useInitialize = () => {
const [{ term, mruTerms }, dispatch] = React.useReducer(reducer, initialData); React.useEffect(() => {
fetch("https://random-word-api.herokuapp.com/word?number=100&swear=0")
.then((response) => response.json())
.then((payload) => dispatch({ type: "init", payload }));
}, []);
const actions = {
updateTerm: (payload) => dispatch({ type: "updateTerm", payload }),
};
return { term, mruTerms, actions };
};
const initialData = { term: "", mruTerms: [] };
const reducer = (data, { type, payload }) => {
const { term, mruTerms } = data;
switch (type) {
case "init":
return { term, mruTerms: payload };
case "updateTerm":
if (payload.trim() === "") return data;
return {
term: payload.trim(),
mruTerms: [...new Set([payload.trim(), ...mruTerms])].slice(0, 100),
};
default:
return data;
}
};
- Instead of multiple
useState
s, we consolidate into a singleuseReducer
. - We move most of the business logic into a custom hook, simplifying the actual component.
👩💻 Try It Out
Version 3
For our purposes, the search terms “ice cream”, “Ice Cream”, and ” ICE CREAM ”
are all considered equivalents. We want to store the last version used (minus
the surrounding spaces) without duplicates in mruTerms
.
Let’s create couple helper functions first:
const addMruTerm = (mruTerms, term) =>
[term, ...mruTerms]
.reduce(
(unique, item) =>
unique.some((e) => trimLower(e) === trimLower(item))
? unique
: [...unique, item.trim()],
[],
)
.slice(0, 100);
const trimLower = (term) => term.trim().toLowerCase();
Then we change how we update mruTerms
in the reducer:
mruTerms: addMruTerm(mruTerms, payload);
👩💻 Try It Out
Version 4
Our autosuggest feature is starting to work nicely. You’ll notice that Chrome
and Firefox sort suggestions in the mruTerms
array order. I think a better
experience would be to sort by three major sections. For example, if the search
term is “cr”:
- First, suggestions that begin with the current search term, e.g. ”cranial”, ”creamery”.
- Next, suggestions that contain words that start with the current search, e.g. “heavy crate”, “ice cream”.
- Finally, suggestions that contain the search term, anywhere, e.g. “discreet”, “scribe”.
Within each section, we can further sort alphabetically.
In order to accomplish this, we need to update <datalist>
as we type the
search term. This means we need to keep track of couple additional pieces of
context data: draft
and sortedMruTerms
:
const initialData = { draft: "", term: "", mruTerms: [], sortedMruTerms: [] };
We also add another helper function sortMruTerms
, and adjust the reducer
accordingly:
const reducer = (data, { type, payload }) => {
const { draft, mruTerms } = data;
switch (type) {
case "init": return { ...data, mruTerms: payload, sortedMruTerms: payload }; case "updateDraft": return { ...data, draft: payload, sortedMruTerms: sortMruTerms(mruTerms, payload), }; case "updateTerm": if (draft.trim() === "") return data; return { ...data, term: draft.trim(), mruTerms: addMruTerm(mruTerms, draft), }; default:
return data;
}
};
const sortMruTerms = (mruTerms, term) => { const partial = trimLower(term);
if (partial === "") return mruTerms;
let terms = [...mruTerms];
let sorted = [];
sortFilters(partial).forEach((filter) => {
sorted = [...sorted, ...terms.filter(filter).sort()];
terms = terms.filter((t) => !sorted.includes(t));
});
return sorted;
};
const sortFilters = (partial) => [
(t) => trimLower(t).startsWith(partial),
(t) => RegExp(`\\b${partial}`, "i").test(t),
(t) => RegExp(partial, "i").test(t),
];
- Initialize
sortedMruTerms
. - Add action to handle
updateDraft
, which is called each time the search term changes. updateTerm
no longer requires a payload, since we can calculate new context values based ondraft
.- Make some tweaks to the custom hook (I trust you can figure this part out on your own).
Lastly, some minor adjustments in the JSX and we’re done:
<input
value={draft} onChange={(e) => actions.updateDraft(e.currentTarget.value)} list="mru-terms"
id="term"
name="term"
/>
<datalist id="mru-terms" key={draft}> {sortedMruTerms.map((term) => ( <option key={term} value={term} />
))}
</datalist>
- Convert the search term
<input>
into a controlled component, binding its value todraft
and event handling toupdateDraft
. <datalist>
is now replaced every timedraft
changes, and its values come fromsortedMruTerms
.
👩💻 Try It Out
As you can see, by abstracting the logic out from the component, we can easily tweak the behavior while minimizing changes to the component itself. We can also test the utility functions independently. Composition FTW 🙌.
Can you think of other improvements to this feature? Fork one of the CodeSandboxes and see what you come up with.
Accessibility
Weston Thayer and I had a discussion about accessibility for data list. He pointed out the following issues and resources for further investigation.
There are potentially some accessibility issues that may prevent you from using this technique, specifically screen readers do not convey data list changes. This may be acceptable in the case of auto suggest, since the user is free to enter whatever they like—the auto suggest feature is a nice-to-have.
For a React-specific alternative, check out Reach UI’s Combobox. Also read 24 Accessibility’s ”<select> Your Poision” article for in-depth discussion of why this is a hard-to-solve problem.
Other Use Cases
It occurred to me that this technique can be used in situations where you want to normalize data as much as possible, while still allowing the user to freely enter anything they like.
In a recent article, I talked about the issue of HR asking for personal pronouns. HR would like the data to be as consistent as possible, but the right thing to do is to allow people to enter whatever they want. Here's a possible implementation that fulfills both requirements:
Submitted pronoun:
Takeaways
- For combo boxes, try the built-in
<datalist>
element first. - You can dynamically generate the datalist options based on events.
- Abstract out as much logic as possible from your components for composability, testability, and agility.