Optimistic Updates
An optimistic update changes the UI before the server responds. If the server succeeds, nothing changes — the UI was already right. If the server fails, you roll back. This makes interactions feel instant instead of sluggish.
The upvote API call
Section titled “The upvote API call”// src/api/postsApi.ts (add to existing file)export interface UpvoteResponse { upvotes: number}
export async function upvotePost(id: number): Promise<UpvoteResponse> { const res = await apiClient.post<UpvoteResponse>(`/posts/${id}/upvote`) return res.data}The server returns the new vote count — you’ll use this to reconcile state.
Adding an upvote button to PostFeed
Section titled “Adding an upvote button to PostFeed”Add upvote functionality directly to the post list. Each post needs its own vote state:
// src/components/PostFeed.tsx — updatedimport { useState, useEffect } from 'react'import { Link } from 'react-router-dom'import { useAuth } from '../context/AuthContext'import { getPosts, upvotePost, Post } from '../api/postsApi'
export default function PostFeed() { const [posts, setPosts] = useState<Post[]>([]) const [loading, setLoading] = useState(true) const [error, setError] = useState<string | null>(null) const { isAuthenticated } = useAuth()
useEffect(() => { getPosts() .then(setPosts) .catch(() => setError('Failed to load posts.')) .finally(() => setLoading(false)) }, [])
async function handleUpvote(postId: number) { // Optimistic update — increment immediately setPosts(prev => prev.map(p => p.id === postId ? { ...p, upvotes: p.upvotes + 1 } : p) ) try { const { upvotes } = await upvotePost(postId) // Reconcile with server value setPosts(prev => prev.map(p => p.id === postId ? { ...p, upvotes } : p) ) } catch { // Rollback setPosts(prev => prev.map(p => p.id === postId ? { ...p, upvotes: p.upvotes - 1 } : p) ) } }
if (loading) return <p>Loading...</p> if (error) return <p>{error}</p>
return ( <ul> {posts.map(post => ( <li key={post.id}> <Link to={`/posts/${post.id}`}> <strong>{post.title}</strong> </Link> <span> by {post.username}</span> {isAuthenticated && ( <button onClick={() => handleUpvote(post.id)}> ▲ {post.upvotes} </button> )} {!isAuthenticated && <span> · {post.upvotes} votes</span>} <span> · {post.commentCount} comments</span> </li> ))} </ul> )}The three-step pattern
Section titled “The three-step pattern”1. Update state immediately (optimistic)2. Send the request3a. Success → reconcile with server value3b. Failure → rollbackStep 3a is important even on success: the server might have a different vote count than you assumed (e.g., a race condition where another user voted at the same moment). Using the server’s returned count keeps your UI accurate.
Immutable state updates
Section titled “Immutable state updates”setPosts(prev => prev.map(p => p.id === postId ? { ...p, upvotes: p.upvotes + 1 } : p))This pattern never mutates p directly — it creates a new object with { ...p, upvotes: ... }. React detects the change and re-renders. Mutating the object directly (p.upvotes++) would not trigger a re-render.
When to use optimistic updates
Section titled “When to use optimistic updates”Use them for fast, reversible actions where the server is very likely to succeed:
- Upvotes, likes, reactions
- Bookmark / follow toggles
- Simple preference changes
Avoid them for actions where failure is common or costly:
- Creating new data (a failed post create needs its own error UI)
- Deleting (wait for the server before removing from the list)
- Anything involving payment or sensitive data
Exercise
Section titled “Exercise”Open src/components/PostFeed.tsx in your Bulletin project.
- Add
upvotePosttosrc/api/postsApi.ts. - Update
PostFeed.tsxto includehandleUpvotewith the three-step optimistic pattern. - Add the upvote button — show it only when
isAuthenticated, show a plain count otherwise. - Test it: click the upvote button on a post and confirm the count updates immediately. Open your SQLite database or call
GET /posts/:idin Thunder Client and confirm the count also updated on the server. - To test rollback: temporarily change
upvotePostto throw an error and confirm the count reverts after clicking.
- Optimistic updates change state before the server responds — rollback on failure.
- Always reconcile with the server’s returned value, even on success.
- Use
{ ...p, field: newValue }to update a single item in an array immutably. - Best suited for fast, reversible, low-risk actions like upvotes.