Task 4: Delete Posts
Step 1: Delete Post
In this step, we will add the ability to delete a post in our Posts app. We will update the PostActions
component, which contains a dropdown menu item for deleting a post. When this item is clicked, the app will make an API call to delete the selected post.
Add the following function to src/lib/api.ts
file:
// Delete post by id
export const deletePost = async (id: string): Promise<void> => {
return new Promise((resolve) => {
setTimeout(() => {
db.posts = db.posts.filter((post) => post.id !== id);
resolve();
}, 200); // Simulate an API delay
});
};
Let’s add an event handler to PostActions
component so when a user clicks on the “Delete post” menu item, the deletePost
API function is called. Here is the summary of changes made to the src/components/post-actions.tsx
file:
-
Import the
deletePost
function.import { deletePost } from "@/lib/api";
-
Update
PostActions
props and add an event handler.const PostActions = ({ postId }: { postId: string }) => { const handleDelete = async () => { await deletePost(postId); }; return (/* DropdownMenu */) }
-
Set
handleDelete
as the event handler for theonClick
event of the related dropdown menu item.- <DropdownMenuItem className="text-red-500"> + <DropdownMenuItem className="text-red-500" onClick={handleDelete}> Delete post </DropdownMenuItem>
Note that we need to pass the post ID from the Post
component down to the PostActions
component.
-
Destructure the
id
attribute from thepost
object:- const { content, user, timestamp } = post; + const { id, content, user, timestamp } = post;
-
Pass the
id
attribute as a prop to thePostFooter
component:- <PostFooter /> + <PostFooter postId={id} />
-
Update the
PostFooter
declaration to receive thepostId
prop:- function PostFooter() { + function PostFooter({ postId } : { postId: string }) {
-
In the
PostFooter
component, pass thepostId
prop to thePostActions
component:- <PostActions /> + <PostActions postId={postId} />
Save all changes and run the application. However, please note that the delete function does not appear to be working.
We will investigate the problem in the following steps. For now, stage and commit all the changes.
git add .
git commit -m "Delete a post [incomplete]"
Step 2: Debugging
In this step, we will address an issue with the "delete post" feature that was introduced in the previous step. We will use debugging techniques to identify the source of the problem. Debugging is an essential skill for developers, and we will demonstrate how to use log messages and browser DevTools to inspect our application's behavior and diagnose the problem. Let's get started!
Debug Messages
To troubleshoot the issue, we can use the logger to add helpful debug messages and trace the events and data.
-
First, import
log
in thePostActions
component:import { log } from "@/lib/logger";
-
Next, update the
handleDelete
function (in the same file) by adding a debug message to it:const handleDelete = async () => { log.debug("Deleting post", postId); await deletePost(postId); };
-
Then, import
log
in thesrc/lib/api.ts
file:import { log } from "./logger";
-
Finally, add debug messages to the
deletePost
function in the same file:// Delete post by id export const deletePost = async (id: string): Promise<void> => { return new Promise((resolve) => { setTimeout(() => { log.debug("delete post is called with id =", id); log.debug("# posts before delete", db.posts.length); db.posts = db.posts.filter((post) => post.id !== id); log.debug("# posts after delete", db.posts.length); resolve(); }, 200); // Simulate an API delay }); };
Run the app and attempt to delete a post while the browser console is open. You will notice that the debug messages indicate that the delete was successful. However, the post is still being displayed in the UI.
VSCode Debugger
Alternative to placing debug statements, you can attach a debugger to the running application and step into your code. To do this, make sure you have the JavaScript Debugger extension installed in VSCode. Then, follow these steps:
-
Add a
.vscode
folder to the root of your project and create alaunch.json
file inside it. The contents of the file should be as follows:{ "version": "0.2.0", "configurations": [ { "type": "chrome", "request": "launch", "name": "Launch Chrome against localhost", "url": "<http://localhost:5173/posts-app/>", "webRoot": "${workspaceFolder}" } ] }
Note: The
url
should be the URL of your application when you run it in development mode. -
Start the app with
pnpm dev
in the terminal. -
In VSCode, go to the Debug view by clicking on the Debug icon in the Activity Bar on the side of the window.
-
Select
Launch Chrome against localhost
from the dropdown at the top of the Debug view. -
Click on the green play (▶️) button to start debugging.
A new Chrome instance should open with debugging enabled. You can set breakpoints in your React code within VSCode. When the code execution reaches those points, it will pause, allowing you to inspect variables, the call stack, and more:
- Setting Breakpoints: Click on the left margin next to the line numbers in your code where you want execution to pause.
- Inspecting Variables: When the code execution is paused, you can hover over variables to inspect their values or use the "Variables" pane in the Debug view.
- Navigating the Call Stack: Use the "Call Stack" pane in the Debug view to navigate through the function calls leading up to the breakpoint.
- Console Log: You can view console logs within VSCode's debug console, making it easier to diagnose issues without switching back to the browser.
To stop debugging, click on the red square (⏹️) button in the Debug view in VSCode. Close the Chrome instance that was opened by the debugger.
With these steps, you should be able to effectively debug their React apps directly within VSCode, leveraging the powerful features of both the Chrome Debugger and VSCode's integrated debugging tools.
React Dev Tools
To further investigate this issue, let's inspect our app using the React Developer Tools. This browser extension adds React debugging tools to the built-in browser Developer Tools. Open the developer tools and notice the two tabs with the React logo: Components and Profiler. Use these tabs to navigate through the React components, view their props, state, etc., or profile the performance of your application.
If you inspect the Posts
component, you'll notice that the "state" of that component still contains the deleted post.
Indeed the problem is that we must update the app state as we update the app data through an API call. One approach to do this is to bring the delete event handler in the parent component of Posts
(where the posts state data resides) and update the state along with the API call to delete the selected post. We will explore this strategy next but for now, stage and commit all changes.
git add .
git commit -m "Added log messages for deleting a post"
Step 3: Update State after Delete
In this step, we will address an issue with the "delete post" feature that was introduced in the previous step. We will explore a strategy to update the app state along with the API call to delete a post. We will bring the delete event handler in the parent component of Posts
where the state data resides. This will allow us to update the UI after the post is deleted from the server. Let's get started!
Let's update the Posts
component.
-
Import
deletePost
andlogger
:import { deletePost } from "@/lib/api"; import { log } from "@/lib/logger";
-
Declare an updated version of
handleDelete
in the body of thePosts
function:const handleDelete = async (postId: string) => { log.debug("Deleting post", postId); await deletePost(postId); setPosts(posts.filter((post) => post.id !== postId)); };
-
Pass this function as a prop to the
Post
component:- <Post post={post} key={post.id} /> + <Post post={post} key={post.id} handleDelete={handleDelete} />
Let's update the Post
component.
-
Update the props that the component receives:
const Post = ({ post, + handleDelete, }: { post: PostWithUserData; + handleDelete: (postId: string) => void; }) => {
-
Pass the
handleDelete
as a prop to thePostFooter
component:- <PostFooter postId={id} /> + <PostFooter postId={id} handleDelete={handleDelete} />
Let's update the PostFooter
component.
-
Update the props that the component receives:
const PostFooter = ({ post, + handleDelete, }: { post: PostWithUserData; + handleDelete: (postId: string) => void; }) => {
-
Pass the
handleDelete
as a prop to thePostActions
component:- <PostActions postId={id} /> + <PostActions postId={id} handleDelete={handleDelete} />
Let's update the PostActions
component.
-
Update the props that the component receives:
const PostActions = ({ post, + handleDelete, }: { post: PostWithUserData; + handleDelete: (postId: string) => void; }) => {
-
Delete the
handleDelete
inner function and remove any unused import statements. -
Update the
onClick
event as follows:<DropdownMenuItem className="text-red-500" - onClick={handleDelete} + onClick={() => handleDelete(postId)} > Delete post </DropdownMenuItem>
Run the app and try deleting a post. It should work now!
Stage and commit all changes.
git add .
git commit -m "Update state after deleting a post"
Step 4: Zustand
In the previous step, we saw that passing a function several levels down through props (a technique called prop drilling) can be cumbersome and error-prone. To solve this problem, there are several different strategies that can be used. One of these strategies is to use a global state management library. In this step, we will use a library called Zustand to manage the state of our Posts app. Zustand is pronounced "zooshtand" and it means "state" in German.
To start, let's refactor our app.
-
First, move all type declarations from
src/lib/data.ts
to a new file calledsrc/lib/types.ts
:export type User = { id: string; userName: string; displayName: string; avatar?: string; }; export type Post = { id: string; userId: string; content: string; timestamp: string; image?: string; likeCount: number; commentCount: number; }; export type Like = { id: string; postId: string; userId: string; }; export type Comment = { id: string; postId: string; userId: string; content: string; timestamp: string; };
-
Next, add an import statement to the
src/lib/data.ts
file as follows:import type { Like, Post, User, Comment } from "./types";
-
Move the
PostWithUserData
type fromsrc/lib/api.ts
tosrc/lib/types.ts
:export type PostWithUserData = Post & { user?: User };
-
Finally, update the import statements in the
src/lib/api.ts
file as follows:- import type { User, Post } from "@/lib/data"; + import type { User, PostWithUserData } from "@/lib/types";
Install Zustand and its dependency Immer
pnpm add zustand immer
Create a new file named src/lib/store.ts
and add the following content:
import { PostWithUserData } from "./types";
import { log } from "./logger";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
type State = {
posts: PostWithUserData[];
// Add more state variables
};
type Action = {
setPosts: (posts: PostWithUserData[]) => void;
removePost: (id: string) => void;
// Add more actions
};
// define the initial state
const initialState: State = {
posts: [],
};
export const useStore = create<State & Action>()(
immer((set, get) => ({
...initialState,
setPosts: (posts) => set({ posts }),
removePost: (id) => {
log.debug("Store's delete post is called with id =", id);
log.debug("# posts before delete", get().posts.length);
const newPosts = get().posts.filter((post) => post.id !== id);
log.debug("# posts after delete", newPosts.length);
set({ posts: newPosts });
},
}))
);
- The
create
function is a helper function that creates a “store”. A store is a React hook where we can put anything in it: primitives, objects, and functions. We keep the app (global) state in the store along with the operations that modify it. We call those operations “actions”. - Note that the
State
type, which represents our app's global state, currently only contains an array of posts. Later on, additional variables will be added to store comments, likes, and other data. Currently, there are only two actions: setting the posts and deleting a post. More actions will be added in the future. - In Zustand, like with React
useState
, we need to update the state immutably. This means that if we want to change an entry in an array for instance, we must return a new array with the modified entry rather than modifying the existing array directly. Zustand provides animmer
middleware that makes this easier by allowing us to modify the state using mutable syntax while still returning an immutable copy of the state. This ensures that the state is updated correctly and that React re-renders only the components that depend on the changed state. - The
set
andget
functions are used in the Zustand store to modify and retrieve the global state, respectively. Theset
function is used to modify the state by passing in an object that describes the changes we want to make. Theget
function is used to retrieve the current state. These functions work together to allow us to manage the state of our application in a simple and efficient way.
Let's update the Posts
component:
-
Replace
useState
withuseStore
:- const [posts, setPosts] = useState<PostWithUserData[]>([]); + const [posts, setPosts] = useStore((state) => [state.posts, state.setPosts]);
-
Update the import statements accordingly:
- import { useEffect, useState } from "react"; + import { useEffect } from "react"; + import { useStore } from "@/lib/store";
-
Remove the
handleDelete
operation:- const handleDelete = async (postId: string) => { - log.debug("Deleting post", postId); - await deletePost(postId); - setPosts(posts.filter((post) => post.id !== postId)); - };
-
Remove the
handleDelete
prop from thePost
component:- <Post post={post} key={post.id} handleDelete={handleDelete} /> + <Post post={post} key={post.id} />
-
Remove unused import statements:
- import { PostWithUserData, deletePost, fetchPosts } from "@/lib/api"; + import { fetchPosts } from "@/lib/api"; - import { log } from "@/lib/logger";
Update the Post
component:
-
First, update the import statement for
PostWithUserData
type:- import type { PostWithUserData } from "@/lib/api"; + import type { PostWithUserData } from "@/lib/types";
-
Next, update the component props by removing
handleDelete
:const Post = ({ post, - handleDelete, }: { post: PostWithUserData; - handleDelete: (postId: string) => void; }) => {
-
Remove the
handleDelete
prop from thePostFooter
component:- <PostFooter postId={id} handleDelete={handleDelete} /> + <PostFooter postId={id} />
Update the PostFooter
component:
-
Update the component props by removing
handleDelete
:const PostFooter = ({ post, - handleDelete, }: { post: PostWithUserData; - handleDelete: (postId: string) => void; }) => {
-
Remove the
handleDelete
prop from thePostActions
component:- <PostActions postId={id} handleDelete={handleDelete} /> + <PostActions postId={id} />
Update the PostActions
component:
-
Update the component props by removing
handleDelete
:const PostActions = ({ post, - handleDelete, }: { post: PostWithUserData; - handleDelete: (postId: string) => void; }) => {
-
Add the
removePost
action from store:const removePost = useStore((state) => state.removePost);
-
Import
useStore
and thedeletePost
API function:import { deletePost } from "@/lib/api"; import { useStore } from "@/lib/store";
-
Define an inner
handleDelete
function:const handleDelete = async (postId: string) => { await deletePost(postId); removePost(postId); };
Run the app and try deleting a post. It should work!
Take note of the log messages that indicate the delete process was executed through the API and the app store:
Stage and commit all changes.
git add .
git commit -m "Add Zustand store"
Step 5: Custom Hooks
In the previous step, we refactored our Posts app to use Zustand for global state management. We created a new file, store.ts
, to define and export a global store using the create
function from Zustand. Additionally, we moved all type declarations to a new file called types.ts
and updated import statements accordingly. To eliminate prop drilling, we replaced useState
with useStore
in several components. As a result, the handleDelete
operation is defined in the PostActions
component.
Make note of these two code segments:
-
The
useEffect
in thePosts
component:useEffect(() => { const loadPosts = async () => { const fetchedPosts = await fetchPosts(); setPosts(fetchedPosts); }; loadPosts(); }, []);
-
The
handleDelete
in thePostActions
component:const handleDelete = async (postId: string) => { await deletePost(postId); removePost(postId); };
These functions exhibit common patterns for developing web applications that involve making a call to an API and updating the application state based on the result of that call. In the first code segment, we use useEffect
to fetch data from the server when the component mounts. Once the data is fetched, we use the setPosts
function to update the state of the Posts
component with the fetched data. In the second code segment, we make a delete request to the API to remove the post from the server. Once the operation is successful, we update the global state of the application and remove the deleted post.
Creating custom hooks is a common practice to further abstract these processes. We will explore that in this section.
In React, a custom hook is a function that starts with the word "use" and allows you to extract common logic into a reusable function. Here is a simple and contrived example:
import { useState, useEffect } from "react";
export const useCounter = (initialValue: number, step: number) => {
const [count, setCount] = useState(initialValue);
const increment = () => {
setCount(count + step);
};
const decrement = () => {
setCount(count - step);
};
useEffect(() => {
console.log(`Count is now ${count}`);
}, [count]);
return { count, increment, decrement };
};
This hook allows you to create a counter that you can increment or decrement by a specified step. The hook returns the current count value and functions to increment or decrement the count. It also logs the current count value to the console whenever it changes.
To use this custom hook in a component, you can do the following:
import { useCounter } from "./useCounter";
const Counter = () => {
const { count, increment, decrement } = useCounter(0, 1);
return (
<div>
<p>Count: {count}</p>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
</div>
);
};
This component will display a counter with a current count value and buttons to increment or decrement the count. The useCounter
hook is used to create the counter and provide the necessary functions and state.
Custom hooks are a way to share stateful logic between multiple components without requiring the components to be nested in a specific order. By using custom hooks, we can make our code more reusable and easier to reason about. The reason why the function name must start with the word "use" is because that is how React defines hooks.
Create a file src/hooks/use-query-posts.tsx
with the following content:
import { fetchPosts } from "@/lib/api";
import { useStore } from "@/lib/store";
import { useEffect } from "react";
function useQueryPosts() {
const posts = useStore((state) => state.posts);
const setPosts = useStore((state) => state.setPosts);
const loadPosts = async () => {
const fetchedPosts = await fetchPosts();
setPosts(fetchedPosts);
};
useEffect(() => {
loadPosts();
}, []);
return { posts };
}
export default useQueryPosts;
Now update the Posts
component:
import Post from "./post";
import useQueryPosts from "@/hooks/use-query-posts";
const Posts = () => {
const { posts } = useQueryPosts()
return (
<div className="">
{posts.map((post) => (
<Post post={post} key={post.id} />
))}
</div>
);
};
export default Posts;
Notice how much simpler the Posts
component became after using the useQueryPosts
hook.
The term "query" in useQueryPosts
is used to indicate that this custom hook is a declarative dependency on an asynchronous source of data. In other words, it is a way for components to specify what data they need and let the hook handle fetching that data from the server. This is similar to how a query works in a database, where we specify what data we need and the database engine does the work of finding and returning that data. By using the term "query" in the name of the hook, we're indicating that this is a way to declaratively fetch data from the server rather than imperatively writing code to fetch that data.
Create a file src/hooks/use-mutations-posts.tsx
with the following content:
import { deletePost } from "@/lib/api";
import { useStore } from "@/lib/store";
function useMutationPosts() {
const removePost = useStore((state) => state.removePost);
const deletePostById = async (postId: string) => {
await deletePost(postId);
removePost(postId);
};
return { deletePostById };
}
export default useMutationPosts;
Now update the PostActions
component:
import { DotsHorizontalIcon } from "@radix-ui/react-icons";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import useMutationPosts from "@/hooks/use-mutation-posts";
const PostActions = ({ postId }: { postId: string }) => {
const { deletePostById } = useMutationPosts();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
className="flex h-8 w-8 p-0 data-[state=open]:bg-muted"
>
<DotsHorizontalIcon className="w-4 h-4" />
<span className="sr-only">Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[160px]">
<DropdownMenuItem>Edit post</DropdownMenuItem>
<DropdownMenuItem
className="text-red-500"
onClick={() => deletePostById(postId)}
>
Delete post
</DropdownMenuItem>
<DropdownMenuItem>Copy link to post</DropdownMenuItem>
<DropdownMenuItem className="text-red-500">
Report post
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
export default PostActions;
Note that abstracting the logic for mutating posts will be very helpful, as it can be reused in other components. Additionally, the PostActions
component abstracts several actions (delete, edit, report, etc.). If all of those logics were to be implemented here, this component would become unwieldy long.
The term "mutation" in useMutationPosts
is used to indicate that this custom hook is a declarative dependency on an asynchronous operation that changes the state of the application. In other words, it is a way for components to specify what state they need to change and let the hook handle updating that state. This is similar to how a mutation works in a database, where we specify what data we want to update and the database engine does the work of updating that data. By using the term "mutation" in the name of the hook, we're indicating that this is a way to declaratively update the state of the application rather than imperatively writing code to update that state.
To wrap this step, stage and commit all changes.
git add .
git commit -m "Create custom hooks to query and mutate posts"