Handling Events, Lists & Forms
Learn how to handle user interactions, render dynamic lists, and build controlled forms in React. Master event handling patterns and form validation techniques.
In this lesson, we'll build two interactive components – a TagInput and an EditableList – as part of our growing React component library. By following along, you'll learn how to handle user events (like clicks, key presses, and form submissions), manage form input with state, and render dynamic lists of items in React. We'll start with the TagInput (for entering and removing tags), then create an EditableList that allows adding and editing list items.
In this lesson:
- Capturing form input with state in a TagInput component
- Handling keyboard and click events to add and remove tags
- Rendering lists of items with keys in React
- Building an EditableList with item addition, removal, and inline editing
- Common pitfalls to avoid when working with events, lists, and forms in React
Step-by-step: build it right
Capturing input with state in TagInput
Before we dive into events, let's set up a basic TagInput component. This component will allow a user to type text into an input and add it as a "tag" (like adding keywords). We need to capture the input's value in React state so we can use it later. In React, form inputs are usually controlled components – their value is kept in state, and updated on every change. We'll use the useState
hook to store the current text and the list of tags.
Inside our TagInput component, we declare two pieces of state: one for the current text being typed (tagInput
), and one for the array of tags added so far (tags
). We then render an <input>
field. We make this input controlled by setting its JSX value to our state and updating state on every keystroke via the onChange
event. In React's JSX, event handler attributes like onChange
or onClick
use camelCase and take a function as their value. Here's the initial setup:
Loading syntax highlighting...
In this snippet, value={tagInput}
ties the input's displayed value to our state, and onChange={handleInputChange}
ensures every change updates that state. This makes the input a controlled component – React is now the single source of truth for its value. If we didn't set value or handle onChange, the form would be uncontrolled and we'd have no way to reliably know what the user typed. By controlling it, we can react to input immediately. The handleInputChange
function simply takes the event and calls setTagInput
with the new value. At this point, typing in the box will update tagInput
state, but we haven't added any tags yet.
Note: In JSX, always pass the function itself to event handlers (e.g.
onChange={handleInputChange}
) rather than calling it. If you accidentally put parentheses (e.g.onChange={handleInputChange()}
), the function will run immediately during render instead of later on user input.
TagInput will display entered tags as small labels followed by an input field. As more tags are added, the input shifts to the right, creating a row of tags.
Handling key events to add tags
Now we want to let the user create a new tag when they press Enter. We'll handle the keyboard event on the input field. React provides event objects for key presses via the onKeyDown
or onKeyUp
props. We'll use onKeyDown
to catch the Enter key before the default browser behavior (which might submit a form) occurs. Inside the event handler, we will check if the pressed key is Enter (e.key === 'Enter'
). If so, and if there is actually text entered (we'll ignore an empty string or just whitespace), we will add the current input value to our tags list.
To add a tag to the list, we take the current tags array from state and append the new tag to it. Importantly, we do not modify the existing array directly. In React, you should treat state as immutable – instead of using methods like push()
that mutate an array, create a new array (e.g. using spread syntax or concat
) and set that as the new state. We'll use the spread operator to make a new array with the old tags plus the new tag at the end. After adding, we also clear out the tagInput
state so the input box resets for the next tag.
One more detail: pressing Enter in a text field that's inside a form can trigger an HTML form submission (which reloads the page by default). We want to prevent the default behavior in this case, since our add-tag logic replaces the need for a page reload. We can call e.preventDefault()
inside the handler to stop the default submission. Here's how we implement the key handler and tag addition:
Loading syntax highlighting...
Now our TagInput will respond when the user presses Enter. We trim the input to avoid blank spaces and ensure it's not empty. If the conditions meet, we call setTags(prev => [...prev, newTag])
. Using the functional setTags(prev => [...prev, newTag])
pattern is a safe way to get the latest state and append to it. This creates a new array with the previous tags plus the new one, rather than mutating the old array in place. After updating the tags state, we clear tagInput
by setting it to an empty string (""
), which in turn clears the input field (because the input's value is tied to tagInput
). The call to e.preventDefault()
ensures that pressing Enter doesn't have any unwanted side effects (like reloading the page, if the input was in a form).
At this stage, the new tag is in our tags state, but we haven't displayed the list of tags yet. Let's do that next.
Rendering the tag list and removing tags
To display the tags, we will render a list of elements, one for each tag in the tags
array. In React, the common pattern is to use Array.map()
to transform an array of data into an array of JSX elements. We'll map over tags
and for each tag, create a small UI element – for example, a <span>
or a list item – showing the tag text and a remove button (like an "×" icon or the word "remove").
Whenever you render a list in React, you must provide a unique key prop for each list item. Keys help React identify which items have changed or been removed, for efficient re-rendering. Here we can use the tag text as the key, assuming tags are unique. (In a real app where duplicate tags could exist, we'd use a unique id for each tag instead.) With the tags rendered, we then implement the remove functionality: when the user clicks the "remove" button on a tag, we should delete that tag from the list. We can do this by filtering it out of the tags
state array. As with adding, we'll create a new array without the removed tag and update the state with it, rather than mutating in place.
Let's add the JSX for the tag list below our input, and a handler to remove tags:
Loading syntax highlighting...
We wrapped everything in a parent <div>
and used some Tailwind classes (flex flex-wrap
) just to make tags and input wrap nicely on a line. The tags.map(...)
produces a <span>
for each tag. We include key={tag}
on the span – this key should be unique among siblings. (Each tag string is unique in our state, so it works here. In cases where items might not have a natural unique value, you should generate an id for keys instead of using the array index.) Inside each span, we show the tag text and a small <button>
with an "×". The button's onClick
uses an inline arrow function to call removeTag(tag)
. When clicked, removeTag
filters the tags state: it returns a new array containing all tags except the one to remove, and setTags
updates the state to that array. Using filter()
in this way avoids mutating the original array. The result is the tag disappears from the UI as the state updates.
At this point, our TagInput component is complete: users can type a tag and press Enter to add it to a list, and click "×" to remove any tag. We've used React state to track the input value and the list, onChange
and onKeyDown
events to handle user interactions, and rendered a dynamic list with keys.
Building an EditableList component
Next, let's build the EditableList component. This component will manage a list of items that the user can add to, remove from, and even edit in place. This is similar to a simple to-do list or any dynamic list in an app. We'll reuse many of the ideas from TagInput: handling form input, updating an array in state, and rendering list elements. Additionally, we'll introduce conditional rendering so that a list item can switch to an "edit mode" with an input field.
First, set up the state for our EditableList. We'll have an array of items in state (each item can just be a string for simplicity), and another state newItem
for the text input where the user types a new item to add. We'll also create a form with an input and Add button to handle adding new items. When the Add form is submitted, we'll prevent default page reload and append the new item to the list (very much like adding a tag earlier).
Loading syntax highlighting...
This sets up an input field and a button inside a <form>
for adding new items. The onChange
on the input updates newItem
state as the user types. On form submission (onSubmit
on the form), handleAddItem
runs: it calls e.preventDefault()
to stop the normal form submission, then trims the input and if it's not empty, updates the items
state by creating a new array with the old items plus the new text. We again use the spread operator (...prevItems
) to avoid mutating the old array. After updating state, we clear newItem
so the input box is ready for another entry.
Now, below the form, we should display the list of items. We'll use an unordered list (<ul>
) and map over items
to render each one as a <li>
. For now, let's just display the item text:
Loading syntax highlighting...
Here we're using the array index as the key for simplicity. This will work for our example, but keep in mind that using indices as keys can lead to issues if the list items are reordered or filtered, since keys would then refer to different items. In a real app, it's better to give each item a stable unique identifier (e.g. an id property). For our controlled list where items are added or removed at the end, index keys will suffice.
At this point, EditableList allows adding and listing items. Users can type a new item and click Add (or press Enter) to append it to the list. Removal and editing functionality is not in place yet, so let's add those.
Removing and editing items in the list
To allow removal of items, we can add a "Remove" button next to each list entry. We'll handle its click event by removing that item from state. Removing an item is essentially filtering it out of the array (similar to removing a tag earlier). We'll write a handleRemoveItem
function that takes an index (or an item value) and updates state by filtering out that item.
For editing an existing item's text, we'll implement an inline editing mode. The idea is: when the user clicks an "Edit" button for an item, that item's display will turn into an input field where the user can change the text, and a "Save" button to confirm. We need to track which item is currently being edited and the temporary edited text. We'll use two more pieces of state: editingIndex
(to store the index of the item being edited, or null if none) and editingText
(to store the current text in the edit input).
When the user clicks "Edit" on item i, we'll set editingIndex
to i and set editingText
to that item's current text. This puts item i into edit mode. In our list rendering, we will conditionally render each <li>
: if its index equals editingIndex
, we show an <input>
and a "Save" button; otherwise, we show the regular text and an "Edit" button. The edit input will be controlled by editingText
state, and on change it updates editingText
. When the user clicks "Save", we'll take the editingText
value and save it back into the items
list (replacing the old value at that index), then exit edit mode (set editingIndex
back to null). This demonstrates conditional rendering (showing different JSX based on state) and updating complex state (an array element update).
Let's add the new state and functions at the top of our component, and update the list rendering:
Loading syntax highlighting...
In this code, each list item conditionally renders its content based on whether it's being edited (editingIndex === index
). If yes, we show an <input>
pre-filled with editingText
and update that state on change. If not, we show a <span>
with the item text. Similarly, we toggle between a "Save" button (when editing) and an "Edit" button (when not editing). The Remove button is shown in both cases for that item. The handleRemoveItem
uses filter
to create a new array without the item at the given index (we ignore the value _
and only use the index in the filter predicate). The startEditing
function sets the editing mode: it records which item index to edit and copies that item's text into editingText
. The handleSaveEdit
function takes the edited text (after trimming whitespace) and inserts it back into the items array. We use a functional state update again: spread the old array into a new one (updated
), replace the one entry, and return the new array for setItems
. After saving, we exit edit mode by resetting editingIndex
. (We also avoid saving an empty string as an edited value by checking text === ""
.)
With these additions, EditableList now supports all operations: adding new items, clicking "Remove" to delete an item, and clicking "Edit" to modify an item's text in place. This involved handling various events: onChange
for input fields, onClick
for buttons, and even the form's onSubmit
. It also required carefully updating state, especially the array of items. By using immutable update patterns (spreading into new arrays, using filter
, etc.), we ensured we're not accidentally mutating state, which could lead to bugs. We also used keys on list elements and managed conditional rendering for the editing mode.
Best practices & common mistakes
When working with events, lists, and forms in React, keep these best practices in mind to avoid common pitfalls:
- Avoid mutating state directly. Instead of using array-mutating methods like
push()
orsplice
on state, always create a new updated array (e.g. with spread[...]
orfilter()
for removal) and pass that to the state setter. Direct mutations won't trigger re-renders and can lead to bugs. - Use unique and stable keys for list items. Keys help React identify items; using array indices or random values can cause problems if the list changes order or items are added/removed. It's better to use an item's id or unique value as the key.
- Pass event handler functions, don't call them. Ensure you write
onClick={handleRemove}
rather thanonClick={handleRemove()}
. The latter will invoke the function immediately during render instead of waiting for the event. - Prevent full page reloads on form submit. If you use a
<form>
for input (which is often convenient), remember to callevent.preventDefault()
in your submit handler to stop the browser from refreshing the page. This lets React handle the form entirely in the client. - Keep inputs controlled by state. Each form input should have a value and onChange so that React state always reflects the current input value. Forgetting this can lead to inconsistent or uncontrolled inputs that are harder to debug.
Recap
- Used useState to control form inputs and store lists of items, making the UI state-driven.
- Handled events like
onChange
,onKeyDown
,onClick
, andonSubmit
to respond to user interactions (typing, key presses, button clicks, form submission). - Added items to an array in state by creating new arrays (using spread or filter) instead of mutating, ensuring React recognizes the updates.
- Rendered dynamic lists of components using
.map()
and provided each item a unique key to help React manage re-renders. - Implemented conditional rendering for editing mode, allowing inline editing of list items by toggling between display and input elements.
With these techniques, you can build interactive form components and list UIs in React while avoiding common bugs. You've created a reusable TagInput for tagging inputs and an EditableList for managing item lists – both useful patterns in real-world applications. Happy coding!