- Write
fetch
requests forGET
,POST
,PATCH
, andDELETE
- Initiate
fetch
requests with theuseEffect
hook - Initiate
fetch
requests from user events - Update state and trigger a re-render after receiving a response to the
fetch
request - Perform CRUD actions on arrays in state
Up to this point, we've seen how to use fetch
in a React application for some
common single-page application patterns, such as:
- Requesting data from a server when our application first loads
- Persisting data to a server when a user submits a form
In both of those cases, our workflow in React follows a similar pattern:
- When X event occurs (our application loads, a user submits a form)
- Make Y fetch request (GET, POST)
- Update Z state (add all items to state, add a single item to state)
In this codealong lesson, we'll get more practice following this pattern to
build out all four CRUD actions to work with both our server-side data (the
database; in our case, the db.json
file) as well as our client-side data
(our React state). We'll be revisiting the shopping list application from the
previous module, this time using json-server
to create a RESTful API which we
can interact with from React.
To get started, let's install our dependencies:
$ npm install
Then, to run json-server
, we'll be using the server
script in the
package.json
file:
$ npm run server
This will run json-server
on http://localhost:4000.
Before moving ahead, open
http://localhost:4000/items in the browser and
familiarize yourself with the data. What are the important keys on each object?
Leave json-server
running. Open a new terminal, and run React with:
$ npm start
View the application in the browser at
http://localhost:3000. We don't have any data to
display yet, but eventually, we'll want to display the list of items from
json-server
in our application and be able to perform CRUD actions on them.
Take some time now to familiarize yourself with the components in the
src/components
folder. Which components are stateful and why? What does our
component hierarchy look like?
Once you've familiarized yourself with the starter code, let's start building out some features!
Our first goal will be to display a list of items from the server when the application first loads. Let's see how this goal fits into this common pattern for working with server-side data in React:
- When X event occurs (our application loads)
- Make Y fetch request (GET /items)
- Update Z state (add all items to state)
With that structure in mind, our first step is to identify which component triggers this event. In this case, the event isn't triggered by a user interacting with a specific DOM element. We want to initiate the fetch request without making our users click a button or anything like that.
So the event we're looking for is a side-effect of a component being
rendered. Which component? Well, we can make that determination by looking at
which state we're trying to update. In our case, it's the items
state which is
held in the ShoppingList
component.
We can call the useEffect
hook in the ShoppingList
component to initiate our
fetch
request. Let's start by using console.log
to ensure that our syntax is
correct, and that we're fetching data from the server:
// src/components/ShoppingList.js
// import useEffect
import React, { useEffect, useState } from "react";
// ...rest of imports
function ShoppingList() {
const [selectedCategory, setSelectedCategory] = useState("All");
const [items, setItems] = useState([]);
// Add useEffect hook
useEffect(() => {
fetch("http://localhost:4000/items")
.then((r) => r.json())
.then((items) => console.log(items));
}, []);
// ...rest of component
}
Check your console in the browser — you should see an array of objects representing each item in our shopping list.
Now all that's left to do is to update state, so that React will re-render our
components and use the new data to display our shopping list. Our goal here is
to replace our current items
state, which is an empty array, with the new
array from the server:
// src/components/ShoppingList.js
function ShoppingList() {
const [selectedCategory, setSelectedCategory] = useState("All");
const [items, setItems] = useState([]);
// Update state by passing the array of items to setItems
useEffect(() => {
fetch("http://localhost:4000/items")
.then((r) => r.json())
.then((items) => setItems(items));
}, []);
// ...rest of component
}
Check your work in the browser and make sure you see the list of items. Which component is responsible for rendering each item from the list of items in state?
To recap:
- When X event occurs
- Use the
useEffect
hook to trigger a side-effect in theShoppingList
component after the component first renders
- Use the
- Make Y fetch request
- Make a
GET
request to/items
to retrieve a list of items
- Make a
- Update Z state
- Replace our current list of items with the new list
Our next goal will be to add a new item to our database on the server when a user submits the form. Once again, let's plan out our steps:
- When X event occurs (a user submits the form)
- Make Y fetch request (POST /items with the new item data)
- Update Z state (add a new item to state)
To tackle the first step, we'll need to identify which component triggers the
event. In this case, the form in question is in the ItemForm
component.
Let's start by handling the form submit
event in this component and access
the data from the form inputs, which are saved in state:
// src/components/ItemForm.js
function ItemForm() {
const [name, setName] = useState("");
const [category, setCategory] = useState("Produce");
// Add function to handle submissions
function handleSubmit(e) {
e.preventDefault();
console.log("name:", name);
console.log("category:", category);
}
return (
// Set up the form to call handleSubmit when the form is submitted
<form className="NewItem" onSubmit={handleSubmit}>
{/** ...form inputs here */}
</form>
);
}
One step down, two to go! Next, we need to determine what data needs to be sent
to the server with our fetch
request. Our goal is to create a new item, and it
should have the same structure as other items on the server. So we'll need to
send an object that looks like this:
{
"name": "Yogurt",
"category": "Dairy",
"isInCart": false
}
Let's create this item in our handleSubmit
function using the data from the
form state:
// src/components/ItemForm.js
function handleSubmit(e) {
e.preventDefault();
const itemData = {
name: name,
category: category,
isInCart: false,
};
console.log(itemData);
}
Check your work in the browser again and make sure you are able to log an item
to the console that has the right key/value pairs. Now, on to the fetch
!
function handleSubmit(e) {
e.preventDefault();
const itemData = {
name: name,
category: category,
isInCart: false,
};
fetch("http://localhost:4000/items", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(itemData),
})
.then((r) => r.json())
.then((newItem) => console.log(newItem));
}
Recall that to make a POST
request, we must provide additional options along
with the URL when calling fetch
: the method
(HTTP verb), the headers
(specifying that we are sending a JSON string in the request), and the body
(the stringified object we are sending). If you need a refresher on this syntax,
check out the MDN article on Using Fetch.
Try submitting the form once more. You should now see a new item logged to the
console that includes an id
attribute from the server. You can also verify the
object was persisted by refreshing the page in the browser and seeing the new
item at the bottom of the shopping list.
However, our goal isn't to make our users refresh the page to see their newly created item — we want it to show up as soon as it's been persisted. So we have one more step left: updating state.
For this final step, we need to consider:
- Which component owns the state that we're trying to update?
- How can we get the data from the
ItemForm
component to the component that owns state? - How do we correctly update state?
For the first question, we're trying to update state in the ShoppingList
component. Our goal is to display the new item in the list alongside the other
items, and this is the component that is responsible for that part of our
application. Since the ShoppingList
component is a parent component to the
ItemForm
component, we'll need to pass a callback function as a prop so
that the ItemForm
component can send the new item up to the ShoppingList
.
Let's add a handleAddItem
function to ShoppingList
, and pass a reference to
that function as a prop called onAddItem
to the ItemForm
:
// src/components/ShoppingList.js
function ShoppingList() {
const [selectedCategory, setSelectedCategory] = useState("All");
const [items, setItems] = useState([]);
useEffect(() => {
fetch("http://localhost:4000/items")
.then((r) => r.json())
.then((items) => setItems(items));
}, []);
// add this function!
function handleAddItem(newItem) {
console.log("In ShoppingList:", newItem);
}
function handleCategoryChange(category) {
setSelectedCategory(category);
}
const itemsToDisplay = items.filter((item) => {
if (selectedCategory === "All") return true;
return item.category === selectedCategory;
});
return (
<div className="ShoppingList">
{/* add the onAddItem prop! */}
<ItemForm onAddItem={handleAddItem} />
<Filter
category={selectedCategory}
onCategoryChange={handleCategoryChange}
/>
<ul className="Items">
{itemsToDisplay.map((item) => (
<Item key={item.id} item={item} />
))}
</ul>
</div>
);
}
Then, we can use this prop in the ItemForm
to send the new item up to the
ShoppingList
when we receive a response from the server:
// src/components/ItemForm.js
// destructure the onAddItem prop
function ItemForm({ onAddItem }) {
const [name, setName] = useState("");
const [category, setCategory] = useState("Produce");
function handleSubmit(e) {
e.preventDefault();
const itemData = {
name: name,
category: category,
isInCart: false,
};
fetch("http://localhost:4000/items", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(itemData),
})
.then((r) => r.json())
// call the onAddItem prop with the newItem
.then((newItem) => onAddItem(newItem));
}
// ...rest of component
}
Check your work by submitting the form once more. You should now see the new
item logged to the console, this time from the ShoppingList
component. We're
getting close! For the last step, we need to call setState
with a new array
that has our new item at the end. Recall from our lessons on working with arrays
in state that we can use the spread operator to perform this action:
// src/components/ShoppingList.js
function handleAddItem(newItem) {
setItems([...items, newItem]);
}
Now each time a user submits the form, a new item will be added to our database and will also be added to our client-side state, so that the user will immediately see their item in the list.
Let's recap our steps here:
- When X event occurs
- When a user submits the
ItemForm
, handle the form submit event and access data from the form using state
- When a user submits the
- Make Y fetch request
- Make a
POST
request to/items
, passing the form data in the body of the request, and access the newly created item in the response
- Make a
- Update Z state
- Send the item from the fetch response to the
ShoppingList
component, and set state by creating a new array with our current items from state, plus the new item at the end
- Send the item from the fetch response to the
Phew! This is a good time to take a break before proceeding — we've got a few more steps to cover. Once you're ready and recharged, we'll dive back in.
For our update action, we'd like to give our users the ability to keep track of which items from their shopping list they've added to their cart. Once more, we can outline the basic steps for this action like so:
- When X event occurs (a user clicks the Add to Cart button)
- Make Y fetch request (PATCH /items)
- Update Z state (update the
isInCart
status for the item)
From here, we'll again need to identify which component triggers the event.
Can you find where the "Add to Cart" button lives in our code? Yep! It's in the
Item
component. We'll start by adding an event handler for clicks on the
button:
// src/components/Item.js
function Item({ item }) {
// Add function to handle button click
function handleAddToCartClick() {
console.log("clicked item:", item);
}
return (
<li className={item.isInCart ? "in-cart" : ""}>
<span>{item.name}</span>
<span className="category">{item.category}</span>
{/* add the onClick listener */}
<button
className={item.isInCart ? "remove" : "add"}
onClick={handleAddToCartClick}
>
{item.isInCart ? "Remove From" : "Add to"} Cart
</button>
<button className="remove">Delete</button>
</li>
);
}
Check your work by clicking this button for different items — you should see
each item logged to the console. We can access the item
variable in the
handleAddToCartClick
function thanks to JavaScript's scope rules.
Next, let's write out our PATCH
request:
// src/components/Item.js
function handleAddToCartClick() {
// add fetch request
fetch(`http://localhost:4000/items/${item.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
isInCart: !item.isInCart,
}),
})
.then((r) => r.json())
.then((updatedItem) => console.log(updatedItem));
}
Just like with our POST
request, we need to specify the method
, headers
,
and body
options for our PATCH
request as well. We also need to include the
item's ID in the URL so that our server knows which item we're trying to update.
Since our goal is to let users add or remove items from their cart, we need to
toggle the isInCart
property of the item on the server (and eventually
client-side as well). So in the body of the request, we send an object with the
key we are updating, along with the new value.
Check your work by clicking the "Add to Cart" button and see if you receive an updated item in the response from the server. Then, refresh the page to verify that the change was persisted server-side.
Two steps done! Once more, our last step is to update state. Let's think this through. Right now, what state determines whether or not the item is in our cart? Where does that state live? Do we need to add new state?
A reasonable thought here is that we might need to add new state to the Item
component to keep track of whether or not the item is in the cart. While we
could theoretically make this approach work, it would be an anti-pattern: we'd
be duplicating state, which makes our components harder to work with and
more prone to bugs.
We already have state in our ShoppingList
component that tells us which items
are in the cart. So instead of creating new state, our goal is to call
setItems
in the ShoppingList
component with a new list of items, where the
isInCart
state of our updated item matches its state on the server.
Just like with our ItemForm
deliverable, let's start by creating a callback
function in the ShoppingList
component and passing it as a prop to the Item
component:
// src/components/ShoppingList.js
function ShoppingList() {
const [selectedCategory, setSelectedCategory] = useState("All");
const [items, setItems] = useState([]);
useEffect(() => {
fetch("http://localhost:4000/items")
.then((r) => r.json())
.then((items) => setItems(items));
}, []);
// add this callback function
function handleUpdateItem(updatedItem) {
console.log("In ShoppingCart:", updatedItem);
}
// ...rest of component
return (
<div className="ShoppingList">
<ItemForm onAddItem={handleAddItem} />
<Filter
category={selectedCategory}
onCategoryChange={handleCategoryChange}
/>
<ul className="Items">
{/* pass it as a prop to Item */}
{itemsToDisplay.map((item) => (
<Item key={item.id} item={item} onUpdateItem={handleUpdateItem} />
))}
</ul>
</div>
);
}
In the Item
component, we can destructure the onUpdateItem
prop and call it
when we have the updated item response from the server:
// src/components/Item.js
// Destructure the onUpdateItem prop
function Item({ item, onUpdateItem }) {
function handleAddToCartClick() {
// Call onUpdateItem, passing the data returned from the fetch request
fetch(`http://localhost:4000/items/${item.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
isInCart: !item.isInCart,
}),
})
.then((r) => r.json())
.then((updatedItem) => onUpdateItem(updatedItem));
}
// ... rest of component
}
Check your work by clicking the button once more. You should now see the updated
item logged to the console, this time from the ShoppingList
component.
As a last step, we need to call setState
with a new array that replaces one
item with the new updated item from the server. Recall from our lessons on
working with arrays in state that we can use .map
to help create this new
array:
// src/components/ShoppingList.js
function handleUpdateItem(updatedItem) {
const updatedItems = items.map((item) => {
if (item.id === updatedItem.id) {
return updatedItem;
} else {
return item;
}
});
setItems(updatedItems);
}
Clicking the button should now toggle the isInCart
property of any item in the
list on the server as well as in our React state! To recap:
- When X event occurs
- When a user clicks the Add to Cart button, handle the button click
- Make Y fetch request
- Make a
PATCH
request to/items/:id
, using the clicked item's data for the ID and body of the request, and access the updated item in the response
- Make a
- Update Z state
- Send the item from the fetch response to the
ShoppingList
component, and set state by creating a new array which contains the updated item in place of the old item
- Send the item from the fetch response to the
Last one! For our delete action, we'd like to give our users the ability to remove items from their shopping list:
- When X event occurs (a user clicks the DELETE button)
- Make Y fetch request (DELETE /items)
- Update Z state (remove the item from the list)
From here, we'll again need to identify which component triggers the event.
Our delete button is in the Item
component, so we'll start by adding an event
handler for clicks on the button:
// src/components/Item.js
function Item({ item, onUpdateItem }) {
// ...rest of component
function handleDeleteClick() {
console.log(item);
}
return (
<li className={item.isInCart ? "in-cart" : ""}>
{/* ... rest of JSX */}
{/* ... add onClick */}
<button className="remove" onClick={handleDeleteClick}>
Delete
</button>
</li>
);
}
This step should feel similar to our approach for the update action. Next, let's
write out our DELETE
request:
// src/components/Item.js
function handleDeleteClick() {
fetch(`http://localhost:4000/items/${item.id}`, {
method: "DELETE",
})
.then((r) => r.json())
.then(() => console.log("deleted!"));
}
Note that for a DELETE
request, we must include the ID of the item we're
deleting in the URL. We only need the method
option — no body
or headers
are needed since we don't have any additional data to send besides the ID.
You can verify that the item was successfully deleted by clicking the button,
checking that the console message of "deleted!"
appears, and refreshing the
page to check that the item is no longer on the list.
Our last step is to update state. Once again, the state that determines which
items are being displayed is the items
state in the ShoppingList
component,
so we need to call setItems
in that component with a new list of items that
does not contain our deleted item.
We'll pass a callback down from ShoppingList
to Item
, just like we did for
the update action:
// src/components/ShoppingList.js
function ShoppingList() {
const [selectedCategory, setSelectedCategory] = useState("All");
const [items, setItems] = useState([]);
useEffect(() => {
fetch("http://localhost:4000/items")
.then((r) => r.json())
.then((items) => setItems(items));
}, []);
// add this callback function
function handleDeleteItem(deletedItem) {
console.log("In ShoppingCart:", deletedItem);
}
// ...rest of component
return (
<div className="ShoppingList">
<ItemForm onAddItem={handleAddItem} />
<Filter
category={selectedCategory}
onCategoryChange={handleCategoryChange}
/>
<ul className="Items">
{/* pass it as a prop to Item */}
{itemsToDisplay.map((item) => (
<Item
key={item.id}
item={item}
onUpdateItem={handleUpdateItem}
onDeleteItem={handleDeleteItem}
/>
))}
</ul>
</div>
);
}
Call the onDeleteItem
prop in the Item
component once the item has been
deleted from the server, and pass up the item that was clicked:
// src/components/Item.js
// Deconstruct the onDeleteItem prop
function Item({ item, onUpdateItem, onDeleteItem }) {
function handleDeleteClick() {
// Call onDeleteItem, passing the deleted item
fetch(`http://localhost:4000/items/${item.id}`, {
method: "DELETE",
})
.then((r) => r.json())
.then(() => onDeleteItem(item));
}
// ... rest of component
}
As a last step, we need to call setState
with a new array that removes the
deleted item from the list. Recall from our lessons on working with arrays in
state that we can use .filter
to help create this new array:
// src/components/ShoppingList.js
function handleDeleteItem(deletedItem) {
const updatedItems = items.filter((item) => item.id !== deletedItem.id);
setItems(updatedItems);
}
Clicking the delete button should now delete the item in the list on the server as well as in our React state! To recap:
- When X event occurs
- When a user clicks the Delete button, handle the button click
- Make Y fetch request
- Make a
DELETE
request to/items/:id
, using the clicked item's data for the ID
- Make a
- Update Z state
- Send the clicked item to the
ShoppingList
component, and set state by creating a new array in which the deleted item has been filtered out
- Send the clicked item to the
Synchronizing state between a client-side application and a server-side application is a challenging problem! Thankfully, the general steps to accomplish this in React are the same regardless of what kind of action we are performing:
- When X event occurs
- Make Y fetch request
- Update Z state
Keep these steps in mind any time you're working on a feature involving synchronizing client and server state. Once you have this general framework, you can apply the other things you've learned about React to each step, like handling events, updating state, and passing data between components.