Building a Tag input using Set
Tags: react, javascript
Published: 2021-02-12 03:39:19

A **tag input** is a component that adds a new tag when a separator is typed (Usually spacebar or comma). It doesn't accept duplicates, which is really annoying to handle when using an array. Fret not, Set's here to help us.

## Creating the Tag component
This one's a bit simple. A stateless component that receives two props: `readOnly`, that toggles the delete button display, and `onRemove`, which is a function to be called when that button is clicked. The final result looks like this:
```jsx
interface TagProps {
  readOnly?: boolean;
  onRemove?: () => void;
}

const Tag: FC<TagProps> = ({ readOnly = false, onRemove, children }) => (
  <div className="tag">
    <small>{children}</small>
    {!readOnly && (
      <button onClick={onRemove}>
        <CloseIcon />
      </button>
    )}
  </div>
);
```

## Creating the input
Now here comes the fun part: the tag input itself. The component should: Render all tags and a text field, add a new tag when a certain separator character is typed, discard duplicate tags and, optionally, remove the latest tag by pressing backspace. After enlisting its expected behavior, things get way easier (That's why unit testing is so useful).

### The foundation
Let's start from the beginning: Props. `value` (defaults to a new instance of Set) and `onChange`, making the component controllable, `separator` (Defaults to `","`) which triggers the tag addition, and `readOnly` (defaults to `false`).

Next up, I'll create the component state. To control the `tags`, a **Set** is the perfect fit, since it assures that all values are **unique**, an array could also be used, but we would have to manually handle repeated tags. Coming next, we should add the text field, I'll remove all of its styling and style the parent `div` instead, since I want the final result to look like an usual input.
```jsx
interface TagInputProps {
  onChange: (value: Set<string>) => void;
  value?: Set<string>;
  separator?: string;
  readOnly?: boolean;
}

const TagInput: FC<TagInputProps> = ({
  onChange,
  value = new Set(),
  separator = ",",
  readOnly = false
}) => {
  const [tags, setTags] = useState(value);
  const [inputValue, setInputValue] = useState("");

  return (
    <div className="tag-input">
      <input 
        value={inputValue} 
        onChange={({ target }) => setInputValue(target.value)}
      />
    </div>
  );
};
```

### Adding tags
To effectively add tags to the state we're going to handle the `onKeyDown` event. If the pressed key (`event.key`) is equal to the separator and `inputValue` isn't empty, we'll add its value to `tags`. Translating into code:
```jsx
const handleKeyDown = useCallback(
  (event) => {
    if (event.key === separator) {
      event.preventDefault();
      if (inputValue.trim().length > 0) {
        setTags((tags) => new Set([...tags, inputValue]));
        setInputValue("");
      }
    }
  },
  [separator, inputValue]
);
```

### Removing tags
Deleting a tag can be done in two ways: By clicking its remove button or by pressing backspace if the cursor 
 of the text field is in position 0.

#### Remove button
No mystery here. Just add a function that removes an item from the `tags` set to the `onRemove` prop on `Tag` component:
```jsx
onRemove={() => {
  const updatedTags = new Set(tags);
  updatedTags.delete(tag);
  setTags((tags) => updatedTags);
}}
```
#### Pressing backspace
Once again, we are going to visit `handleKeyDown`. First, we check if the prop `backspaceErase` is set to `true`, then if Backspace was pressed, then if the caret is at position 0, but how? By using `selectionStart` and `selectionEnd` from `event.target`: If no text is selected and cursor is at the start, both properties are going to be `0`, now, `handleKeyDown` will look like this:
```jsx
const handleKeyDown = useCallback(
  (event) => {
    if (event.key === separator) {
      event.preventDefault();
      if (inputValue.trim().length > 0) {
        setTags((tags) => new Set([...tags, inputValue]));
        setInputValue("");
      }
    }
    if (backspaceErase && event.key === "Backspace") {
      const { selectionStart, selectionEnd } = event.target;
      if (selectionStart === 0 && selectionEnd === 0) {
        setTags((tags) => new Set([...tags].slice(0, -1)));
      }
    }
  },
  [separator, inputValue]
);
```
## The result
Well, that was easier than I thought it would be. The result can be seen in the code sandbox below, where I also added a `calcInputWidth` function to dynamically resize the input to break lines only if the text couldn't fit on it. Cheers! See you in the next article!
{% codesandbox hardcore-mountain-0uegj module=/src/components/TagInput/index.tsx %}