This article is published in English.
Store Causes, Derive Consequences: Designing Minimal React State
Learn to spot redundant React state, replace Effect-driven sync chains and boolean flags with derived values and status unions, and decide where state should live.
Most React components do not become hard to change because of one bad decision. They drift there one reasonable-looking useState call at a time, until the same information is stored in three places and nobody can say which copy is authoritative. This guide walks through a realistic product list that falls into that trap, then shows how to decide what a component should actually remember, what it should calculate on every render, and where each remaining piece of state belongs. By the end you will have a concrete review checklist for pruning state before it turns into synchronization bugs.
How a simple product list accumulates state
Picture an internal admin screen that lists products. People can type a name to search, narrow the list to one category, sort by price, pick a single product, and see how many matches are left. The first version holds only what the user typed and chose:
function Products({ products }) {
const [search, setSearch] = useState("");
const [category, setCategory] = useState("all");
// ...
}
Then the feature requests come in. The table has to render the matching products, so someone adds a state variable holding the filtered list:
const [filteredProducts, setFilteredProducts] = useState(products);
A counter above the table shows how many products match, and that number gets its own state as well:
const [resultCount, setResultCount] = useState(products.length);
When nothing matches, the page should show an empty-state message, so a flag appears for that too:
const [hasResults, setHasResults] = useState(true);
Sorting follows, bringing both the chosen sort key and a sorted copy of the list:
const [sortBy, setSortBy] = useState("name");
const [sortedProducts, setSortedProducts] = useState(products);
Each change is small and easy to approve in review. A few weeks later, though, the bug reports start. Switching categories sometimes shows the right rows next to the wrong count. Clearing the search box flashes the "no results" message for a moment. When a fresh API response delivers new products, the table keeps showing an outdated filtered list until the user clicks something.
The component is full of state, yet it can no longer answer the one question that matters: which of these values is the real one?
useState is not to blame. The trouble starts when a component keeps several stored versions of information that could all be computed from a smaller set of underlying facts. Every extra stored value is one more thing that has to be kept in step with the others, and keeping things in step is exactly where simple components turn fragile.
Every stored value is another way to be wrong
Components need state because some information must survive between renders: the text in an input, the active tab, whether a modal is open, which row the user picked. Those are natural fits.
The mistake is treating "this shows up on screen" as if it meant "this must be stored". Look again at the product page with every value held in state:
const [search, setSearch] = useState("");
const [category, setCategory] = useState("all");
const [filteredProducts, setFilteredProducts] = useState(products);
const [resultCount, setResultCount] = useState(products.length);
const [hasResults, setHasResults] = useState(true);
Each variable has a sensible name, but they are not independent of each other. The filtered list is a function of three inputs:
products + search + category
The count is a function of the filtered list:
filteredProducts.length
And the empty-state flag is a function of the count:
resultCount > 0
A handful of genuine facts has been expanded into several stored consequences. That opens the door to combinations the interface should never be able to display, such as this one:
filteredProducts = []
resultCount = 4
hasResults = true
React will happily hold these values. You declared three independent pieces of state, so React treats them as three independent pieces of state. Keeping them logically consistent is entirely your application's job, and every event handler that touches one of them has to remember the others.
The React documentation advises against redundant and duplicated state for precisely this reason: when a value can be computed from props or other state during render, storing it separately only creates a new opportunity for the copies to disagree.
The takeaway is not "call useState fewer times". It is a shift in what you consider state to be:
Remember the facts the component cannot recover any other way. Compute everything that follows from them.
Keep inputs in state and compute the rest
The product page never needed to remember resultCount. What it needs to remember is what the user typed into the search box and which category they picked. Those are choices made by a person, and nothing in the product data can reconstruct them. Everything else follows:
function Products({ products }) {
const [search, setSearch] = useState("");
const [category, setCategory] = useState("all");
const filteredProducts = products.filter((product) => {
const matchesSearch = product.name
.toLowerCase()
.includes(search.toLowerCase());
const matchesCategory =
category === "all" || product.category === category;
return matchesSearch && matchesCategory;
});
const resultCount = filteredProducts.length;
const hasResults = resultCount > 0;
// ...
}
Notice what has disappeared. Nothing has to update resultCount, hasResults has no setter, and there is no longer any path where one handler refreshes the filtered list but forgets the count. Each render simply recomputes the outputs from the current inputs. A new search string produces a new list, a new category produces a new list, and if the parent passes a different products array, the calculation just uses it.
The component now has fewer writable variables, which is a far more meaningful improvement than having fewer lines. A derived value can still contain a logic error, but it can never be stale because some handler forgot to refresh it. That removes a whole family of states the component could previously reach.
The React docs illustrate the same idea with a fullName built from first and last name: if you can compute it while rendering, a separate state variable adds nothing except a chance for inconsistency.
A quick test you can apply during code review:
If I deleted this state variable,
could I reconstruct its value exactly
from current props and other state?
If the answer is yes, start by making it a plain calculation. State exists to hold information, not to cache every intermediate result the component happens to produce along the way.
When useEffect becomes a synchronization pipeline
A common reaction to stale derived state is to reach for useEffect and keep the copy updated automatically. The product component then grows something like this:
const [filteredProducts, setFilteredProducts] = useState(products);
useEffect(() => {
const nextProducts = products.filter((product) => {
const matchesSearch = product.name
.toLowerCase()
.includes(search.toLowerCase());
const matchesCategory =
category === "all" || product.category === category;
return matchesSearch && matchesCategory;
});
setFilteredProducts(nextProducts);
}, [products, search, category]);
Next, a second Effect keeps the count aligned with the list:
useEffect(() => {
setResultCount(filteredProducts.length);
}, [filteredProducts]);
And perhaps a third Effect drives the empty-state flag:
useEffect(() => {
setHasResults(resultCount > 0);
}, [resultCount]);
Together they form a small internal pipeline:
products/search/category
↓
filteredProducts
↓
resultCount
↓
hasResults
None of these steps talk to anything outside React. They only transform values React already holds, and that distinction is the heart of the matter. The React docs treat Effects as an escape hatch meant for keeping a component in step with something React does not control, such as a browser API, a socket, or a non-React widget. When an Effect exists only to set one piece of component state in response to another, the current guidance is to ask whether that second piece of state should exist at all.
There is also a runtime cost that is easy to miss. Each Effect runs after React has already committed the render, so every link in the chain triggers another render with partially updated values. That is exactly where the brief "no results" flash in the opening scenario comes from: for one render, the new list exists but the flag still reflects the old count.
The calculated version has no chain at all:
const filteredProducts = filterProducts(
products,
search,
category
);
const resultCount = filteredProducts.length;
const hasResults = resultCount > 0;
This is more than tidier syntax. It changes what you have to think about. With stored derived state, you track when each value was last written, whether the relevant Effect has already run, whether its dependency array is complete, and whether another update is still queued behind it. With a calculation, you think about inputs and outputs, and for pure transformations that is a far easier model to maintain. If your codebase already has Effects of this kind, the step-by-step refactor in Stop Syncing State with useEffect covers how to remove them safely.
Replace boolean flags with a single status
Duplicated values are one flavor of excess state. Another shows up when a single concept is spread across several independent booleans. A form submission is the classic case:
const [isIdle, setIsIdle] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);
The flow is meant to be in exactly one of four phases:
idle
submitting
success
error
Four booleans, however, can express sixteen combinations, and many of them are nonsense. The form can claim to be submitting and already successful at once:
isSubmitting = true
isSuccess = true
Or it can report success and failure simultaneously:
isSuccess = true
isError = true
Or every flag can be false, which matches none of the phases. The UI may never deliberately produce these combinations, but the data structure permits them, so a missed setter call in one handler is enough to reach them. React's guidance on structuring state explicitly recommends avoiding contradictions like this and cutting down variables that let impossible UI states be expressed.
A single status value describes the concept far more precisely. In TypeScript, a string-literal union also lets the compiler reject typos and unknown phases:
type Status =
| "idle"
| "submitting"
| "success"
| "error";
const [status, setStatus] = useState<Status>("idle");
The convenient booleans are still available, now as derived values:
const isSubmitting = status === "submitting";
const isSuccess = status === "success";
const isError = status === "error";
The difference looks minor but is fundamental. The first version asks your code to keep four facts in agreement. The second stores one fact and reads four views of it.
The payoff grows with the component. A checkout might move through these phases:
editing
validating
submitting
confirmed
failed
A file importer might move through these:
idle
uploading
processing
completed
failed
Whenever a component has modes that exclude one another, make that exclusion part of the state model instead of a rule every handler must respect. You are deciding which states the program is even able to represent, and that decision deserves as much care as the markup. One caveat: if a phase carries data, such as an error message that only exists in the failed phase, a discriminated union of objects keeps that data attached to the right phase instead of adding another loose variable.
Fewer variables is not the same as one big object
Once a team hears "reduce state", a tempting overcorrection is to pour everything into a single object:
const [state, setState] = useState({
search: "",
category: "all",
selectedProductId: null,
sidebarOpen: false,
page: 1,
});
That is not an improvement by default. Separate useState calls carry no meaningful cost, so minimizing their number is not a goal. The goal is to represent independent information clearly and to avoid storing the same truth twice.
search and category change on their own schedules, and sidebarOpen has nothing to do with either of them. Keeping them as separate variables makes every update obvious at the call site:
const [search, setSearch] = useState("");
const [category, setCategory] = useState("all");
const [selectedProductId, setSelectedProductId] =
useState<string | null>(null);
const [sidebarOpen, setSidebarOpen] = useState(false);
The React docs draw the same line. Values that always change together can be worth grouping, while redundant, contradictory, duplicated or deeply nested data should be reduced. Merging unrelated values also has a practical downside: every update has to spread the previous object, and forgetting to do so silently wipes out the other fields.
So the useful question is not whether a set of values could fit in one object. Nearly anything can. Ask instead:
Do these values form one coherent piece of state whose transitions belong together?
When they do, grouping them can make the code clearer. When they do not, a combined object just obscures which update touches what. Reducing state is about eliminating knowledge that is stored twice, not squeezing the component into as few Hooks as possible.
Deriving values without ignoring performance
Moving a filtered list out of state usually prompts one objection: doesn't the filter now run on every render? It does, and for most everyday transformations that is exactly the right trade. Filtering an array of moderate size is cheap, and computing it inline keeps the component simple without any noticeable cost.
If profiling shows that a transformation really is expensive, for example a large list that is filtered and sorted, you can cache the result with useMemo:
const filteredProducts = useMemo(() => {
return products
.filter((product) => {
const matchesSearch = product.name
.toLowerCase()
.includes(search.toLowerCase());
const matchesCategory =
category === "all" ||
product.category === category;
return matchesSearch && matchesCategory;
})
.sort(compareProducts);
}, [products, search, category, sortBy]);
Pay attention to what useMemo does not change. filteredProducts has not become writable state again. It is still a pure function of its inputs; memoization only decides whether React can reuse the previous result on a given render instead of recomputing it. That keeps correctness and optimization as separate concerns. The React docs present useMemo strictly as a performance optimization and warn against depending on it for correct behavior, because React may discard cached values.
The order of work that follows from this:
First make the state model correct.
Then measure.
Then optimize expensive calculations if necessary.
Using state as a hand-rolled cache flips that order. It adds synchronization complexity up front, before anyone has shown that the computation is slow. It is also worth checking that a memoized calculation lists every input in its dependency array; in the example above, sortBy is listed because the sort comparator is expected to depend on it.
Put each piece of state where the decision is shared
Even state that genuinely needs to exist can cause trouble if it sits in the wrong component. Suppose each product row tracks its own selection:
function ProductRow({ product }) {
const [selected, setSelected] = useState(false);
// ...
}
That works as long as every row can be toggled independently. Now the requirement changes: only one product may be selected at a time. Suddenly several sibling components each hold their own copy of what should be a single shared fact, namely which product is selected. When that answer matters to multiple siblings, their parent should own it:
function ProductTable({ products }) {
const [selectedProductId, setSelectedProductId] =
useState<string | null>(null);
return products.map((product) => (
<ProductRow
key={product.id}
product={product}
selected={product.id === selectedProductId}
onSelect={() => setSelectedProductId(product.id)}
/>
));
}
Rows no longer store selection at all. They receive a boolean and a callback, and there is exactly one source of truth:
selectedProductId
The React docs frame this as giving every distinct piece of state exactly one owning component. When several components must coordinate around the same information, lifting it to their closest shared parent keeps the copies from diverging.
None of this argues for hoisting everything to the root of the app. State that nothing else needs should stay local. Whether a tooltip is visible has no business living next to authentication data, and a half-typed form field rarely justifies a global store. State is easiest to manage when its owner lines up with how widely the underlying decision is shared. Place it too low and components duplicate the truth; place it too high and distant parts of the app start re-rendering for changes that are irrelevant to them. Finding that boundary is a large part of good state design, and Rethinking React State: Where Your Data Should Actually Live goes deeper into the local, shared, server and URL options.
Reducers organize transitions, not the model
When a component collects many setters, moving to useReducer is a common next step, and often a good one. Instead of a handler that makes several coordinated calls like these:
setStatus("submitting");
setError(null);
setLastAttempt(Date.now());
you describe what happened as a single event:
dispatch({ type: "submitted" });
A reducer gathers transitions in one place, which pays off when several genuinely related values change together. What it cannot do is make redundant data stop being redundant. This initial state is still suspect:
const initialState = {
search: "",
products: [],
filteredProducts: [],
resultCount: 0,
hasResults: true,
};
Packing duplicated values into a reducer leaves the synchronization problem intact; it simply relocates the synchronization logic into the reducer. Every action might update all the copies correctly today, but the model still allows several stored versions of the same information, and the next action someone adds can miss one.
A better reducer keeps only the inputs:
const initialState = {
search: "",
category: "all",
sortBy: "name",
};
The visible product list is then derived during render from the reducer state plus the current products. useReducer is a good tool when transitions get complicated, but it does not replace the more basic question of what the component actually needs to remember. Answer that first, then pick the tool that manages it.
A checklist for reviewing component state
useState makes adding state nearly frictionless, and that ease hides the architectural cost. Each new variable is another value that can change on its own. If it duplicates something already available, you now need rules for keeping both versions aligned. One duplicate is manageable. Five of them bring a tangle of Effects and dependency lists, setters that trigger other setters, reset code, stale reads, conflicting flags, and bugs that only surface after one specific sequence of clicks. The fix is rarely a smarter synchronization mechanism; usually, the synchronization should not exist in the first place.
When a component's state keeps growing, go through each stored value and ask:
- Does it represent a decision made by the user or the system?
- Does the component need to remember it across renders?
- Could you rebuild it precisely from the current props or the rest of the state?
- Is there any sequence of events in which it disagrees with another stored value?
- Does some other component hold a copy of the same fact?
- Is it owned by the component whose subtree actually shares that decision?
These questions tell you far more than a tally of Hooks does. A component holding eight independent, necessary pieces of state can be perfectly well designed, while one with three variables already has too much if two of them are copies or consequences of the third.
Key takeaways
- Store causes such as user input and selections; derive consequences such as filtered lists, counts and flags during render.
- An Effect that only sets state from other state is a sign that the second value should be a calculation.
- Model mutually exclusive modes as one status value so that impossible combinations cannot be represented.
- Group values only when they change together; one large object is not a goal in itself.
- Reach for
useMemoafter measuring, and remember it is a cache, not a source of truth. - Give shared facts a single owner at the lowest common parent, and keep purely local UI state local.
- When a component becomes hard to modify, ask what real-world fact each state variable stands for before adding another setter. The state that is easiest to keep in sync is the state you never stored.