Create your first React app - Part 3
We'll begin this step by adding more state to the root component.
Add this line of code to the RootComponent() function (put it just under the line that declares the 'users' state, and before the call to useEffect()):
const [selectedUserId, setSelectedUserId] = useState(-1);
We'll use 'selectedUserId' to keep track of the user that is selected (clicked on) in the list. We'll initialize it to -1, which indicates that no user has been selected from the list.
Now let's display this state in the UI by updating the returned JSX in the root component to look like this:
<div>
<h1>User Manager</h1>
<button onClick={handleAddUser}>Add User</button>
<p>Number of users: {users.length}</p>
<UserList users={users} onUserSelected={handleUserSelected} />
<p>Selected User ID: {selectedUserId}</p>
</div>
And finally, update the handleUserSelected() function to look like this:
const handleUserSelected = (userId) => {
setSelectedUserId(userId)
}
Run the app, and notice how the RootComponent re-renders when you click on an LI in the UserList. The re-render is caused by changing the selectedUserId state (by calling setSelectedUserId()). Whenever state changes in a component, React will automatically re-render it.
And now we'll create a component that allows you to edit a user. Replace the line that says // TODO: put the UserForm component here with the following code:
// The UserForm component
function UserForm({userId}){
console.log("rendering UserForm component...");
useEffect(() => {
if(userId > 0){
const user = uda.getUserById(userId);
console.log("User to display:", user)
}
}, []);
return (
<div className="user-form-container">
<h2>User Details</h2>
<p>User ID: {userId}</p>
<p>TODO: Put a FORM element here</p>
</div>
)
}
Note that the UserForm has a 'userId' prop, which will get passed in from the root component.
Also note that we are using the prop in the call to useEffect(). As a side effect (after the component initially renders), we'll use the userId prop to the data access component when we call getUserById().
Now we'll add the UserForm as a child to the root component. Update the JSX in the root component to look like this:
<div>
<h1>User Manager</h1>
<button onClick={handleAddUser}>Add User</button>
<p>Number of users: {users.length}</p>
<UserList users={users} onUserSelected={handleUserSelected} />
{selectedUserId > -1 && <UserForm userId={selectedUserId} />}
</div>
The last line of code inside the DIV element is using a common technique in React, known as conditional rendering. The UserForm will not be displayed unless the selectedUserId is greater than -1. If the expression on the left side of the && evaluates to false, then the right side will be ignored (this is known as 'short-circuiting'). You might be wondering why we used -1, rather than 0, to indicate that no user has been selected from the list. You'll see later that 0 will indicate that we want to display the UserForm so that we can add a new user.
Run the app and notice that not only the does the root component re-render when you click on an LI element (which triggers a state change in the root), but the UserForm also re-renders because it's userId prop changes. The userId prop in the UserForm is a reference to the selectedUserId state in the root component.
Like state, when a component's prop changes, React will automatically re-render it.
Here's a link with more information on how 'rendering' occurs in React
Now add the following state to the UserForm component (you can put it just before the call to useEffect()):
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [email, setEmail] = useState("");
When you add state to a component, it's usually because you want to display it in the UI, so update the JSX returned by the UserForm component to look like this:
<div className="user-form-container">
<h2>User Details</h2>
<p>User ID: {userId}</p>
<form>
<div>
<label>First Name:</label>
<input name="firstName" value={firstName} />
</div>
<div>
<label>Last Name:</label>
<input name="lastName" value={lastName}/>
</div>
<div>
<label>Email:</label>
<input name="email" value={email} />
</div>
<div>
<input type="submit" id="btnSubmit" name="submit button" />
</div>
</form>
</div>
Note that each of the text inputs are using the VALUE attribute to display one of the state variables. If you run the app now, and select a user from the UserList, you'll see the UserForm appear, but the form will be empty, because each of the state variables are initialized to empty strings. You'll also see a warning in the console log, which we'll discuss in a moment.
Now update the call to useEffect() (in the UserForm component), to look like this:
useEffect(() => {
if(userId > 0){
const user = uda.getUserById(userId);
//console.log("User to display:", user)
setFirstName(user.firstName);
setLastName(user.lastName);
setEmail(user.email);
}
}, []);
Remember, that if you want to display data from a database in your component, you should load it by calling useEffect() (this takes some getting used to, and we can discuss it in our next class meeting).
The callback function that you pass into useEffect() will be triggered after the component's initial render. In our case, the code in the callback function will check the userId prop to see if it's greater than 0. If so, it will then use the prop to fetch all the data for the user with the matching id. Once it gets the data, it uses it to set the state variables. This will force the component to re-render, and on this render, it will display the updated state variables.
Run the app and you should see that the form displays the data for the user that you select. But if you select another user from the list, the form does NOT update.
We can fix this by adding 'userId' to the empty array that we pass as the second parameter to useEffect(). Update the call to useEffect() (in the UserForm), so that it looks like this:
useEffect(() => {
if(userId > 0){
const user = uda.getUserById(userId);
//console.log("User to display:", user)
setFirstName(user.firstName);
setLastName(user.lastName);
setEmail(user.email);
}
}, [userId]); // by adding userId to the array, it will trigger the callback whenever the userId prop changes
Now re-run that app, and the UserForm should update each time you select a user from the list.
The second parameter that you pass into useEffect() is known as the dependencies array. If this array is empty, then the callback function (the first param) will be triggered only once, after the component's initial render. But by adding the userId prop to the array, the callback will be triggered any time the userId prop changes. This allows you to trigger state changes that are dependent upon prop changes (or other state changes).
Again, useEffect() is tricky to understand for React beginners. We will practice working with it a lot, and we'll discuss it a lot in class. On of the questions that I had (and still have) is that using useEffect() seems to force a component to re-render more than it should. The UserForm will render when it is first displayed, then it will re-render when the callback (the first param to useEffect()) is triggered because that code includes state changes. Why do we have to render the component twice??? A lot of developers have asked that question! Apparently, the answer is that in order to keep your component functions 'pure', you must load the data they display after the initial render (which is the reason we must call useEffect in order to load the data). But you don't need to worry about this details at this point.
Now let's discuss the warning message that you see in the console log on the UserForm's first render:
Warning: You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`..
The problem here is that if you update one of the text inputs in the form, then the associated state will not automatically change to reflect the update in the UI. Remember, the goal of 'reactivity' is that when state changes, the UI should automatically change. And when the UI changes, the corresponding state should update. In order to fix this issue, we need to add 'change' event handlers to each of the text inputs.
Update the JSX returned by the UserForm component so that it looks like this:
<div className="user-form-container">
<h2>User Details</h2>
<p>User ID: {userId}</p>
<form>
<div>
<label>First Name:</label>
<input name="firstName" value={firstName} onChange={ evt => setFirstName(evt.target.value) } />
</div>
<div>
<label>Last Name:</label>
<input name="lastName" value={lastName} onChange={ evt => setLastName(evt.target.value) }/>
</div>
<div>
<label>Email:</label>
<input name="email" value={email} onChange={ evt => setEmail(evt.target.value) } />
</div>
<div>
<input type="submit" id="btnSubmit" name="submit button" />
</div>
</form>
{JSON.stringify({firstName, lastName, email})}
</div>
Note that each event handler will update the corresponding state by calling the 'set' function and passing in the value of the event's target (hopefully you understand what evt.target is, if not we can go over it in class).
I've also added a bit of code under the FORM element so that uses JSON.stringify() to display the state variables so that we can see how they automatically get updated when the text inputs change.
By adding these event handlers that update state, we have made the UserForm a controlled component, which means that the state and the UI are automatically kept in sync (this is 'reactivity'). When I first learned about controlled components in React, I wasn't sure if they were necessary. The question that I had, was 'what if I don't want them to be synched until after I had a chance to validate the input?'. It made more sense to me update the state only when the user presses the submit button, and after the input has been validated. We'll it turns out that you can design your components to work in this way was as well, and we can explore those methods in class.
Here's an article on 'controlled' vs 'uncontrolled' components in React.
In the next part of the project, we'll learn about updating and adding users when the UserForm is submitted.
Handling form submits
Now we'll add an event handler function for when the UserForm is submitted.
To get started, add this function (to the UserForm component - you can put it just before the return statement):
const handleSubmit = (evt) => {
evt.preventDefault();
// TODO: we should validate the user input here,
// but we'll skip it for now to keep things simple
// create an 'updated' user object based on the state variables
const updatedUser = {id: userId, firstName, lastName, email};
console.log("TODO: save the updated user: ", updatedUser);
}
The code inside this function prevents the default behavior of the 'submit' event (remember that React apps are single page applications, so we don't want to send the form data to another page). Then it combines the state variables, and the userId prop into an 'updated user' object. You'll see what we do with this object in a minute. But first, hook the function up to the 'submit' event of the form by updating the opening FORM tag to look like this:
<form onSubmit={handleSubmit}>
Run the app, select a user to edit, update some of the user's information displayed in the form, then press the submit button. You should see the console log that prompts you to 'save the updated user', which is what we'll do now.
When the form is submitted, we want to notify the root component because it is managing the 'users' state. In order to allow the UserForm to notify it's parent (the root component), we'll add a prop called onUserSaved. The root component will define a function and pass it to the UserForm as the onUserSaved prop, and when the form is submitted, it will trigger the function. This is how React allows child components to communicate with parent components. The parent defines a function and passes it to the the child as a prop. The child will trigger the function at the appropriate time.
Declare the onUserSaved prop by updating the first line of the UserForm component function to look like this:
function UserForm({userId, onUserSaved}){
Now that the UserForm component has two props (userId and onUserSaved) we could add it as a child of another component like so (don't add this code because we'll use a slighlty different approach):
<UserForm userId={someUserId} onUserSaved={(updatedUser) => console.log("User to update:", updatedUser)} />
In this code sample we are passing an anonymous function as the onUserSaved prop. The UserForm could then invoke/trigger this function when it's form is submitted. This allows the parent component to 'hook into' or 'listen for' an event occurs in a child component. Also note that the anonymous function includes a parameter, which means that when the UserForm triggers it, a parameter should be passed in.
Instead of using an anonymous function, we'll create a named function in the root component, and pass it by name as the onUserSaved prop. Add this function to the RootComponent (you can put it just before the return statement):
const handleUserSaved = (updatedUser) => {
console.log("TODO: User to update:", updatedUser)
}
And now modify the JSX code in the root component that declares the UserForm so that it looks like this:
{selectedUserId > -1 && <UserForm key={selectedUserId} userId={selectedUserId} onUserSaved={handleUserSaved} />}
Just one more thing before we test these changes. Update the handleSubmit() function in the UserForm component so that it triggers the onUserSaved() function. Here's what it should look like after the update:
const handleSubmit = (evt) => {
evt.preventDefault();
// TODO: we should validate the user input, but we'll skip it
// for now to keep things simple
// create an 'updated' user object based on the state variables
const updatedUser = {id: userId, firstName, lastName, email};
//console.log("TODO: save the updated user: ", updatedUser);
onUserSaved(updatedUser);
}
Now when you run the app, the console will appear to be doing the same thing that it did the last time we ran it. But, this time the code that logs the message 'TODO: save the updated user' is in the root component. This is the component that should update changes to the 'users' array because the root component is the one that declares the 'users' state.
Finally, update the handleUserSaved() function in the root component so that it actually updates the 'users' state:
const handleUserSaved = (updatedUser) => {
//console.log("TODO: User to update:", updatedUser);
// update the database
uda.updateUser(updatedUser);
// update the 'users' state to reflect the change
setUsers(users.map(u => u.id === updatedUser.id ? updatedUser : u))
// hide the user form
setSelectedUserId(-1);
}
Here's what the finished version of the function does:
- Uses the data access component to update the database
- Updates the 'users' state by calling setUser(). This line of code may be a litte tricky if you aren't super comfortable with the map() method. We should definitely discuss this code in class (please remind me)! Remember that because the 'users' state is passed as a prop to the UserList component, when it changes, the UserList will automatically re-render to display the change.
- Remember that if there if the selectedUserId state is -1, then the UserForm will not be displayed. If a user has just been updated, we no longer need to display the UserForm. This state change (by calling setSelectedUserId()) will cause the root component to re-render, and this time the UserForm will not be displayed.
The final step to this project is to set it up so that new users can be added and deleted. We'll do that in the next part.