javascript
56 lines · 8 steps
Building a tag input in React
A controlled React component that turns typed text into deduplicated tag chips with keyboard editing.
Explained by
highlit
1import { useState, useRef } from "react";
2
3export function TagInput({ initialTags = [], onChange }) {
4 const [tags, setTags] = useState(initialTags);
5 const [draft, setDraft] = useState("");
6 const inputRef = useRef(null);
7
8 const commit = (value) => {
9 const tag = value.trim().toLowerCase();
10 if (!tag || tags.includes(tag)) return;
11 const next = [...tags, tag];
12 setTags(next);
13 onChange?.(next);
14 };
15
16 const removeAt = (index) => {
17 const next = tags.filter((_, i) => i !== index);
18 setTags(next);
19 onChange?.(next);
20 };
21
22 const handleKeyDown = (e) => {
23 if (e.key === "Enter" || e.key === ",") {
24 e.preventDefault();
25 commit(draft);
26 setDraft("");
27 } else if (e.key === "Backspace" && draft === "" && tags.length) {
28 e.preventDefault();
29 removeAt(tags.length - 1);
30 }
31 };
32
33 return (
34 <div className="tag-input" onClick={() => inputRef.current?.focus()}>
35 {tags.map((tag, i) => (
36 <span key={tag} className="chip">
37 {tag}
38 <button type="button" aria-label={`Remove ${tag}`} onClick={() => removeAt(i)}>
39 ×
40 </button>
41 </span>
42 ))}
43 <input
44 ref={inputRef}
45 value={draft}
46 placeholder={tags.length ? "" : "Add a tag"}
47 onChange={(e) => setDraft(e.target.value)}
48 onKeyDown={handleKeyDown}
49 onBlur={() => {
50 commit(draft);
51 setDraft("");
52 }}
53 />
54 </div>
55 );
56}
01 / 01
STEP 01
‹ swipe to step through ›
Walkthrough
Space play
←→ step
click any line
Three takeaways
- 1Keeping the committed tags and the in-progress draft as separate state cleanly divides finished data from work in progress.
- 2Building a fresh array with the spread operator lets you update state and notify a parent from the same immutable value.
- 3Mapping keyboard events like Enter, comma, and Backspace onto commit and remove actions makes an input feel like a native tag editor.
Related explainers
javascript
import { NavLink, useLocation } from 'react-router-dom'; const NAV_ITEMS = [ { to: '/', label: 'Dashboard', end: true },
Building an accessible Sidebar in React
routing
accessibility
declarative-ui
Intermediate
6 steps
javascript
function escapeHtml(str) { return str.replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<',
Safely highlighting search matches in text
html-escaping
regex
search-highlighting
Intermediate
7 steps
python
from copy import deepcopy from typing import Any, Mapping
How a recursive deep merge works in Python
recursion
immutability
dictionaries
Intermediate
6 steps
javascript
import { useEffect, useRef } from "react"; import { useBlocker } from "react-router-dom"; export function useUnsavedChangesPrompt(isDirty, message = "You have unsaved changes. Leave anyway?") {
Guarding unsaved changes with a React hook
custom-hooks
navigation-guard
event-listeners
Intermediate
7 steps
javascript
function deepFreeze(obj) { const propNames = Reflect.ownKeys(obj); for (const name of propNames) {
Recursively freezing a nested object
recursion
immutability
object-freezing
Intermediate
6 steps
ruby
class QueryProxy ALLOWED = %i[where limit offset order includes distinct].freeze def initialize(relation, operations = [])
Building a lazy query proxy in Ruby
metaprogramming
method_missing
lazy-evaluation
Advanced
7 steps
Share this explainer
Here's the card — post it anywhere.
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code
Embed this explainer
Drop the interactive walkthrough into a blog or docs. Views never cost a credit.
<iframe src="https://highlit.co/explainers/building-a-tag-input-in-react-explained-javascript-74c3/embed?autoplay=1" width="100%" height="520" loading="lazy" style="border:0"></iframe>
Autoplay is on by default — add ?autoplay=0 to start paused.