In this task, we will create custom hooks to fetch and mutate data in the Posts app. We will refactor the components to use these custom hooks for data management.
Consider the handleDelete
operation in the DeletePostDialog
component:
const handleDelete = async () => {
await deletePost(post.id);
removePost(post.id);
};
This operation involves deleting a post by calling the API (deletePost
function) and then updating the state (calling the removePost
function). This pattern is repeated in other components as well, to create or update posts.
We can abstract this pattern into a custom hook that handles the API calls and state updates. This will make our components cleaner and more maintainable.
Custom hooks are JavaScript functions whose names start with use
and may call other hooks. They allow you to reuse stateful logic across components. Custom hooks can be used to encapsulate complex logic and provide a clean interface for components to interact with.
Here is an example of a custom hook that prints a count to the console:
import { useEffect, useState } from "react";
function useCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log(`Count: ${count}`);
}, [count]);
const increment = () => setCount((prevCount) => prevCount + 1);
const decrement = () => setCount((prevCount) => prevCount - 1);
return { count, increment, decrement };
}
In this example:
useState
hook to create a count
state.useEffect
hook to log the count to the console whenever it changes.increment
and decrement
functions to update the count state.count
, increment
, and decrement
functions from the custom hook.Here is how you can use this custom hook in a component:
import { useCounter } from "./useCounter";
function Counter() {
const { count, increment, decrement } = useCounter();
return (
<div>
<button onClick={increment}>Increment</button>
<button onClick={decrement}>Decrement</button>
<p>Count: {count}</p>
</div>
);
}
The useCounter
custom hook encapsulates the logic for managing the count state and provides a clean interface for components to interact with it. We will follow a similar pattern to create custom hooks for fetching and mutating data in the Posts app.
We’ll start by creating a custom hook to fetch data. This hook will handle the API call to fetch posts and interacts with the store to update the state.
Create a new file use-query-posts.tsx
in the web/src/hooks
directory:
import { useEffect } from "react";
import { fetchPosts } from "@/data/api";
import { useStore } from "@nanostores/react";
import { setPosts, $posts } from "@/lib/store";
function useQueryPosts() {
const posts = useStore($posts);
const loadPosts = async () => {
try {
const fetchedPosts = await fetchPosts();
setPosts([...fetchedPosts]);
} catch (error) {
const errorMessage =
(error as Error).message ?? "Please try again later!";
console.error(errorMessage);
}
};
useEffect(() => {
loadPosts();
}, []);
return { posts };
}
export default useQueryPosts;
In this custom hook:
useStore
hook from @nanostores/react
to access the store state.loadPosts
function that fetches posts using the fetchPosts
API call and updates the store state using the setPosts
function.useEffect
hook to call loadPosts
when the component mounts.posts
state to be used in the components.The term “query” in the custom hook name useQueryPosts
is inspired by GraphQL, where queries are used to fetch data. This custom hook encapsulates the logic for fetching posts and provides a clean interface for components to interact with the posts state.
Posts
component to use the custom hookNow, we’ll update the Posts
component to use the useQueryPosts
custom hook to fetch posts.
- import { useEffect } from "react";
import Post from "./post";
- import { fetchPosts } from "@/data/api";
- import { useStore } from "@nanostores/react";
- import { $posts, setPosts } from "@/lib/store";
+ import useQueryPosts from "@/hooks/use-query-posts";
const Posts = () => {
- const posts = useStore($posts);
+ const { posts } = useQueryPosts();
- useEffect(() => {
- fetchPosts().then((data) => setPosts(data));
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, []);
return (
<div>
{posts
.sort((a, b) => (a.date > b.date ? -1 : 1))
.map((post) => (
<Post key={post.id} post={post} />
))}
</div>
);
};
export default Posts;
In this update:
useQueryPosts
custom hook.posts
state directly from the store, we use the useQueryPosts
custom hook to fetch and manage the posts state.useEffect
hook) as this logic is now handled by the custom hook.Create a new file use-mutation-posts.tsx
in the web/src/hooks
directory:
import { createPost, deletePost, editPost } from "@/data/api";
import { addPost, removePost, updatePostContent } from "@/lib/store";
function useMutationPosts() {
const deletePostById = async (postId: string) => {
try {
await deletePost(postId);
removePost(postId);
} catch (error) {
const errorMessage =
(error as Error).message ?? "Please try again later!";
console.error(errorMessage);
}
};
const addNewPost = async (content: string) => {
try {
if (!content) {
throw new Error("Content cannot be empty!");
}
const newPost = await createPost(content);
addPost(newPost);
} catch (error) {
const errorMessage =
(error as Error).message ?? "Please try again later!";
console.error(errorMessage);
}
};
const updatePost = async (postId: string, content: string) => {
try {
if (!content) {
throw new Error("Content cannot be empty!");
}
const updatedPost = await editPost(postId, content);
updatePostContent(updatedPost.id, updatedPost.content);
} catch (error) {
const errorMessage =
(error as Error).message ?? "Please try again later!";
console.error(errorMessage);
}
};
return {
deletePostById,
addNewPost,
updatePost,
};
}
export default useMutationPosts;
In this custom hook:
The term “mutation” in the custom hook name useMutationPosts
is inspired by GraphQL, where mutations are used to modify data. This custom hook encapsulates the logic for mutating posts and provides a clean interface for components to interact with the posts state.
DeletePostDialog
component import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { TrashIcon } from "@radix-ui/react-icons";
import { PostType } from "@/data/types";
- import { deletePost } from "@/data/api";
- import { removePost } from "@/lib/store";
+ import useMutationPosts from "@/hooks/use-mutation-posts";
type DeletePostDialogProps = {
post: PostType;
};
const DeletePostDialog = ({ post }: DeletePostDialogProps) => {
const { deletePostById } = useMutationPosts();
const handleDelete = async () => {
+ deletePostById(post.id);
- await deletePost(post.id);
- removePost(post.id);
};
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant={"ghost"} size={"icon"}>
<TrashIcon className="w-4 h-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will permanently delete your post
and remove it from our servers.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDelete}>Continue</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
};
export default DeletePostDialog;
In this update:
useMutationPosts
custom hook.deletePost
API function and updating the store directly, we use the deletePostById
function from the custom hook to delete the post.AddPost
component import { useState } from "react";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
- import { createPost } from "@/data/api";
- import { addPost, toggleAddPost } from "@/lib/store";
+ import { toggleAddPost } from "@/lib/store";
+ import useMutationPosts from "@/hooks/use-mutation-posts";
const AddPost = () => {
const [content, setContent] = useState("");
+ const { addNewPost } = useMutationPosts();
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value);
};
const handleSave = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
- if (content) {
- const post = await createPost(content);
- addPost(post);
+ addNewPost(content);
setContent("");
toggleAddPost();
- }
};
const handleCancel = () => {
setContent("");
toggleAddPost();
};
return (
<form className="grid w-full gap-1.5 p-4 border-b">
<Label htmlFor="content" className="text-sm">
Your post
</Label>
<Textarea
id="content"
placeholder="Type your message here."
value={content}
onChange={handleTextChange}
/>
<div className="flex justify-end gap-3">
<Button type="reset" variant={"secondary"} onClick={handleCancel}>
Cancel
</Button>
<Button type="submit" onClick={handleSave}>
Post
</Button>
</div>
</form>
);
};
export default AddPost;
In this update:
useMutationPosts
custom hook.createPost
API function and updating the store directly, we use the addNewPost
function from the custom hook to add a new post.EditPost
component import { useEffect, useState } from "react";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { PostType } from "@/data/types";
- import { editPost } from "@/data/api";
- import { updatePostContent } from "@/lib/store";
+ import useMutationPosts from "@/hooks/use-mutation-posts";
type EditPostProps = {
post: PostType;
setIsEditing: React.Dispatch<React.SetStateAction<boolean>>;
};
const EditPost = ({ post, setIsEditing }: EditPostProps) => {
const [id, setId] = useState("");
const [content, setContent] = useState("");
+ const { updatePost } = useMutationPosts();
useEffect(() => {
if (post && post.id !== id && post.content !== content) {
setId(post.id);
setContent(post.content);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [post]);
const handleTextChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setContent(e.target.value);
};
const handleSave = async (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault();
- if (content) {
- await editPost(id, content);
- updatePostContent(id, content);
+ updatePost(id, content);
setContent("");
setIsEditing(false);
- }
};
const handleCancel = () => {
setContent("");
setIsEditing(false);
};
return (
<form className="grid w-full gap-1.5 p-4 border-b">
<Label htmlFor="content" className="text-sm">
Your post
</Label>
<Textarea
id="content"
placeholder="Type your message here."
value={content}
onChange={handleTextChange}
/>
<div className="flex justify-end gap-3">
<Button type="reset" variant={"secondary"} onClick={handleCancel}>
Cancel
</Button>
<Button type="submit" onClick={handleSave}>
Post
</Button>
</div>
</form>
);
};
export default EditPost;
In this update:
useMutationPosts
custom hook.editPost
API function and updating the store directly, we use the updatePost
function from the custom hook to update the post content.We have added basic error handling in the custom hooks to log errors to the console. You can enhance the error handling further by providing feedback to the user when an error occurs. For example, you can display an error message in a toast notification or an alert dialog.
Let’s add the Toast component from Shadcn UI to display error messages:
pnpm dlx shadcn@latest add toast
Next, you’ll need to add the Toaster
component to the App
component to display error messages.
import Sidebar from "@/components/sidebar";
import Feed from "@/components/feed";
+ import { Toaster } from "./components/ui/toaster";
function App() {
return (
<div className="flex min-h-dvh">
<div className="flex-1 min-w-14">
<Sidebar />
</div>
<div className="w-full max-w-md mx-auto md:max-w-lg">
<Feed />
</div>
<div className="flex-1">{/* Placeholder for another sidebar */}</div>
+ <Toaster />
</div>
);
}
export default App;
The Toaster
component will display the error messages as toast notifications. It must be placed at the root level of the application to ensure that it is accessible from any component.
useQueryPosts
custom hookNow update the useQueryPosts
custom hook to display an error message in the toast component when an error occurs during the API call:
import { useEffect } from "react";
import { fetchPosts } from "@/data/api";
import { useStore } from "@nanostores/react";
import { setPosts, $posts } from "@/lib/store";
+ import { toast } from "@/components/ui/use-toast";
function useQueryPosts() {
const posts = useStore($posts);
const loadPosts = async () => {
try {
const fetchedPosts = await fetchPosts();
setPosts([...fetchedPosts]);
} catch (error) {
const errorMessage =
(error as Error).message ?? "Please try again later!";
- console.error(errorMessage);
+ toast({
+ variant: "destructive",
+ title: "Sorry! There was an error reading the posts 🙁",
+ description: errorMessage,
+ });
}
};
useEffect(() => {
loadPosts();
}, []);
return { posts };
}
export default useQueryPosts;
useMutationPosts
custom hookSimilarly, update the useMutationPosts
custom hook to display an error message in the toast component when an error occurs during the API call:
import { createPost, deletePost, editPost } from "@/data/api";
import { addPost, removePost, updatePostContent } from "@/lib/store";
+ import { toast } from "@/components/ui/use-toast";
function useMutationPosts() {
const deletePostById = async (postId: string) => {
try {
await deletePost(postId);
removePost(postId);
} catch (error) {
const errorMessage =
(error as Error).message ?? "Please try again later!";
- console.error(errorMessage);
+ toast({
+ variant: "destructive",
+ title: "Sorry! There was an error deleting the post 🙁",
+ description: errorMessage,
+ });
}
};
const addNewPost = async (content: string) => {
try {
if (!content) {
throw new Error("Content cannot be empty!");
}
const newPost = await createPost(content);
addPost(newPost);
} catch (error) {
const errorMessage =
(error as Error).message ?? "Please try again later!";
- console.error(errorMessage);
+ toast({
+ variant: "destructive",
+ title: "Sorry! There was an error adding a new post 🙁",
+ description: errorMessage,
+ });
}
};
const updatePost = async (postId: string, content: string) => {
try {
if (!content) {
throw new Error("Content cannot be empty!");
}
const updatedPost = await editPost(postId, content);
updatePostContent(updatedPost.id, updatedPost.content);
} catch (error) {
const errorMessage =
(error as Error).message ?? "Please try again later!";
- console.error(errorMessage);
+ toast({
+ variant: "destructive",
+ title: "Sorry! There was an error updating the post 🙁",
+ description: errorMessage,
+ });
}
};
return {
deletePostById,
addNewPost,
updatePost,
};
}
export default useMutationPosts;
You can test the error handling by deliberately causing an error such as trying to add a new post with an empty content. You should see the error message displayed in the toast component.
In this task, we created custom hooks to fetch and mutate data in the Posts app. We refactored the components to use these custom hooks for data management, making our code cleaner and more maintainable. We also added basic error handling to the custom hooks and displayed error messages using toast notifications.