Week 9 - Modifying Remote Data
REST APIs
REST APIs are the most common API type, and comprised of many url paths, or “endpoints”.
They are typically structured so that their URLs represent a hierarchy from left to right.
POST /token
- Retrieve a login tokenGET /contacts
- Get a list of contactsGET /contacts/{contact_id}
- Get a specific contact- Returns a single object instead of a list, and often returns more details than a list endpoint
GET /contacts/{contact_id}/profile
- Get a specific user’s profile image- Note how
/profile
could be seen as “nested under” a specific contact’s path
- Note how
POST /contacts
- Create a new contactPATCH /contacts/{contact_id}
- Update an existing contactDELETE /contacts/{contact_id}
- Delete an existing contact
Some other common examples of nesting might be:
GET /groups/{group_id}/users
: get all users from a given groupPOST /groups/{group_id}/activities/{activity_id}/start
to start a group activity
Fetch API Options
The fetch
function provided by javascript accepts two arguments - the URL, and an object containing any options to customize the request.
const response = await fetch("https://example.org/post", options);
The options object can contain accept anything defined in the RequestInit
docs. The options we are focused on are:
headers
: Used to “describe” the requestmethod
:fetch
performsGET
requests by default. To perform other types of requests -POST
/DELETE
/etc - we pass the method we want as a string.body
: The body of the request, usually containing data, forPOST/PUT/PATCH
requests
Request Body
POST
, PUT
, and PATCH
requests can also accept a “body”, which allows us to send more complex data. The request
body can accept a string or binary data, and there are several types of formatting when using a string.
Sending JSON
Since the body is a simple string, but the content type can vary, we must describe what the body of our request contains with a header. To send data as a JSON object, we must:
- Include the
Content-Type
type header, set toapplication/json
. - “Serialize” the data for the request using
JSON.stringify()
- If we don’t do this, we will end up with
[Object object]
in our post body
- If we don’t do this, we will end up with
const contact = {
firstName: "New",
lastName: "Contact",
email: "[email protected]",
phone: '315-867-5309'
}
const response = await fetch("https://example.org/post", {
method: "POST",
// serialize request as JSON
body: JSON.stringify(contact),
headers: {
// tell the server our content is JSON
'Content-Type': 'application/json',
'Authorization': `ApiKey ${API_KEY}`, // don't forget about authentication!
}
});
Contacts REST API
CI256 has a REST API set up at https://api.ci256.cloud for maintaining a list of Contacts. Don’t forget about the builtin docs!
POST /contacts
- Create a Contact
To create a new contact, we use a POST
request to /contacts
. We can find the exact format required on the docs site: https://api.ci256.cloud/docs#/Contacts/create_contacts__post
It is the same as the contact object we’ve been working with so far, minus the id
attribute. The server will assign us an ID attribute once the object is created in the database. That means our input data looks something like this:
{
"firstName": "string",
"lastName": "string",
"email": "string",
"phone": "string",
"extra": {
"string": "string"
}
}
A fetch request to create a new user would look like this:
async function addContact(contact) {
const response = await fetch(`https://api.ci256.cloud/contacts`, {
// post request - make new contact
method: "POST",
// include authentication information
headers: {'Authorization': `ApiKey ${apiKey}`},
// include the contact object in the body
body: JSON.stringify(contact)
});
// error handling
if (!response.ok) {
console.error(`Response status: ${response.status}`);
throw new Error(`Response status: ${response.status}`)
}
// get response data if needed
const data = response.json();
console.log('created a new contact', data)
return data
}
// get contact information from form fields in our UI
let newContact = {
"firstName": "New",
"lastName": "Person",
"email": "[email protected]",
"phone": "315-867-5309",
"extra": {
"favorite_song": "Jenny by Tommy Tutone"
}
}
// call our addContact() function to add our new contact to the API
let created = addContact(newContact)
The returned data will be the contact object with the fields we created, plus an ID field:
{
// server adds this for us
"id": 101,
// these fields are equal to our input values
"firstName": "New",
"lastName": "User",
"email": "[email protected]",
"phone": "315-867-5309",
"extra": {
"favorite_song": "Jenny by Tommy Tutone"
}
}
PATCH /contacts/{contact_id}
Full Contact Object. A PATCH request will update an existing user. This request looks exactly like the POST request, except we change the method to PATCH, and we must include the contact ID in the URL.
Input: Fields to be updated. Any field may be omitted.
Note: Extra will be replaced entirely. Be sure to include any existing extra fields!
{
"firstName": "string",
"lastName": "string",
"email": "string",
"phone": "string",
"extra": {}
}
Output: Full Contact Object, including the existing ID.
DELETE /contacts/{contact_id}
- Delete a contact
To delete a contact with a given ID, we just need to make a DELETE
request to the appropriate endpoint.
function deleteContact(id) {
const response = await fetch(`https://api.ci256.cloud/contacts/${id}`, {
method: "DELETE",
headers: {'Authorization': `ApiKey ${apiKey}`}
});
// todo error handling, check response
}
Expected Response:
{
"ok": true
}
Post-request actions - What now?
After creating, modifying a contact, we’ll want to perform 2 actions:
- Redirect the user to the contact detail page
- Update our list of users with the latest changes
Redirecting the User
To redirect the user to a new page within our application, the useNavigate
hook from React Router is used.
useNavigate
returns a function that can be called to navigate the user to a new page without reloading the entire page, like the <Link>
component.
export default function EditContact() {
const navigate = useNavigate();
async function submit(e) {
// save the contact
const result = await saveContact(newContact);
// redirect to contact detail page
navigate(`/contacts/${result.id}`);
}
}
Reloading Page Data
Since we may have modified a contact name or added a new entry to the list, we want to update our sidebar with the latest content.
React Router provides a revalidator
hook for this purpose. By calling revalidate()
, React Router will re-execute the loader()
functions we’ve defined in our layouts and routes.
export default function EditContact() {
const revalidator = useRevalidator();
async function submit(e) {
// save the contact
const result = await saveContact(newContact);
// update sidebar data
revalidator.revalidate()
}
}
Lab - Final Project: Creating, Editing, and Deleting Contacts
For Lab, you’ll be working on the ability to manage contacts on the remote server. This will include creating, editing, and deleting contacts.
Note: You cannot modify “public contacts” with IDs 1000 - 1100. To make this lab easier, you may want to remove public contacts from the list by including
?public=false
in your call toGET /contacts
1. Fix “Create Contact”
The first thing is to fix our “Create Contact” button by updating our button/link and adding a new route.
1A. Convert “Create Contact” Button to a Link
Update the “Create Contact” button to use a Link element, like we did in the last lab with the Contact link.
Example: <Link to="/contacts/create">(your button html here)</Link>
You know this step is working when you can click on the “create contact” button and the app navigates to the url /contacts/create
. It’s okay if that page shows a 404/Route not found error.
1B. Adding a Create Contact route
Add a new route for /contacts/create
in main.tsx
.
If you already have a component like
<CreateContact />
you can use it here - otherwise, you will need to create a new component.
{
path: '/contacts/create',
element: <CreateContact />,
},
2. Send the network request
Add new functions to contacts.ts
for creating and updating a new contact. Remember to:
- Use
POST /contacts
for creating new contacts - Use
PATCH /contacts/{contact_id}
for updating an existing contact - Serialize the data with
JSON.stringify
- Include the
content-type
andauthorization
headers
3. Save Contact
Call your new function from step when the user presses a Save button in the CreateContact component.
You’ll know this step is working if there are no errors in the browser console, and refreshing the page shows the contact you just saved.
4. Post-save actions
When the user presses the save button, we should update the UI with the new data, and redirect the user to the contact detail page.
You should have created a function to handle the onClick event of the save button within your CreateContact component. It might look something like this:
async function submit(e) {
let newContact = {
firstName: first,
lastName: last,
phone,
email,
extra
}
const data = await saveContact(newContact);
}
We need to add two steps after the contact is successfully saved:
- Reload the page data with
revalidate()
- Redirect the user to
/contacts/${data.id}
You’ll know this step is working if, upon saving a new contact:
- Your sidebar updates with the new contact name
- Your URL changes to
/contacts/${data.id}
- Note: This should be a “seamless” transition using React Router’s
useNavigate
, NOT setting window.location directly
- Note: This should be a “seamless” transition using React Router’s
- The new contact’s details are displayed on screen
NOTE: You may run into a situation where
revalidate()
is refreshing the data, but your UI is not refreshing. This can be caused by using the value ofuseLoaderData()
within auseState
hook.
You have two options to resolve this:
- Stop using
useState
, and utilize the value directly. - Use the
useEffect
hook to update the state when the data changes.
Option 2 is more likely the “correct” answer for your situation. Example:
const contacts = useLoaderData();
const [displayedContacts, setDisplayedContacts] = useState(contacts)
// Add this useEffect hook
useEffect(
// update the displayedContact list...
() => setDisplayedContacts(contacts),
// ... when the dependency variable "contacts" changes
[contacts]
)
function onSearch(e) {
setDisplayedContacts(contacts.filter(c => /* search logic here */))
}
5. Edit an existing contact
Now lets add the ability to modify an existing contact.
5A. Edit Contact Button
Add a button to edit an existing contact when a contact is being displayed. Be sure to use Link
from React Router for this.
When clicked, the browser should navigate to the url /contacts/:id/edit
(or similar).
You’ll know this is working if
- An edit button/link displayed on existing Contacts
- Clicking the button/link navigates the user to
/contacts/:id/edit
- It’s okay if this page shows a 404/Route not found error, that’s the next step!
5B. Edit Contact Route
Add a new route in main.tsx
for /contacts/:id/edit
. You may use a new component for this if you’d like, but the recommended approach is to _re-use your existing component used to create the contact.
This route will need to include a loader function - the loader function from /contacts/:id
may be copied and re-used, as we are loading the same information. Don’t forget to use useLoaderData()
in the component!
TIP: If no
loader
function is defined,userLoaderData()
will return undefined. This is useful for determining within the component if we are editing an existing contact, or creating a new contact.For example, given these two routes are using the same
<EditContact />
component:{ path: '/contacts/:id/edit', element: <EditContact />, loader: () => { /* */ }, }, { path: "/contacts/create", element: <EditContact />, },
The component can use logic like this:
export default function EditContact() { let existingContact = useLoaderData(); if(existingContact) { console.log('modifying existing contact') } else { console.log('creating a new contact') } }
Be sure to utilize the existing contact information to populate input fields on this page. Remember that the default value to useState
is the first argument, so we can set the defaults like this:
let existingContact = useLoaderData();
const [firstName, setFirstName] = useState(existingContact?.firstName || '')
// ...
return (
{/* ...other stuff... */}
<Input value={firstName} onChange={e => setFirstName(e.target.value)} />
{/* ...other stuff... */}
)
You’ll know this step is working if you can click the “Edit Contact” button and:
- The browser directs you to
/contacts/:id/edit
- An edit screen is displayed
- All input fields are pre-populated with existing contact data
5C. Save the updated information
Upon the user clicking save, be sure to call the appropriate function from step 2.
All of the post-save steps from step 4 should also apply.
You’ll know this step is working if:
- You can click the save button and no errors occur in the browser console
- After saving, you are redirected to the contact detail screen (
/contacts/:id
) - There is not a new entry added to the sidebar
- Be sure to refresh the page to verify this! A common mistake here will be accidentally creating duplicate users.
6. Deleting Contacts
Finally, add a delete button for deleting existing contacts.
6A. Add a delete contact function
To delete a contact, make a network request with method DELETE
to the endpoint /contacts/:id
- no request body is required.
Add a function to contacts.ts
to make this request. The function should accept a single parameter, contactId
.
6B. Add a delete button
Delete buttons usually exist on the edit page for an object, but, it can go on the display page if desired.
Add a button somewhere in the UI for deleting contacts. Call the function from step 6A to perform the action.
6C. Post-Delete Actions
After deleting a user, we want to perform two actions, similar to step 4.
- Redirect the user, since the object being viewed/modified will no longer exist.
- Sending the user to
/
, the “root” or “homepage”, probably makes the most sense
- Sending the user to
- Update the sidebar data
You’ll know this step is working if:
- There is a delete button available for every user
- Clicking the delete button results in no errors in the browser console
- Deleting a user removes that user from the sidebar
- After deleting a user, the user is redirected to
/