A filterable project list looks simple: a text box, a few projects, and a result count. Yet it is easy to turn that screen into four state variables and a chain of Effects. When every displayed value gets its own setter, keeping the screen consistent becomes a second job.
A useful starting question is: which values contain information that cannot be calculated from the others? Those are the candidates for state. The rest can often be ordinary expressions.
Give each fact one home
Suppose a portfolio receives a list of projects and lets the visitor search by title. The query is a user decision, so it belongs in state. The matching projects and their count can be calculated from that query and the supplied list.
React's documentation recommends calculating derived values during rendering when possible. Using an Effect to copy a calculation into another state variable adds a synchronization step and an extra update. See You Might Not Need an Effect.
Here is a small, self-contained example:
"use client"; import { useId, useState } from "react"; type Project = { id: string; title: string }; export function ProjectSearch({ projects }: { projects: Project[] }) { const inputId = useId(); const [query, setQuery] = useState(""); const search = query.trim().toLowerCase(); const matches = projects.filter((project) => project.title.toLowerCase().includes(search) ); return ( <section> <label htmlFor={inputId}>Search projects</label> <input id={inputId} type="search" value={query} onChange={(event) => setQuery(event.target.value)} /> <p role="status">{matches.length} projects found</p> <ul> {matches.map((project) => ( <li key={project.id}>{project.title}</li> ))} </ul> </section> ); }
There is one state variable. Clearing the search, receiving new project data, and changing the query all use the same calculation. A separate resultCount setter would add another place where the implementation could disagree with itself.
Notice the less glamorous details too: the input has a label, each row has a stable identifier, and whitespace-only input shows the full list. Simpler state leaves more attention for those visible behaviors.
Put actions beside their trigger
Imagine adding a “Save shortlist” button. Saving is a response to a click. Put the request in that button's event handler, along with pending and error handling. A flag such as shouldSave, watched by an Effect, spreads one action across two places.
Effects still have a clear purpose: keeping a component synchronized with something outside React, such as a subscription or a browser API. Set up the connection in an Effect and release it in cleanup. React's Synchronizing with Effects guide explains this distinction and why development checks exercise setup and cleanup.
For a live project feed, subscribing to updates is synchronization; filtering the received projects is calculation. Keeping those responsibilities separate makes it easier to investigate a stale connection without also untangling the search box.
Measure before adding a cache
Filtering a small portfolio list is unlikely to justify elaborate caching. If a real workload is slow, measure it first. useMemo can cache an expensive calculation between renders when its dependencies are unchanged, but correctness should not depend on that cache. The React guide describes how to evaluate this tradeoff.
This example makes no performance claim. A large data set might need pagination, server search, or a different indexing strategy. A memoized array scan does not eliminate the work when the query changes.
Review behavior, not the number of hooks
For this component, useful checks include an empty query, mixed case, no matches, and a new project list arriving while a query is active. Each check describes something a visitor can observe.
For your next component review, write down its independent inputs, its calculated outputs, and its external connections. If two state variables express the same fact, try removing one. The payoff is a component with fewer opportunities to contradict itself.