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
function evaluate(expression) { const tokens = tokenize(expression); let pos = 0;
Building a recursive descent calculator
parsing
recursion
operator-precedence
Intermediate
8 steps
typescript
import { Injectable, effect, signal, computed } from '@angular/core'; interface Preferences { theme: 'light' | 'dark';
A signal-based preferences store in Angular
signals
state-management
persistence
Intermediate
7 steps
javascript
import { useState, useEffect, useCallback, useRef } from 'react'; const cache = new Map(); const inflight = new Map();
Building a stale-while-revalidate hook in React
caching
request-deduplication
custom-hooks
Advanced
10 steps
typescript
import { useEffect, useState } from "react"; interface Section { id: string;
Building a scroll-spy hook in React
custom-hooks
intersectionobserver
dom-observation
Intermediate
8 steps
go
package logging import ( "context"
Deduplicating log attributes in Go's slog
decorator-pattern
structured-logging
immutability
Intermediate
8 steps
javascript
import { useEffect, useRef, useState } from 'react'; export function useDelayedFlag(active, delay = 300) { const [visible, setVisible] = useState(false);
Delaying a loading spinner with a React hook
custom-hooks
debouncing
cleanup
Intermediate
8 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.