Upvotes on the Detail Page
The feed has an upvote button — the detail page should too. Rather than duplicating the optimistic update logic, you’ll extract it into a custom hook that both components share.
The useUpvote hook
Section titled “The useUpvote hook”import { useState } from 'react'import { upvotePost } from '../api/postsApi'
export function useUpvote(initialCount: number, postId: number) { const [upvotes, setUpvotes] = useState(initialCount)
async function handleUpvote() { setUpvotes(prev => prev + 1) try { const { upvotes: serverCount } = await upvotePost(postId) setUpvotes(serverCount) } catch { setUpvotes(prev => prev - 1) } }
return { upvotes, handleUpvote }}The hook encapsulates the three-step optimistic pattern. Any component can use it by passing the initial count and post ID.
Using the hook in PostDetail
Section titled “Using the hook in PostDetail”// src/pages/PostDetail.tsx — excerptimport { useUpvote } from '../hooks/useUpvote'import { useAuth } from '../context/AuthContext'
export default function PostDetail() { // ... existing state const { isAuthenticated } = useAuth()
// Initialize after post loads: const { upvotes, handleUpvote } = useUpvote(post?.upvotes ?? 0, Number(id))
// in JSX: return ( <main> <h1>{post.title}</h1> <p>by {post.username}</p> {isAuthenticated ? <button onClick={handleUpvote}>▲ {upvotes}</button> : <span>{upvotes} votes</span> } {/* ... rest of page */} </main> )}Updating PostFeed to use the hook
Section titled “Updating PostFeed to use the hook”Refactor PostFeed to use useUpvote per post. Since each post is its own list item, create a small PostItem component that uses the hook:
import { Link } from 'react-router-dom'import { useAuth } from '../context/AuthContext'import { useUpvote } from '../hooks/useUpvote'import { Post } from '../api/postsApi'
interface Props { post: Post}
export default function PostItem({ post }: Props) { const { isAuthenticated } = useAuth() const { upvotes, handleUpvote } = useUpvote(post.upvotes, post.id)
return ( <li> <Link to={`/posts/${post.id}`}> <strong>{post.title}</strong> </Link> <span> by </span> <Link to={`/users/${post.userId}`}>{post.username}</Link> {isAuthenticated ? <button onClick={handleUpvote}>▲ {upvotes}</button> : <span> · {upvotes} votes</span> } <span> · {post.commentCount} comments</span> </li> )}Then simplify PostFeed:
// src/components/PostFeed.tsx — simplifiedimport PostItem from './PostItem'
// in JSX:<ul> {posts.map(post => ( <PostItem key={post.id} post={post} /> ))}</ul>Custom hooks: the rule
Section titled “Custom hooks: the rule”A custom hook is just a function whose name starts with use and calls other hooks inside it. The naming convention is what matters — React enforces the rules of hooks (no conditional calls, top-level only) based on the use prefix.
Custom hooks are for sharing stateful logic, not for sharing UI. When you find yourself copying the same useState/useEffect/callback pattern into multiple components, extract it.
Exercise
Section titled “Exercise”Open src/hooks/useUpvote.ts in your Bulletin project (create the file).
- Create
src/hooks/useUpvote.tswith theuseUpvotehook as shown. - Create
src/components/PostItem.tsxusing the hook. - Update
PostFeed.tsxto render<PostItem>instead of inline<li>elements. - Add the upvote button to
PostDetail.tsxusinguseUpvote. - Test both: upvote a post from the feed, then navigate to its detail page and confirm the count is consistent (the server is the source of truth).
- Custom hooks extract shared stateful logic — name them with
useprefix. useUpvoteencapsulates the three-step optimistic pattern: increment, confirm, rollback.- Extract
PostItemfromPostFeedwhen a list item needs its own hook state. - The feed and detail page both use
useUpvote— the logic lives in one place.