Learning React & Redux in 2023 — Part 5
We’re now going to create a new project called “books”
we’ll get started by using create-react-app
npx install create-react-app books
once installed, we’ll open our project in VS Code:
firstly we’ll delete everything in the src directory, and then create an index.js file and an App.js file
index.js
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
const root = ReactDOM.createRoot(
document.getElementById('root')
);
root.render(<App />);
App.js
function App() {
return (
<div>
<p>App</p>
</div>
);
}
export default App;
if all goes well, when I do npm run start, I’ll see the following in my browser:
Our project will have multiple components -
BookList — a component that holds a list of BookShow components
BookShow — a component showing an individual Book Item
BookEdit — a component to edit a Book Item
BookCreate — a component to create a new Book Item
let’s create these four components with some boilerplate temporary code in each. We’ll start with BookCreate.js
function BookCreate() {
return (
<div>BookCreate</div>
);
}
export default BookCreate;
we’ll do the same for BookList, BookShow and BookEdit, and we’ll store them all in a new “components” folder.
Next we’ll create a piece of state in the App.js file. It makes sense to store this piece of state in App.js, because ultimately all of its child components will need access to this piece of state, and therefore all child components will re-render whenever the PARENT component (App.js) re-renders. Our piece of state will be a “books” array. We’ll start off with an empty array.
import { useState } from "react";
function App() {
const [books, setBooks] = useState([]);
return (
<div>
<p>App</p>
</div>
);
}
export default App;
Next we want to create a function that handles the event of when a new book is created, and we’ll end up using the BookCreate component for this. Let’s first of all create an event handler that will be called when this happens. We’ll call it handleCreateBook.
const handleCreateBook = (bookTitle) => {
console.log(bookTitle);
};
We’ll also add a prop to our BookCreate component which we’ll add into the BookCreate Component. We’ll call this prop “onCreateBook” which will call our handleCreateBook callback function.
import { useState } from "react";
import BookCreate from "./components/BookCreate";
function App() {
const [books, setBooks] = useState([]);
const handleCreateBook = (bookTitle) => {
console.log(bookTitle);
};
return (
<div>
<BookCreate onCreateBook={handleCreateBook}/>
</div>
);
}
export default App;
Now let’s switch to BookCreate. Firstly, we’ll use destruturing to bring in this prop of onCreateBook:
function BookCreate({ onCreateBook }) {
return (
<div>BookCreate</div>
);
}
export default BookCreate;
This component will allows users to add in new books, so it makes sense to have a form, with an input field, and a submit button. As per the previous exercise, we need to setup a form when using React in a specific way. Let’s update the BookCreate accordingly.
import { useState } from 'react';
function BookCreate({ onCreateBook }) {
const [bookTitle, setBookTitle] = useState("");
const handleChange = (event) => {
setBookTitle(event.target.value);
};
const handleSubmit = (event) => {
event.preventDefault();
onCreateBook(bookTitle);
};
return (
<form onSubmit={handleSubmit}>
<label>Enter Book Title</label>
<input value={bookTitle} onChange={handleChange}/>
<button>Create Book!</button>
</form>
);
}
export default BookCreate;
We’re going to want to add a book title to our books piece of state in App.js, so we create a piece of state in BookCreate called “bookTitle”, which has a default value of an empy string.
We create a form which includes an input field and a button. Whenever the values change in the form, we want to update our state, so we call handleChange from our onChange event handler. This calls handleChange which updates the bookTitle piece of state to be whatever the input fields event.target.value is.
Once this piece of state is updated, we then pass it as the input field “value” so that the piece of state (bookTitle) actually shows in the input field when we type.
We also handle the form when we click the button, so we use the onSubmit event handler, which calls handleSubmit, and this function calls the onCreateBook() function in App.js and passes the bookTitle piece of state to it. We also add in the line event.preventDefault() so that we avoid the default form behavior.
This is what we should now see in the browser when we type in a book name and click the button:
it would be quite nice for the input field to empty each time we click on the Create Book button, so that it’s ready for adding another book. We can do this simply by setting the piece of state back to an empty string when the form re-renders. We can therefore update handleSubmit as follows:
const handleSubmit = (event) => {
event.preventDefault();
onCreateBook(bookTitle);
setBookTitle("");
};
which empties the field after each re-render.
We can now focus on what we will do with this book title in App.js
currently our code should look like this:
import { useState } from "react";
import BookCreate from "./components/BookCreate";
function App() {
const [books, setBooks] = useState([]);
const handleCreateBook = (bookTitle) => {
console.log(bookTitle);
};
return (
<div>
<BookCreate onCreateBook={handleCreateBook}/>
</div>
);
}
export default App;
we could already see that the console log is working correctly as it’s logging the names of the books as we typed them, but what we want to do is actually add these books to the “books” piece of state.
NOTE: Whenever you deal with updating state objects or arrays, you have to be VERY careful about how you update state. If you don’t handle updating them correctly, you may find that React will not re-render components as you expect them to!
updating state objects or arrays
Adding elements to the START of a state array: (spreader)
const [colors, setColors] = useState(['red', 'green']);
const addColor = (colorToAdd) => {
const updatedColors = [colorToAdd, ...colors];
setColors(updatedColors);
};
Adding elements to the END of a state array: (spreader)
const [colors, setColors] = useState(['red', 'green']);
const addColor = (colorToAdd) => {
// Now 'colorToAdd' will be at the end
const updatedColors = [...colors, colorToAdd];
setColors(updatedColors);
};
Adding elements to a specific index of a state array: (slice)
const [colors, setColors] = useState(['red', 'green']);
const addColorAtIndex = (colorToAdd, index) => {
const updatedColors = [
...colors.slice(0, index),
colorToAdd,
...colors.slice(index),
];
setColors(updatedColors);
};
Removing specific elements by index in a state array: (filter)
const [colors, setColors] = useState(['red', 'green', 'blue']);
const removeColorAtIndex = (indexToRemove) => {
const updatedColors = colors.filter((color, index) => {
return index !== indexToRemove;
});
setColors(updatedColors);
};
Removing specific elements by value in a state array: (filter)
const [colors, setColors] = useState(['red', 'green', 'blue']);
const removeValue = (colorToRemove) => {
const updatedColors = colors.filter((color) => {
return color !== colorToRemove;
});
setColors(updatedColors);
};
Modifying objects inside a state array: (map)
const [books, setBooks] = useState([
{ id: 1, title: 'Sense and Sensibility' },
{ id: 2, title: 'Oliver Twist' },
]);
const changeTitleById = (id, newTitle) => {
const updatedBooks = books.map((book) => {
if (book.id === id) {
return { ...book, title: newTitle };
}
return book;
});
setBooks(updatedBooks);
};
Modifying object properties in state: (spreader)
const [fruit, setFruit] = useState({
color: 'red',
name: 'apple',
});
const changeColor = (newColor) => {
const updatedFruit = {
...fruit,
color: newColor,
};
setFruit(updatedFruit);
};
Removing object properties in state: (de-structuring)
const [fruit, setFruit] = useState({
color: 'red',
name: 'apple',
});
const removeColor = () => {
// `rest` is an object with all the properties
// of fruit except for `color`.
const { color, ...rest } = fruit;
setFruit(rest);
};
Now we know this, we can focus on updating the “books” piece of state in App.js. We can update the code as follows:
import { useState } from "react";
import BookCreate from "./components/BookCreate";
function App() {
const [books, setBooks] = useState([]);
const handleCreateBook = (bookTitle) => {
const updatedBooks = [...books,
{
id: Math.round(Math.random() * 99999),
title: bookTitle
},
];
console.log(bookTitle, books.length);
setBooks(updatedBooks);
};
return (
<div>
<BookCreate onCreateBook={handleCreateBook}/>
<p>Books stored so far: {books.length}</p>
</div>
);
}
export default App;
so inside handleCreateBook we’ve added a new array called upodatedBooks, and each time handleCreateBook is called, it will create a new array which will use the spreader to add in the existing state array of “books” to this new array, and then append a new object at the end, which will have a unique id and a title. We used a JavaScript function to generate a random number for the id, and passed in the bookTitle as the title.
Finally, we update the piece of state (books) by passing in this new array (updatedBooks) into setBooks.
This is what we see on screen:
notice how the console log prints the books piece of state length as 2, but the rendered component shows the length as 3?
In React, state updates are asynchronous. When you call the setBooks function to update the state with updatedBooks, React will not immediately update the books variable with the new value. Instead, React will schedule the update and perform it at a later time, during the next render cycle.
This can be demonstrated by temporarily using React’s “useEffect” for the console log of the books.length:
import { useState, useEffect } from "react";
import BookCreate from "./components/BookCreate";
function App() {
const [books, setBooks] = useState([]);
const handleCreateBook = async (bookTitle) => {
const updatedBooks = [...books,
{
id: Math.round(Math.random() * 99999),
title: bookTitle
},
];
setBooks(updatedBooks);
console.log(bookTitle, books.length);
};
useEffect(() => {
console.log(books.length);
}, [books]);
return (
<div>
<BookCreate onCreateBook={handleCreateBook}/>
<p>Books stored so far: {books.length}</p>
</div>
);
}
export default App;
The next thing to do will be to pass the list of books from App.js to the BookList component, and then map individual books from the list to individual BookShow components.
As BookList will be a CHILD of App.js, we need to import it into App and then create a “prop” for BookList that will allow us to bring in the books piece of state. We’ll call our prop “books” with a state value of books.
import { useState, useEffect } from "react";
import BookCreate from "./components/BookCreate";
import BookList from "./components/BookList";
function App() {
const [books, setBooks] = useState([]);
const handleCreateBook = (bookTitle) => {
const updatedBooks = [...books,
{
id: Math.round(Math.random() * 99999),
title: bookTitle
},
];
setBooks(updatedBooks);
console.log(bookTitle, books.length);
};
useEffect(() => {
console.log(books.length);
}, [books]);
return (
<div>
<BookList books={books}/>
<BookCreate onCreateBook={handleCreateBook}/>
</div>
);
}
export default App;
Next we’ll switch to our BookList component. We need to reference our prop:
function BookList( {books} ) {
Next we’ll create a map function that will take our array of book objects, and map each instance to a singe instance of the BookShow component. BookShow will be a CHILD of BookList so we also need to import that into BookList.
import BookShow from "./BookShow";
function BookList( {books} ) {
const renderedBooks = books.map((book) => {
return <BookShow key={book.id} book={book}/>;
});
return <div>{renderedBooks}</div>;
}
export default BookList;
so basically each instance of renderedBooks will be a book with an id that we generated, and the actual book object itself.
Finally, we’ll update BookShow to reference renderedBooks.
function BookShow( {book} ) {
return (
<div>{book.title}</div>
);
}
export default BookShow;
This is what it would look like in the browser:
Deleting Books
We’re now seeing multiple instances of BookShow being rendered to the screen, but it would be nice to be able to also delete those books. The book array is stored in App.js so ultimately we’d need to call a function to delete books from the “books” array piece of state, but in terms of actually interacting with the page in the browser, we’d probably want some kind of delete button or box to click that is associated with each BookShow component instance.
Based on this we’d need to once again pass down a “prop” to BookShow from App.js to be able to call a “delete” function.
Let’s first create a function that will actually delete books from the “books” piece of state in App.js
Because we are deleting an object from an array piece of state, we will use the filter method.
const handleDeleteBook = (id) => {
const updatedBooks = books.filter((book) => book.id !== id);
setBooks(updatedBooks);
};
So what we’re saying here is that if we pass in an id, we’ll filter to see if that id matches any id in the book state array. If it’s NOT equal to id, then filter it out of the new array.
We’ll create a “prop” for BookList that we can use in the component and we’ll call it deleteBook
import { useState, useEffect } from "react";
import BookCreate from "./components/BookCreate";
import BookList from "./components/BookList";
function App() {
const [books, setBooks] = useState([]);
const handleDeleteBook = (id) => {
const updatedBooks = books.filter((book) => book.id !== id);
setBooks(updatedBooks);
};
const handleCreateBook = (bookTitle) => {
const updatedBooks = [...books,
{
id: Math.round(Math.random() * 99999),
title: bookTitle
},
];
setBooks(updatedBooks);
console.log(bookTitle, books.length);
};
useEffect(() => {
console.log(books.length);
}, [books]);
return (
<div>
<BookList books={books} deleteBook = {handleDeleteBook}/>
<BookCreate onCreateBook={handleCreateBook}/>
</div>
);
}
export default App;
deleteBook (when called) will run the handleDeleteBook function we just created, which will update the piece of state by creating a new array and filtering out the matching id.
Now we’ll update BookList component to include this “deleteBook” prop into the function
import BookShow from "./BookShow";
function BookList( {books, deleteBook} ) {
const renderedBooks = books.map((book) => {
return <BookShow key={book.id} book={book} deleteSpecificBook={deleteBook}/>;
});
return <div>{renderedBooks}</div>;
}
export default BookList;
Now it’s not BookList that we want to interact with….it’s the child of BookList, which is BookShow, so we’ll create another prop which we’ll reference in the BookShow component. To avoid any confusion, we’ll call this new prop deleteSpecificBook, and the event handler for this prop will be the deleteBook prop from App.js
Finally, we’ll pass this new prop to BookShow, and we’ll add in a button for the component instance for a user to click on.
We’ll also add an onClick handler for the button, which will call a handleClick function:
function BookShow( {book, deleteSpecificBook} ) {
const handleClick = () => {
deleteSpecificBook(book.id);
};
return (
<div>
<div>{book.title}</div>
<button onClick={handleClick}>Delete</button>
</div>
);
}
export default BookShow;
So here we can see that when a user clicks on the Delete button, it triggers handleClick.
handleClick calls the “deleteSpecificBook” prop function from BookList, and passes the book.id value of THIS component. Remember we passed in book, so we have this specific mapped instance of a book object from the overall array of books.
Because handleClick calls deleteSpecificBook() with the value of the book id, this calls the function in BookList.
If we look back at BookList once more:
import BookShow from "./BookShow";
function BookList( {books, deleteBook} ) {
const renderedBooks = books.map((book) => {
return <BookShow key={book.id} book={book} deleteSpecificBook={deleteBook}/>;
});
return <div>{renderedBooks}</div>;
}
export default BookList;
We can see that deleteSpecificBookList will call the deleteBook function from App.js with the book.id we passed.
Now if we look once more at App.js:
import { useState, useEffect } from "react";
import BookCreate from "./components/BookCreate";
import BookList from "./components/BookList";
function App() {
const [books, setBooks] = useState([]);
const handleDeleteBook = (id) => {
const updatedBooks = books.filter((book) => book.id !== id);
setBooks(updatedBooks);
};
const handleCreateBook = (bookTitle) => {
const updatedBooks = [...books,
{
id: Math.round(Math.random() * 99999),
title: bookTitle
},
];
setBooks(updatedBooks);
console.log(bookTitle, books.length);
};
useEffect(() => {
console.log(books.length);
}, [books]);
return (
<div>
<BookList books={books} deleteBook = {handleDeleteBook}/>
<BookCreate onCreateBook={handleCreateBook}/>
</div>
);
}
export default App;
We can see that deleteBook will call the handleDeleteBook event handler, and that is what will call our function which includes the filter method for removing objects from an array. So basically, when we click on the delete button down in BookShow, we’re ultimately calling this handleDeleteBook function in the parent.
This is what we see on screen:
When you click any delete button related to a specific BookShow instance, it will call the handleDeleteBook function in App.js and delete the book object.
Editing the book Title
We can now delete a book object completely, but we’d also like to be able to edit the title of the book. The ideal scenario is that we have an edit button in front of our delete button. When a user clicks on the edit button, the component displays an editing form to allow them to edit the book title. This would basically be the BookEdit component being displayed. If they clicked the edit button once more, it would just toggle back to the book title being displayed and the BookEdit component would be hidden.
Before we actually setup a form for BookEdit, let’s first actually setup a way of toggling between showing book.title and actually showing the BookEdit component.
Book Edit will be a CHILD of BookShow, so let’s import it first.
import BookEdit from "./BookEdit";
function BookShow( {book, deleteSpecificBook} ) {
const handleClick = () => {
deleteSpecificBook(book.id);
};
return (
<div>
<div>{book.title}</div>
<button onClick={handleClick}>Delete</button>
</div>
);
}
export default BookShow;
Next, let’s create a piece of new state in BookShow that tracks if the book.title should be displayed, or if the BookEdit component should be displayed.
First we import useState from React:
import { useState } from "react";
We then create our new piece of state to track the edit status:
const [showEdit, setShowEdit] = useState(false);
So basically, this piece of state will be set to false to not be showing the BookEdit component, and if we want to show BookEdit, then it will be set to true.
Let’s now create a new button for our component, just before our delete button.
<div>
<div>{book.title}</div>
<button onClick={handleEditClick}>Edit</button>
<button onClick={handleClick}>Delete</button>
</div>
We’ll now create an event handler function for handleEditClick:
const handleEditClick = () => {
setShowEdit(!showEdit);
};
What’s happening here, is that it’s setting showEdit to be the opposite of whatever it currently is. If showEdit is false, then it will set it to true, and vice-versa, if showEdit is true, it will set it to false.
Finally, we’ll add something that will replace the current {book.title] return, based on whether showEdit is true or false.
let editState = <div>{book.title}</div>;
if (showEdit === true) {
editState = <div> <BookEdit /> </div>;
}
so we’ve created a new variable called editState with a default value of {book.title}
We’re then checking with an if statement if showEdit === true
If it is, then our editState variable will instead be set to show the BookEdit component.
We’ll then also replace the line in our return statement to instead point to editState.
Our final BookShow component will look like this:
import { useState } from "react";
import BookEdit from "./BookEdit";
function BookShow( {book, deleteSpecificBook} ) {
const [showEdit, setShowEdit] = useState(false);
const handleEditClick = () => {
setShowEdit(!showEdit);
};
let editState = <div>{book.title}</div>;
if (showEdit === true) {
editState = <div> <BookEdit /> </div>;
}
const handleClick = () => {
deleteSpecificBook(book.id);
};
return (
<div>
<div>{editState}</div>
<button onClick={handleEditClick}>Edit</button>
<button onClick={handleClick}>Delete</button>
</div>
);
}
export default BookShow;
We haven’t created our BookEdit form yet, it’s currently just boilerplate…
function BookEdit() {
return (
<div>BookEdit</div>
);
}
export default BookEdit;
So based on this, all we’d expect to see on screen at this stage is the text of the book title toggling between the name of the book, and the words “BookEdit” when we toggle with the edit button.
So when the button was clicked, the name of the book (In this case it was Harry Potter) was replaced with the BookEdit component on screen.
Now that we know the edit button is toggling between showing the book title and the actual BookEdit component, we can design the BookEdit form.
import { useState } from "react";
function BookEdit({book, editThisBook, saveThisBook}) {
const [editedBookTitle, setEditedBookTitle] = useState(book.title);
const handleSubmit = (event) => {
event.preventDefault();
editThisBook(book.id, editedBookTitle);
saveThisBook();
};
const handleChange = (event) => {
setEditedBookTitle(event.target.value);
};
return (
<div>
<form onSubmit = {handleSubmit}>
<label>New Book Title</label>
<input value = {editedBookTitle} onChange={handleChange}/>
<button>Save</button>
</form>
</div>
);
}
export default BookEdit;
We’ve created a piece of state called editedBookTitle, which will track what’s in the form. The next thing we need to do is update the code in App.js so that the editedBookTitle replaces the existing book title in the books piece of state in App.js.
BookEdit will need to be aware of this books piece of state, so similar to what we did for handleDeleteBook in App.js, we’ll create another handler called “handleEditBook” and a new prop called editBook. Let’s do this in App.js:
import { useState, useEffect } from "react";
import BookCreate from "./components/BookCreate";
import BookList from "./components/BookList";
function App() {
const [books, setBooks] = useState([]);
const handleEditBook = (id, newTitle) => {
const updatedBooks = books.map((book) => {
if (book.id === id) {
return { ...book, title: newTitle };
}
return book;
});
setBooks(updatedBooks);
};
const handleDeleteBook = (id) => {
const updatedBooks = books.filter((book) => book.id !== id);
setBooks(updatedBooks);
};
const handleCreateBook = (bookTitle) => {
const updatedBooks = [...books,
{
id: Math.round(Math.random() * 99999),
title: bookTitle
},
];
setBooks(updatedBooks);
console.log(bookTitle, books.length);
};
useEffect(() => {
console.log(books.length);
}, [books]);
return (
<div>
<BookList books={books} editBook = {handleEditBook} deleteBook = {handleDeleteBook}/>
<BookCreate onCreateBook={handleCreateBook}/>
</div>
);
}
export default App;
so we created a new handler function called handleEditBook. We will pass in the id of the book, and then the new book title. We’ll then use the special map function to update an object in a state array. This will then update our piece of state for books.
Now let’s pass down a prop so that BookEdit can call it. We created the new prop called editBook, and now we need to pass it down to BookList:
import BookShow from "./BookShow";
function BookList( {books, editBook, deleteBook} ) {
const renderedBooks = books.map((book) => {
return <BookShow key={book.id} book={book} editSpecificBook = {editBook} deleteSpecificBook={deleteBook}/>;
});
return <div>{renderedBooks}</div>;
}
export default BookList;
As this needs to pass down to the next child component (BookShow) we’ll create a new prop called editSpecificBook which utilizes the editBook prop.
Now we go down to the next child component BookShow.
Because we’ll expect the BookEdit component visibility to toggle when we click the save button (same as when it toggles for the edit button!) we’ll create a handler that can be passed down as a prop to BookEdit.
We’ll call this handleEditClick:
const handleEditClick = () => {
setShowEdit(!showEdit);
};
So when a user clicks on the save button we’ll want this to toggle.
Here’s the BookShow code in full:
import { useState } from "react";
import BookEdit from "./BookEdit";
function BookShow( {book, editSpecificBook, deleteSpecificBook} ) {
const [showEdit, setShowEdit] = useState(false);
const handleEditClick = () => {
setShowEdit(!showEdit);
};
let editState = <div>{book.title}</div>;
if (showEdit === true) {
editState = <div> <BookEdit saveThisBook = {handleEditClick} editThisBook = {editSpecificBook} book={book} /> </div>;
}
const handleClick = () => {
deleteSpecificBook(book.id);
};
return (
<div>
<div>{editState}</div>
<button onClick={handleEditClick}>Edit</button>
<button onClick={handleClick}>Delete</button>
</div>
);
}
export default BookShow;
Note the three “props” we’re passing on to the <BookEdit /> component:
saveThisBook (which calls handleEditClick)
editThisBook (which calls editSpecificBook — ultimately from App.js)
book (which allows us to update the books piece of state in App.js)
It’s important to note that we also need to pass down this “book” prop with the book object to BookEdit, so that BookEdit is aware of the id we’re dealing with…
let editState = <div>{book.title}</div>;
if (showEdit === true) {
editState = <div> <BookEdit saveThisBook = {handleEditClick} editThisBook = {editSpecificBook} book={book} /> </div>;
}
finally, inside BookEdit, we’ve de-structured book, editThisBook, and saveThisBook.
function BookEdit({book, editThisBook, saveThisBook}) {
Which now means we have visibility to the App.js books piece of state, the handleEditBook function in App.js and also the handleEditClick function in BookShow.
We now can update our handleSubmit function in BookEdit so that when we call editThisBook, we’ll pass on the book id and the new edited book title.
Also we can call saveThisBook, which will call the handlEditClick in BookShow to toggle the visibility of BookEdit when we click the save button. Here’s the final code:
import { useState } from "react";
function BookEdit({book, editThisBook, saveThisBook}) {
const [editedBookTitle, setEditedBookTitle] = useState(book.title);
const handleSubmit = (event) => {
event.preventDefault();
editThisBook(book.id, editedBookTitle);
saveThisBook();
};
const handleChange = (event) => {
setEditedBookTitle(event.target.value);
};
return (
<div>
<form onSubmit = {handleSubmit}>
<label>New Book Title</label>
<input value = {editedBookTitle} onChange={handleChange}/>
<button>Save</button>
</form>
</div>
);
}
export default BookEdit;
This is what it looks like in the browser: