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 event objects).

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 AppContext with 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:

  • any as 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 implicit children prop and other surprises. Writing function 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.