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.