React best practices and anti-patterns: composition over cleverness
React has been the dominant way to build web UIs for more than a decade, and the accumulated folk wisdom about how to use it well is now larger than the library itself. Some of that wisdom is durable. Some was correct in 2017 and has aged poorly. Some — especially the advice about memoization, effects, and state management — has been quietly contradicted by the React team’s own guidance without the ecosystem fully updating.
This post is an attempt to separate the best practices that survive from the anti-patterns that keep appearing in large codebases, and to say why in each case. The underlying theme, if there is one: a lot of React problems are self-inflicted, created by over-thinking the parts of the library that are actually simple and under-thinking the parts that are not.
State should live as low as it can
The single most common source of needless complexity in a React codebase is state stored higher up the tree than it needs to be.
The rule, from the React docs, is to colocate state — keep it in the lowest component that actually uses it. Only lift it when two siblings need to read or mutate the same state. Lifting any higher is premature; it widens the surface area of re-renders and forces intermediate components to pass props they don’t care about.
The anti-pattern is the inverse: global stores of state that a
single leaf component consumes. “We put it in Redux / Zustand /
context so it would be easy to access anywhere” is a common
justification that ignores the fact that anywhere was already
possible via a local useState a few levels down, without any of
the cost.
The instinct to globalize often comes from anticipating future shared use. In practice that sharing rarely materializes, and if it does, lifting the state at that point is a small refactor. Until then, the state should live next to the code that uses it.
Derive, don’t synchronize
The second common source of complexity: state that is a function
of other state, stored as its own state and kept in sync with a
useEffect.
// anti-pattern
const [items, setItems] = useState([]);
const [count, setCount] = useState(0);
useEffect(() => { setCount(items.length); }, [items]);
This is three bugs waiting to happen. The count can go stale. The effect fires a render after the state it describes. The invariant “count equals items.length” is enforced by a side effect rather than by the code that reads it.
The fix is to compute:
const [items, setItems] = useState([]);
const count = items.length;
No effect, no second state, no sync. If the derivation is
expensive, wrap it in useMemo — but only after measuring, and
usually it isn’t expensive enough to matter.
This pattern generalizes. Any time you find yourself writing
useEffect(() => setY(f(x)), [x]), replace it with
const y = f(x). The React docs have a whole page on this titled
“You might not need an effect,” and it is worth reading in full.
useEffect is for synchronizing with external systems
The most misunderstood hook in the library is useEffect, and most
React bugs that take more than an hour to debug involve one.
The correct mental model, per the React team: effects are for synchronizing React state with systems outside React. Network requests. DOM APIs the library doesn’t own. Subscriptions to browser events, websockets, timers. Things React cannot observe on its own.
Effects are not for:
- Running code when props change (just run it during render, or in an event handler).
- Updating state in response to other state (derive it instead).
- Transforming data before showing it (compute it in the render body).
- Reacting to user events like clicks (use event handlers, which
run before rendering and give you access to
eventobjects).
The test: if the effect could fire because some unrelated state in the component changed and re-rendered it, and that firing would cause a bug, the effect is doing the wrong job. Effects fire on every matching dependency change, not just the one you had in mind.
Effect dependencies are the other half of the trap. The linter will tell you to include everything the effect closes over. The correct response is almost always to do so — or to restructure the effect so it doesn’t need the dependency. The incorrect response, very common in older codebases, is to suppress the lint rule and hope nothing changes. This always ends the same way.
Memoization is not free and not automatic insurance
useMemo, useCallback, and React.memo exist and do what they
say. They are also, in most application code, applied
prophylactically by developers who have heard memoization is good
and assume more of it is better.
The reality: each of these adds a cost. useMemo and useCallback
both run on every render to check dependencies; the check itself is
not free, and the retained reference costs memory. React.memo
adds a prop-comparison step before skipping the render. If the
thing you’re memoizing is cheap — a string concatenation, a small
object literal — the memoization costs more than the work it
avoids.
The guidance from the React team, quietly restated over several years: don’t memoize unless you have a measured reason to. The reason is almost always one of:
- A downstream component is expensive to render, and the memoization prevents a re-render that would otherwise happen because a reference changed identity.
- A value feeds into a hook dependency array, and a stable reference is required to avoid an effect firing on every render.
- The computation itself is genuinely expensive — large list transforms, cryptographic work, heavy parsing.
None of these is “a component renders a few times and I feel nervous about it.” Re-renders are cheap in most cases. The React reconciler is fast. The cost of memoizing everything exceeds the cost of rendering most things.
The React Compiler, in the process of rolling out, promises to remove this concern by memoizing automatically where it would help. Until it is default, the right mental default is: write the straightforward code, measure, memoize only the hot spots.
Controlled and uncontrolled: pick one per input
Forms are where a surprising fraction of React bugs live, and most of them come from inputs that are neither fully controlled nor fully uncontrolled.
A controlled input has its value stored in React state, with
value and onChange props both wired. React is the source of
truth; the DOM reflects it.
An uncontrolled input stores its value in the DOM, accessed via a
ref when needed. The DOM is the source of truth.
Both work. The anti-pattern is the accidental mix: a value prop
without an onChange, an initial state that tries to “seed” a
later-uncontrolled input, a form library that sometimes owns the
value and sometimes doesn’t. React will log warnings for the
obvious cases; the subtle cases just produce forms that fight the
user’s input.
The working guidance:
- Default to controlled for forms where you need to validate, show derived UI, or submit structured data. It is more code but the behavior is predictable.
- Use uncontrolled when the form is a thin wrapper around native behavior — a search box that submits on enter, a file input, a one-off modal. The DOM does most of the work.
- Pick one per input and don’t cross the streams.
Form libraries like React Hook Form exist primarily to paper over this distinction — they store values in refs (uncontrolled under the hood) while giving you a controlled-feeling API. They are worth adopting once a form grows past a few fields.
Keys are not a detail
key on list items is a prop that React uses to decide whether two
elements across renders are the “same” element. Getting it wrong
produces bugs that look like state corruption: inputs showing the
wrong value, components preserving state from the wrong item,
animations jumping.
The anti-patterns, in order of how often they appear:
- Array index as key. Works as long as the list is append-only and never reorders. Breaks the moment you insert, delete, or sort. The classic symptom: an input’s value follows the position, not the row.
- Random key per render. Defeats reconciliation entirely; every item is a fresh mount. Usually a sign someone was trying to force a remount and didn’t realize the cost.
- Non-unique key. React warns, then picks whichever duplicate it sees first and produces confusing behavior.
The correct key is a stable identifier from the data — a database id, a slug, a hash of the content if no id is available. The key should survive reordering, insertion, and deletion; that is its whole purpose.
Composition over configuration
The React mental model rewards composing small components more than
configuring a single component with many props. A <Button> with
twenty props — icon, iconPosition, loading, loadingText,
tooltip, tooltipPosition, and so on — is a smell. A
<Button> that accepts children and a few variants, with
<IconButton>, <LoadingButton>, <Tooltip> composing around it,
is usually simpler.
The principle generalizes into slot-based APIs — components that
accept named children to fill in regions — and into compound
components where a parent and several children share state via
context and present a coordinated interface (<Tabs>,
<Tabs.List>, <Tabs.Panel>). Both are harder to design than a
flat prop API and easier to use once designed. They preserve
composition — the user can insert their own elements between the
slots — where a prop-bag API can only do what the props allow.
The anti-pattern is the giant configurable component: the
<DataTable> with 40 props that tries to anticipate every use
case. It works for the cases the author thought of and fails
awkwardly for the rest. The composable version, where the table
exposes smaller pieces the consumer assembles, is longer to write
but outlives more product requirement changes.
Server state is not client state
A category of bug that barely existed in 2016 and is now one of the largest categories: treating server data as if it were local state.
Client state is state the app owns: which tab is selected, is this modal open, what has the user typed. It lives in React. It is small, fast, and synchronous.
Server state is state the server owns: the current list of users, the latest price, the message thread. It lives on the server. It is large, possibly stale, asynchronous, and shared across clients.
The anti-pattern: fetching server data in a useEffect, storing
it in useState, and building caching, invalidation, deduplication,
retry, and background refresh on top of that primitive. Every team
that does this reinvents the same infrastructure badly. Every team
that does it for a third entity type realizes they have built a
client-side ORM.
The alternative, now a near-universal pattern: a library that specializes in server state. TanStack Query (formerly React Query), SWR, Apollo for GraphQL, RTK Query for Redux users. All of them turn server data into a hook call that handles fetching, caching, invalidation, and refetching as a first-class concern. None of them is perfect; all of them are better than rolling it yourself.
The separation matters because the two kinds of state want
different tools. Client state is fine in useState, useReducer,
or a small store. Server state wants caching, TTLs, refetch
policies, request deduplication — an entirely different shape that
no useState is going to provide.
Context is for wide, slowly-changing values
React context solves a specific problem: passing a value through a deep tree without prop-drilling. It is not a state management solution. Two anti-patterns recur:
- Storing frequently-changing values in context. Every consumer re-renders on every change. If the value in context is “the current cursor position” or “the time,” every component in the subtree re-renders on every tick. Context doesn’t scope subscriptions — it broadcasts.
- Putting everything in one context. A single
AppContextwith user, theme, router state, and ephemeral UI state means that any change re-renders everyone. Splitting into narrow contexts — one per concern — is almost always worth it.
Context works well for values that are wide (many consumers) and slow (change rarely or never): the current user, the theme, the locale, the router. It works poorly for values that are hot. Libraries like Zustand, Jotai, and Redux solve the hot-value problem by letting consumers subscribe to slices, which is something context cannot do directly.
Don’t fight the reconciler
Several patterns exist for “making React re-render.” A key that
changes to force a remount. A state variable incremented on
forceUpdate. An effect that calls setState to kick
reconciliation. Each of these solves a real problem in isolation
and collectively tends to be a sign that the component’s data flow
is upside-down.
The working version: if a piece of UI depends on some input, make the input a prop or a piece of state the component reads. Rendering is a pure function of props and state; if the function is pure, React re-renders when the inputs change. If you are forcing re-renders, the inputs probably aren’t the inputs.
The common exception: truly resetting a component when some
external identity changes. Changing key on <Form key={userId}>
is a valid way to say “when the user changes, blow away the form
state.” It is not a trick; it is the documented way to express “a
new user’s form is a new component.” The trick-versus-idiom line is
whether the key corresponds to a real identity.
Don’t Redux everything
Redux, and the Redux-shaped successors, solved a real problem in 2016 — shared mutable state across a large component tree with predictable updates and time-travel debugging. The tradeoff was a lot of boilerplate and an indirection layer for every piece of state.
Since then, three things have happened:
useState,useReducer, and context cover most of what Redux covered, with much less ceremony.- TanStack Query and friends took over server state, which was the largest slice of what people stored in Redux.
- Lightweight stores — Zustand, Jotai, Valtio — made “a small shared store for a specific concern” cheap enough that you don’t need a framework.
The anti-pattern, now: reaching for Redux (or its modern successor Redux Toolkit) for every new React project by reflex. Most applications do not need it. Applications that truly have a large shared client-state surface — a rich editor, a design tool, a complex wizard — still benefit. Applications that have a handful of user-owned preferences and a lot of server data almost certainly don’t.
The question to ask before adopting any state library: what specific problem does this solve that local state plus a server- state library doesn’t? If the answer is “I’m not sure, it’s just what we’ve always used,” the answer is probably “nothing.”
TypeScript pays for itself here
A React codebase without types is a codebase where prop contracts
are enforced by convention, component refactors are blind, and
every intermediate file has a // what shape is this? comment.
TypeScript with React is not a perfect experience — generic
components, forwardRef, discriminated unions on props, complex
children types all have sharp edges. It is still dramatically
better than the alternative, especially as a codebase grows past
what one person can hold in their head.
The anti-patterns to avoid within TypeScript:
anyas an escape hatch on props. If a prop’s type is hard to express, the prop’s API is usually wrong.React.FC— the older function-component type — imposes an implicitchildrenprop and other surprises. Writingfunction Foo(props: FooProps)is fine and more accurate.- Overusing
useState<Thing | null>(null). Many “null until loaded” patterns are better modeled as a discriminated union ({ status: 'loading' } | { status: 'ready', data: Thing }).
The rule
Most React anti-patterns are a single step away from a best practice, and the step is almost always in the direction of more cleverness: more state, more effects, more memoization, more configuration, more layers. The best practices pull the other way: less state, derive instead of sync, effects only for external systems, memoize when measured, compose instead of configure.
The library itself is small. The idiom is to keep the code you write on top of it small too — a set of components that each have a clear job, a data flow that runs in one direction, state that lives where it’s used, and server data treated as the separate concern it is. Every time you find yourself reaching for a workaround, the underlying code is trying to tell you something about its shape. Usually the fix is to stop fighting and move the state, derive the value, or split the component.
The cleverness is in the restraint.