This lesson applies useState to concrete blog application features — optimistic like toggles, tag filtering, collapsible sections, and debounced search. Each pattern solves a real UX problem: optimistic updates make the UI feel instant even when waiting for the API; debouncing prevents a flood of API calls while the user is typing; collapsible sections keep long pages manageable. These patterns appear repeatedly across React applications and are worth internalising as standard building blocks.
Optimistic Like Button
import { useState } from "react";
function LikeButton({ postId, initialCount, initialLiked }) {
const [count, setCount] = useState(initialCount);
const [liked, setLiked] = useState(initialLiked);
const [pending, setPending] = useState(false);
async function handleToggle() {
if (pending) return; // debounce rapid clicks
// ── Optimistic update — update UI immediately ─────────────────────────
const wasLiked = liked;
setLiked(!liked);
setCount((c) => wasLiked ? c - 1 : c + 1);
setPending(true);
try {
// ── API call — happens in background ─────────────────────────────
await fetch(`/api/posts/${postId}/like`, { method: "POST" });
} catch {
// ── Rollback on error — restore previous state ────────────────────
setLiked(wasLiked);
setCount((c) => wasLiked ? c + 1 : c - 1);
} finally {
setPending(false);
}
}
return (
<button
onClick={handleToggle}
disabled={pending}
className={`flex items-center gap-1 transition-colors
${liked ? "text-red-500" : "text-gray-400"}
${pending ? "opacity-50" : ""}`}
>
{liked ? "♥" : "♡"} {count}
</button>
);
}
Note: The optimistic update pattern updates the UI before the API call completes, then rolls back if the call fails. This creates a snappy, responsive feel — clicking like changes the heart colour instantly rather than waiting 100–300ms for the API round-trip. The rollback on error maintains data integrity: if the API rejects the like (token expired, network error), the UI returns to its previous correct state. Always handle the rollback case — leaving the UI in an optimistically-updated wrong state is worse than a brief delay.
Tip: Debouncing a search input prevents a new API call on every keystroke. Instead of calling the API for “p”, “py”, “pyt”, “pyth”, “pytho”, “python”, wait until the user stops typing for 300ms and then make one call. Implement with a timeout: in the
onChange handler, clear any existing timeout (clearTimeout(timeoutRef.current)) and set a new one (timeoutRef.current = setTimeout(() => callApi(query), 300)). The useRef hook stores the timeout ID between renders (Chapter 39 covers useRef in detail).Warning: Optimistic updates require careful error handling. If you update state optimistically but then fail to roll back on error, the UI shows incorrect data — a liked post that the server rejected as a duplicate, or a deleted post that failed the ownership check. Always implement the rollback in a
catch block. Consider also showing a toast notification (“Could not save — please try again”) to inform the user of the failure while the UI reverts.Tag Filter with Active State
function PostFeedWithFilter({ posts, allTags }) {
const [activeTag, setActiveTag] = useState(null); // null = show all
const visiblePosts = activeTag
? posts.filter((p) => p.tags?.some((t) => t.slug === activeTag))
: posts;
return (
<div>
{/* Tag filter bar */}
<div className="flex flex-wrap gap-2 mb-6">
<button
onClick={() => setActiveTag(null)}
className={`px-3 py-1 rounded-full text-sm
${activeTag === null
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}
>
All
</button>
{allTags.map((tag) => (
<button
key={tag.id}
onClick={() => setActiveTag(tag.slug === activeTag ? null : tag.slug)}
className={`px-3 py-1 rounded-full text-sm
${activeTag === tag.slug
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-600 hover:bg-gray-200"}`}
>
{tag.name}
</button>
))}
</div>
{/* Filtered posts */}
<PostList posts={visiblePosts} />
</div>
);
}
Collapsible Comment Section
function CommentSection({ postId, commentCount }) {
const [isOpen, setIsOpen] = useState(false);
const [comments, setComments] = useState([]);
const [hasLoaded, setHasLoaded] = useState(false);
async function handleToggle() {
setIsOpen((prev) => !prev);
// Load comments only the first time the section is opened
if (!hasLoaded) {
const data = await fetch(`/api/posts/${postId}/comments`).then((r) => r.json());
setComments(data.items ?? []);
setHasLoaded(true);
}
}
return (
<div className="mt-4 border-t pt-4">
<button
onClick={handleToggle}
className="flex items-center gap-2 text-sm text-gray-500 hover:text-gray-700"
>
<span>{isOpen ? "▲" : "▼"}</span>
{commentCount} {commentCount === 1 ? "Comment" : "Comments"}
</button>
{isOpen && (
<div className="mt-3 space-y-3">
{comments.length === 0 && hasLoaded && (
<p className="text-sm text-gray-400">No comments yet.</p>
)}
{comments.map((c) => (
<div key={c.id} className="text-sm">
<strong>{c.author.name}</strong>: {c.body}
</div>
))}
</div>
)}
</div>
);
}
Debounced Search Input
import { useState, useEffect, useRef } from "react";
function DebouncedSearchBar({ onSearch, debounceMs = 300 }) {
const [query, setQuery] = useState("");
const timeoutRef = useRef(null); // stores timeout ID between renders
function handleChange(e) {
const value = e.target.value;
setQuery(value); // update input display immediately
// Cancel previous timeout
clearTimeout(timeoutRef.current);
// Schedule new search after debounce delay
timeoutRef.current = setTimeout(() => {
onSearch(value);
}, debounceMs);
}
// Clean up timeout when component unmounts
useEffect(() => {
return () => clearTimeout(timeoutRef.current);
}, []);
return (
<input
type="search"
value={query}
onChange={handleChange}
placeholder="Search posts..."
className="w-full border rounded-lg px-4 py-2"
/>
);
}
Common Mistakes
Mistake 1 — Forgetting the rollback in optimistic updates
❌ Wrong — UI stays in wrong state if API fails:
setLiked(true); setCount(c => c + 1);
await fetch("/api/posts/1/like"); // if this throws, no rollback!
✅ Correct — always catch and rollback in optimistic updates.
Mistake 2 — Creating new timeout without clearing the old one
❌ Wrong — many overlapping timeouts, multiple onSearch calls:
setTimeout(() => onSearch(value), 300); // never cleared — piles up!
✅ Correct — clear previous timeout before setting new one.
Quick Reference
| Pattern | State Needed | Key Technique |
|---|---|---|
| Like toggle (optimistic) | liked, count, pending | Update → API → rollback on catch |
| Tag filter | activeTag (string | null) | Derived filtered array from state |
| Collapsible section | isOpen, data, hasLoaded | Load once on first open |
| Debounced search | query (display), useRef (timeout) | clearTimeout + setTimeout in onChange |