Introduction
In this article, we will learn how to implement forms with React. When building a real application with React, it's important to interact with users. Forms are the most common solution to request information from our users in the browser. However, due to the declarative nature of the library, handling input fields and other form elements is not always straightforward. Therefore, we will learn how to use uncontrolled and controlled components to handle forms in the following sections.
Uncontrolled Components
Uncontrolled components are similar to standard HTML form inputs where you can't directly manage the value. Instead, the value is managed by the DOM, but you can access this value using a React reference.
import { useState, ChangeEvent, MouseEvent } from 'react'
const Uncontrolled = () => {
const [value, setValue] = useState('')
return (
<form>
<input type="text" />
<button>Submit</button>
</form>
)
}
export default Uncontrolled
If we execute the previous excerpt in the browser, we will see an input field where we can type something and a clickable button. This is due to the use of an uncontrolled component, where we do not define the value of the input field, but let the component handle its own internal state. However, we probably want to do something with the value of the element when the Submit button is clicked. For example, we may want to send the data to an API.
We can easily do this by adding an onChange listener.
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
console.log(e.target.value)
}
When an event is captured by the event listener, it is accompanied by an object containing information about the event. We focus on the value of the field that generated this event, which is represented by the event target. To begin with, we simply record this value, as it is important to proceed step by step. However, we plan to soon store this value in the state.
return (
<form>
<input type="text" onChange={handleChange} />
<button>Submit</button>
</form>
)
If we publish the component in the browser and enter the word "React" in the form field, we can see something similar to the following in the console:
R
Re
Rea
Reac
React
Every time the input value changes, the listener is triggered. So, our function is called once for every character the user types. The next step is to store the value entered by the user and make it available to the user when they click the "Submit" button.
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value)
}
Detecting the form submission is very similar to listening to the input field change event. Both are events that are triggered by the browser when an action is performed. We will now define a function, where we will simply save the entered value. In a real scenario, you can send the data to an API endpoint or pass it to another component. This function is called handleSubmit.
const handleSubmit = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
console.log(value)
}
This handler is quite simple; we simply save the currently stored value in the state. We also want to override the default browser behavior when the form is submitted, in order to perform a custom action. This approach seems reasonable and works well for a single field. However, what if we have multiple fields? Let's say we have dozens of different fields?
Let's start with a basic example, where we manually create each field and handler, and then see how we can improve this approach by applying different levels of optimization. We will create a new form with first name and last name fields:
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
Nous initialisons les deux champs à l'intérieur de l'état et nous définissons également un gestionnaire d'événements pour chacun des champs. Cependant, cette approche ne fonctionne pas très bien lorsqu'il y a beaucoup de champs. Il est donc important de bien comprendre le problème avant de passer à une solution plus flexible. Maintenant, nous allons implémenter les nouveaux gestionnaires :
const handleChangeFirstName = ({ target: { value } }) => {
setFirstName(value)
}
const handleChangeLastName = ({ target: { value } }) => {
setLastName(value)
}
We also need to make some modifications to the submission handler so that it displays the first name and last name when it is clicked:
const handleSubmit = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault()
console.log(`${firstName} ${lastName}`)
}
Finally:
return (
<form onSubmit={handleSubmit}>
<input type="text" onChange={handleChangeFirstName} />
<input type="text" onChange={handleChangeLastName} />
<button>Submit</button>
</form>
)
We're ready: if we run the previous component in the browser, we will see two fields. If we type into the first and second field, we will see the full name displayed in the browser console when the form is submitted.
Again, this works well and we can do interesting things this way. However, it becomes difficult to manage when we have complex scenarios without forcing us to write a lot of boilerplate code. Let's see how we can optimize this a bit. Our goal is to use a single change handler so that we can add an arbitrary number of fields without creating new listeners. Let's go back to the component and modify our state:
const [values, setValues] = useState({ firstName: '', lastName: '' })
It's still possible that we may want to initialize the values, and later in this section, we will see how to provide pre-filled values for the form. However, the interesting part is how we can modify the implementation of the handler to make it flexible and applicable to different domains:
const handleChange = ({ target: { name, value } }) => {
setValues({
...values,
[name]: value
})
}
As we saw earlier, the target property of the event we receive represents the input field that triggered the event. So we can use the name of the field and its value as variables. We then need to set the name for each field:
return (
<form onSubmit={handleSubmit}>
<input
type="text"
name="firstName"
onChange={handleChange}
/>
<input
type="text"
name="lastName"
onChange={handleChange}
/>
<button>Submit</button>
</form>
)
Exactly! Now we can add as many fields as we want without having to create new handlers. This makes our form much more flexible and easy to manage.
Controlled Components
A controlled component is a React component that controls the input element values in a form using the component's state. In this section, we will explore how we can pre-fill form fields with specific values that we can receive from the server or parent props. To fully understand this concept, we will start with a simple functional component and add functionality to it as we go.
const Controlled = () => (
<form>
<input type="text" value="Hello React" />
<button>Submit</button>
</form>
)
If we run this component in the browser, we notice that it displays the default value as expected, but does not allow us to change the value or enter anything else inside. The reason is that in React, we declare what we want to see on the screen, and setting an attribute to a fixed value always results in rendering that value, regardless of any other actions taken. This is probably not a behavior we want in a real-world application.
If we open the console, we get the following error message: "You provided a value prop to a form field without an onChange handler. This will render a read-only field." React is telling us that we are doing something wrong.
If we simply want the input field to have a default value and be able to change it by typing, we can use the defaultValue property.
import { useState } from 'react'
const Controlled = () => {
return (
<form>
<input type="text" defaultValue="Hello React" />
<button>Submit</button>
</form>
)
}
export default Controlled
In this way, the field will display with the default value when it is rendered, but the user will then be able to enter anything inside and change its value. Now, let's add some state:
const [values, setValues] = useState({ firstName: 'Carlos', lastName: 'Santana' })
The handlers remain the same as before:
const handleChange = ({ target: { name, value } }) => {
setValues({
[name]: value
})
}
const handleSubmit = (e) => {
e.preventDefault()
console.log(`${values.firstName} ${values.lastName}`)
}
In fact, we will use the values of the input fields to set their initial as well as updated values:
return (
<form onSubmit={handleSubmit}>
<input
type="text"
name="firstName"
value={values.firstName}
onChange={handleChange}
/>
<input
type="text"
name="lastName"
value={values.lastName}
onChange={handleChange}
/>
<button>Submit</button>
</form>
)
The first time the form is rendered, React uses the initial state values as input field values. When the user types something into the field, the handleChange function is called, and the new field value is stored in the state.
When the state changes, React re-renders the component and uses the new input field values again. We now have full control over the field values, and we call this pattern controlled components.
Handling Events
Events work slightly differently across browsers. React tries to abstract the way events work and provide developers with a consistent interface to handle events. This is a nice feature of React because we can forget about the targeted browsers and write vendor-independent event handlers and functions.
To provide this feature, React introduced the concept of synthetic events. A synthetic event is an object that wraps the original event object provided by the browser, and it has the same properties, regardless of where it is created. To attach an event listener to a node and get the event object when the event is triggered, we can use a simple convention that resembles how events are attached to DOM nodes. In fact, we can use the camelCase event name (e.g., onClick
) to define the callback to be triggered when the events occur. A common convention is to name event handler functions after the event name and prefix them using handle
(e.g., handleClick
).
We saw this pattern in action in the previous examples, where we were listening to the onChange
event of form fields. Let's take a basic event listener example to see how we can organize multiple events in the same component in a more pleasant way. We'll implement a simple button and start as usual by creating a component:
const Button = () => {
}
export default Button
Next, we define the event handler:
const handleClick = (syntheticEvent) => {
console.log(syntheticEvent instanceof MouseEvent)
console.log(syntheticEvent.nativeEvent instanceof MouseEvent)
}
As you can see here, we are doing a very simple thing: we are just checking the type of event object we are receiving from React and the type of native event attached to it. We expect the former to return false and the latter to return true. You should never need to access the original native event, but it's good to know that you can if you need to. Finally, we define the button with the onClick attribute to which we attach our event handler:
return (
<button onClick={handleClick}>Click me!</button>
)
Now suppose we wanted to attach a second handler to the button that listens to the double-click event. One solution is to create a separate new handler and attach it to the button using the onDoubleClick attribute, like this:
<button
onClick={handleClick}
onDoubleClick={handleDoubleClick}
>
Click me!
</button>
⚠️ Let's not forget that our goal is still to write less boilerplate code and avoid code duplication. For this reason, a common practice is to write a single event handler for each component that can trigger different actions depending on the type of event.
const handleEvent = (event) => {
switch (event.type) {
case 'click':
console.log('clicked')
break
case 'dblclick':
console.log('double clicked')
break
default:
console.log('unhandled', event.type)
}
}
The generic event handler receives the event object and triggers the appropriate action based on the event type. This is particularly useful if we want to call a function on every event (e.g., for analytics) or if certain events share the same logic. Finally, we attach the new event listener to the onClick and onDoubleClick attributes, respectively:
return (
<button
onClick={handleEvent}
onDoubleClick={handleEvent}
>
Click me!
</button>
)
From now on, when we need to create a new event handler for the same component, instead of creating a new method and binding it, we can simply add a new case to the switch statement. It is important to note that synthetic events in React are reused, and there is only one global handler. This means that we cannot store a synthetic event and reuse it later because it becomes null right after the action. While this is very efficient in terms of performance, it can be problematic if we want to store the event in the component state for some reason. To solve this problem, React provides us with a method on synthetic events that we can call to make the event persistent so that we can store and retrieve it later (persist).
The second interesting implementation detail concerns performance again and relates to how React attaches event handlers to the DOM. Every time we use the on attribute, we describe to React the behavior we want, but the library does not attach the actual event handler to the underlying DOM nodes. Instead, it attaches a single event handler to the root element, which listens to all events, thanks to event bubbling. When an event that interests us is triggered by the browser, React calls the handler on specific components on its behalf. This technique is called event delegation and is used to optimize memory and speed.
Exploring refs
React is appreciated for its declarative nature, which allows us to simply describe what should be displayed on the screen and let React handle communications with the browser. However, in some cases, it may be necessary to access the underlying DOM nodes to perform some imperative operations. While this should be avoided in most cases, it is important to know how it works in order to make the right decision.
Suppose we want to create a simple form with an input field and a button, and we want the input field to be focused when the button is clicked. To do this, we need to call the method on the input node, which is the actual DOM instance, in the browser window. So, let's create a component called "Focus".
import { useRef } from 'react'
const Focus = () => {
const inputRef = useRef(null)
}
export default Focus
Then, we implement the click method:
const handleClick = () => {
inputRef.current.focus()
}
As you can see, we reference the current attribute of inputRef and call the focus method on it:
return (
<>
<input
type="text"
ref={inputRef}
/>
<button onClick={handleClick}>Set Focus</button>
</>
)
The core of the logic in this component is the definition of a function on the ref attribute of the input element inside the form. This function is called when the component is mounted, and the element parameter represents the DOM instance of the input. It is important to note that when the component is unmounted, the same callback is called with a null parameter to free up memory.
In this callback, we store the reference to the element so we can use it later. Then we have the button with its event handler. If we run this code in a browser, the form with the input field and button will be displayed, and clicking the button will focus the input field, as we intended.
It's important to note that the use of refs should be avoided as much as possible, as it forces the code to be more imperative and can make the code harder to read and maintain.