Lifting State Up — Sharing State Between Components

State lives in a component and is only directly accessible by that component and components it renders (via props). When two sibling components need to share state — a search input and the list it filters, a selected tag and the posts filtered by it — the state must be lifted to their nearest common ancestor. The ancestor owns the state, and the siblings receive the current value and an update callback as props. This “lifting state up” pattern is React’s fundamental mechanism for sharing state without a global store.

Lifting State — Tag Filter Example

// ── BEFORE lifting — each component manages its own state, no sharing ────────

// ❌ Wrong — each TagBadge controls its own selection, no way to
//   tell PostList which tag is selected
function TagBadge({ tag }) {
    const [isSelected, setIsSelected] = useState(false);   // local state
    return (
        <button onClick={() => setIsSelected(!isSelected)}>
            {tag.name}
        </button>
    );
}

// ── AFTER lifting — ancestor owns state, children receive props ──────────────

// ✓ PostFeed lifts state and coordinates children
function PostFeed({ posts, allTags }) {
    // State lifted to common ancestor
    const [selectedTag, setSelectedTag] = useState(null);

    const filtered = selectedTag
        ? posts.filter((p) => p.tags.some((t) => t.slug === selectedTag))
        : posts;

    return (
        <div>
            {/* TagFilter receives state as prop and setter as callback */}
            <TagFilter
                tags={allTags}
                selected={selectedTag}
                onSelect={setSelectedTag}
            />
            {/* PostList receives derived filtered data */}
            <PostList posts={filtered} />
        </div>
    );
}

function TagFilter({ tags, selected, onSelect }) {
    return (
        <div className="flex flex-wrap gap-2 mb-4">
            <button
                onClick={() => onSelect(null)}
                className={selected === null ? "font-bold" : "text-gray-500"}
            >
                All
            </button>
            {tags.map((tag) => (
                <button
                    key={tag.id}
                    onClick={() => onSelect(tag.slug)}
                    className={selected === tag.slug ? "font-bold text-blue-600" : "text-gray-500"}
                >
                    {tag.name}
                </button>
            ))}
        </div>
    );
}
Note: The pattern of passing a state value and its setter as separate props is so common it has a name: the state + setter pair. selected={selectedTag} onSelect={setSelectedTag}. The child component calls onSelect(newValue), which calls setSelectedTag(newValue) in the parent, triggering a re-render of the parent and flowing the new selected prop back down to the child. The data always flows in one direction: down via props, up via callbacks.
Tip: Before lifting state, ask: “which is the lowest component in the tree that needs this state?” If only one component needs it, keep it local. If two siblings need it, lift to their parent. If the entire page needs it, lift to the page component. If multiple pages need it, consider a global state store (Chapter 39). Lifting state too high causes unnecessary re-renders — every keystroke in a search input re-renders the entire app if the state is in the root component.
Warning: Lifting state causes the ancestor to re-render on every state change, which cascades to all of its children. If the parent renders a large, expensive component tree and the state changes frequently (like a text input that updates on every keystroke), this can cause performance issues. The remedy is either to keep fast-changing state lower in the tree (and sync it to the ancestor less frequently), or to use React.memo to skip re-renders for components that did not receive new props.

LikeButton State Lifting

// ── Lifted: PostCard owns like state for the post ────────────────────────────
function PostCard({ post }) {
    const [likeCount,  setLikeCount]  = useState(post.like_count ?? 0);
    const [hasLiked,   setHasLiked]   = useState(false);

    function handleLike() {
        if (hasLiked) {
            setLikeCount((c) => c - 1);
            setHasLiked(false);
        } else {
            setLikeCount((c) => c + 1);
            setHasLiked(true);
        }
    }

    return (
        <article>
            <h2>{post.title}</h2>
            {/* LikeButton receives state + callbacks via props */}
            <LikeButton
                count={likeCount}
                liked={hasLiked}
                onLike={handleLike}
            />
        </article>
    );
}

function LikeButton({ count, liked, onLike }) {
    return (
        <button
            onClick={onLike}
            className={liked ? "text-red-500" : "text-gray-400"}
        >
            {liked ? "♥" : "♡"} {count}
        </button>
    );
    // No local state — pure presenter, all data from props
}

Common Mistakes

Mistake 1 — Lifting too high (every input triggers page-level re-render)

❌ Wrong — search state at app root re-renders entire tree on every keystroke:

function App() {
    const [search, setSearch] = useState("");   // too high!
    // Every keystroke re-renders the entire application
}

✅ Correct — lift only as high as needed; keep state local to the feature.

Mistake 2 — Duplicating state that already exists in an ancestor

❌ Wrong — child duplicates parent’s state:

function PostCard({ post }) {
    const [title, setTitle] = useState(post.title);   // copies parent data
    // Now you have two sources of truth — which one is correct?
}

✅ Correct — read directly from props; do not copy props into state unless you need independent local modifications.

Quick Reference

Pattern Code
Lift state Move useState to nearest common ancestor
Pass value down <Child value={state} />
Pass setter down <Child onUpdate={setState} />
Child updates parent props.onUpdate(newValue)
Derived state (avoid) Compute from lifted state instead of useState

🧠 Test Yourself

A SearchBar and a PostList are siblings — both children of PostFeed. The search query from SearchBar needs to filter PostList. Where should the search query state live?