State Patterns in Practice — Blog Application State

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

🧠 Test Yourself

In the optimistic like toggle, why must wasLiked be captured before calling setLiked?