Most React codebases don't fall apart because of a single bad decision they erode gradually, through hundreds of small ones: state lifted higher than it needs to be, components that know too much about their siblings, effects that re-run for the wrong reasons.
Here's the set of practices that actually hold up as a codebase grows past its first few dozen components.
1. Colocate state with where it's used
The default should be local state, not global state. Lifting state up should be a deliberate decision made when two components genuinely need to share it not the starting point.
// Avoid: global store for state only one component tree needs
const useDropdownStore = create((set) => ({ isOpen: false, setIsOpen: set }))
// Prefer: local state, lifted only when actually shared
function Dropdown() {
const [isOpen, setIsOpen] = useState(false)
// ...
}
2. Favor composition over configuration props
A component that accepts ten boolean props to control its rendering is harder to reason about than one built from smaller composable pieces:
// Avoid
<Card showHeader showFooter showBadge badgeText="New" compact />
// Prefer
<Card>
<Card.Header />
<Card.Badge>New</Card.Badge>
<Card.Body />
</Card>
Composition scales because each piece has one job; configuration props scale by adding more conditionals inside one component.
3. Be deliberate about useEffect
Most useEffect bugs come from treating it as a general "run this when something changes" hook rather than what it actually is: a synchronization mechanism with an external system.
// This is not a "run on state change" hook it's for external sync
useEffect(() => {
const controller = new AbortController()
fetch(`/api/users/${id}`, { signal: controller.signal })
.then((r) => r.json())
.then(setUser)
return () => controller.abort()
}, [id])
If you're deriving one piece of state from another, that's a plain calculation during render not an effect.
// Avoid: derived state stored and effect-synced
const [fullName, setFullName] = useState('')
useEffect(() => {
setFullName(`${firstName} ${lastName}`)
}, [firstName, lastName])
// Prefer: compute it directly
const fullName = `${firstName} ${lastName}`
4. Memoize with intent, not by default
useMemo/useCallback/React.memo are a cost (extra comparisons, extra memory) that only pays off when a component is provably expensive to re-render or is a dependency of something else that's memoized. Wrapping everything "just in case" adds complexity without measured benefit.
| When to memoize | When not to |
|---|---|
| Expensive computation confirmed via profiler | Cheap renders (most components) |
| Referential stability needed for a memoized child | No memoized children downstream |
| Large list item components | One-off UI elements |
5. Keep data fetching close to where it's consumed
Prop-drilling fetched data through five layers of components is a common source of unnecessary re-renders and brittle refactors. Colocate fetches with the component (or route) that needs the data, using React Query/SWR or the framework's own data layer (e.g. Next.js Server Components) instead of routing everything through a single top-level fetch.
Checklist for a healthy React codebase
- State lives at the lowest common component that needs it
- Components are composed from smaller pieces, not configured via many props
-
useEffectis reserved for actual external synchronization - Memoization is applied only where profiling shows a real cost
- Data fetching is colocated with the consuming route/component
The takeaway
None of these practices are React-specific wisdom so much as general software design discipline applied to React's rendering model. The codebases that stay pleasant to work in after two years are the ones where these were defaults from day one, not a refactor bolted on later.
Building out a new React frontend and want a second pair of eyes on the architecture? Let's talk.






