Buy Now

Building an Email Chip Input Field with Chakra-UI

Mike Cavaliere

01/18/2022

While building the photo gallery app I cover in Cut Into The Jamstack, I needed a UI that would allow users to type or paste emails and have them validated on the client side, very much like the email chip input field in Gmail. I didn't find an existing plug-and-play one, aside from this codepen which used class components and custom styling, so I decided to build a new one with React functional components and Chakra-UI. As it turns out this was pretty easy. Here's the breakdown.

If you want to jump to the code, here's the Codesandbox for my version of the email chip input.

Creating and styling presentational components

Visually, the UI has 2 main parts:

  • A list of email "chips": highlighted emails that have been input already, with buttons to allow removal; and
  • The email form field input.

I decided to make these "dumb" or "presentational" React components; they simply accept props and render UI, without any state or logic inside them. This way the UI stays pretty separate from those other pieces (and we follow the separation of concerns principle well).

The Chip & ChipList components

Luckily, Chakra-UI already had a component that looked exactly like I wanted the Chip UI to look: the Tag component. It even has a variant with a close button, so here's what the chip component came out looking like:

/**
 * Represents an email added to the list. Highlighted with a close button for removal.
 */
export const Chip = ({ email, onCloseClick }) => (
  <Tag key={email} borderRadius="full" variant="solid" colorScheme="green">
    <TagLabel>{email}</TagLabel>
    <TagCloseButton
      onClick={() => {
        // TBD
      }}
    />
  </Tag>
);

To contain the chips, Chakra-UI has a lot of container components like Flex and Stack. In this case I want the chips to wrap to the next line if there are too many, so I used the Wrap component:

/**
 * A horizontal stack of chips. Like a Pringles can on its side.
 */
export const ChipList = ({ emails = [], onCloseClick }) => (
  <Wrap spacing={1} mb={3}>
    {emails.map((email) => (
      <Chip email={email} key={email} onCloseClick={onCloseClick} />
    ))}
  </Wrap>
);

The ChipEmailInput component

And the input field just gets wrapped in a Box:

/**
 * Form field wrapper.
 */
export const ChipEmailInput = ({ ...rest }) => (
  <Box>
    <Input type="email" {...rest} />
  </Box>
);

Stateless utility functions

That's the presentation; now let's make our code do stuff.

Our EmailChipInput component will take one prop for an initial list of emails, in case we want to prepopulate the list.

export const EmailChipInput = ({ initialEmails = [] }) => {
  // ...
}

We'll need a few utility functions. The first one, isValidEmail() can live outside the component since it doesn't have any state dependencies.

const EMAIL_REGEXP = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const isValidEmail = (email) => EMAIL_REGEXP.test(email);

The above regexp is one I grabbed from online, since email validation is so common. PSA: if you aren't comfortable with regular expressions, please start learning them now 😀 They'll change your life.

The other utility functions will be dependent on what's in our component state. So let's define the state we need.

Adding state

The only two pieces of UI state we care about are:

  • The current text in the input field; and
  • The current list of emails.

So we'll have two useState calls for these:

  const [inputValue, setInputValue] = useState("");
  const [emails, setEmails] = useState(initialEmails);

Stateful utility functions

Now we can define our state-dependent utility functions:

  // Checks whether we've added this email already.
  const emailChipExists = (email) => emails.includes(email);

  // Add an email to the list, if it's valid and isn't already there.
  const addEmails = (emailsToAdd) => {
    const validatedEmails = emailsToAdd
      .map((e) => e.trim())
      .filter((email) => isValidEmail(email) && !emailChipExists(email));

    const newEmails = [...emails, ...validatedEmails];

    setEmails(newEmails);
    setInputValue("");
  };

  // Remove an email from the list.
  const removeEmail = (email) => {
    const index = emails.findIndex((e) => e === email);
    if (index !== -1) {
      const newEmails = [...emails];
      newEmails.splice(index, 1);
      setEmails(newEmails);
    }
  };

That covers adding and removing the emails from the list, and tangentially clearing the input field when an email is added. If we wanted to get really picky about separation of concerns here we could have the setInputValue("") call in a useEffect since it's a side effect; for now this is fine IMO.

Event handlers

There are a few events we care about for the input field:

  • As the user is typing, we want to save the current form field value in state.
  • If they press Enter, Tab or a comma, we want to validate what's in the email field and add it to the list.
  • If they paste text in the field, we want to split it by commas, and validate and add each email they've pasted.

To achieve this we'll add event handlers for onChange, onKeyDown, and onPaste respectively. These will use the state and utility functions we've already defined.

  // Save input field contents in state when changed.
  const handleChange = (e) => {
    setInputValue(e.target.value);
  };

  // Validate and add the email if we press tab, enter or comma.
  const handleKeyDown = (e) => {
    if (["Enter", "Tab", ","].includes(e.key)) {
      e.preventDefault();

      addEmails([inputValue]);
    }
  };

  // Split and add emails when pasting.
  const handlePaste = (e) => {
    e.preventDefault();

    const pastedData = e.clipboardData.getData("text");
    const pastedEmails = pastedData.split(",");
    addEmails(pastedEmails);
  };

Handling email removal

We do need one last bit of functionality; when the user clicks the close button on a chip, we want to remove that email from the list. This requires a few more steps, because the handler for this will use the state functions (defined in the EmailChipInput component) but the actual click will be two levels down inside the Chip component.

There are at least two ways to pass a function to a component that is nested several levels down:

  • Using the React Context API
  • Using prop drilling

I opted to use prop drilling since the context API would be overkill in this case. So we've defined an onCloseClick prop on both the Chip component and ChipList. So if you notice in the ChipList we pass on the onCloseClick prop to the Chip component which is all the prop drilling we need:

export const ChipList = ({ emails = [], onCloseClick }) => (
  <Wrap spacing={1} mb={3}>
    {emails.map((email) => (
      <Chip email={email} key={email} onCloseClick={onCloseClick} />
    ))}
  </Wrap>
);

The rest is just defining the handler at the top level:

  const handleCloseClick = (email) => {
    removeEmail(email);
  };

Rendering

The return value of EmailChipInput is straightforward. It renders the ChipList and the ChipEmailInput inside a React fragment, passing in the state and handlers we've defined.

  return (
    <>
      <ChipList emails={emails} onCloseClick={handleCloseClick} />

      <ChipEmailInput
        placeholder="enter emails"
        onPaste={handlePaste}
        onKeyDown={handleKeyDown}
        onChange={handleChange}
        value={inputValue}
      />
    </>
  );

Boom, we've got a nice email chip. Full runnable code sandbox is here.


Supported by

eb-logo-gradient-black-textCreated with Sketch.

© 2022 Mike Cavaliere. All rights reserved

TwitterMediumLinkedIn