Mistakes that block the jump from intermediate to senior

As you move past beginner React patterns, most mistakes stop being about syntax and start being about structure. The decisions in this post don’t fail loudly; they quietly increase complexity, hide dependencies, and make future changes harder than they need to be.
1. Grouping Component Variations
Section titled: 1. Grouping Component VariationsWhen creating UI you often find yourself in need of a component that is just slightllly different from one you already have.
For example:
- You already have a page for displaying templates, but now you need to build a page for templates that are shared with others
- You already have a list view for active items, but now you need a list for archived items with slightly different actions
- You already have a toast for successful saves, but now you need a near-duplicate toast with a secondary action button
The beginner approach to this problem is to add conditionals to the component that you already have:
// Don't do this (1){ item.state === 'archived' ? ( <div> <button>Unarchive</button> <button>Delete Forever</button> </div> ) : ( <div> <button>Archive</button> <button>Edit</button> </div> )}The problem with this approach is that it doesn’t scale. What happens when you need to add a third state? Or frequently the same condition ends up in multiple locations in the same file.
Now all the code for every variant of this component all lives in the same file that just continues to grow. Some elements are shown for multiple states, hidden for others; it becomes difficult to be certain if a specific element was supposed to be displayed (or hidden) for a particular state or if it was just overlooked in the tangle of everything else.
The Experienced Approach
Section titled: The Experienced ApproachThere’s an old programming maxim:
Isolate what changes“
The way to accomplish that here is to create one generic component and then pass down the variant pieces as props. This isolates the pieces that change in parent, variant-specific components.
// Do this instead (1)function ArchivedFile() { return ( <File actionButtons={ <> <button>Unarchive</button> <button>Delete Forever</button> </> } /> )}You no longer have one infinitely growing file and can be certain about which elements are shared between the variants.
If the thing that changes is the data, then the same applies to data fetching!
// Don't do this (2)const templates = useGetTemplatesQuery({enabled: tab === 'mine'})const sharedTemplates = useGetSharedTemplatesQuery({enabled: tab === 'shared'})
const templatesData = templates.data ?? sharedTemplates.data// Do this instead (2)function MyTemplatesPage() { const templates = useGetTemplatesQuery() return <TemplatesTable templates={templates.data} />}
function SharedTemplatesPage() { const templates = useGetSharedTemplatesQuery() return <TemplatesTable templates={templates.data} />}2. URL Access in Nested Components
Section titled: 2. URL Access in Nested ComponentsWhen you need info from the URL, it’s tempting to just pull it out (or invoke a hook that does so) wherever you need it.
Even if it initially happens in a single-use component, it’s only a matter of time until another dev comes around and reuses that component. And there’s never a guarantee that they notice the URL access; especially if it’s nested inside a custom hook. And now you’ve got this brittle, indirect, and unclear line of dependencies.
The Experienced Approach
Section titled: The Experienced ApproachKeep URL access at the page level. Treat the URL as data that the page owns. Child components shouldn’t know or care what the current URL is. When lower level components need those data, pass them down as explicit props to make those dependencies clear.
Tanstack Router, while certainly not requiring this pattern, makes it very natural by providing obvious access to the route params in the file where the route is declared.
export const Route = createFileRoute('/file/$fileId')({ component: FileDetailsPage,})
function FileDetailsPage() { const {fileId} = Route.useParams() const file = useGetFile(fileId) return <FileData file={file.data} />}3. Using state incorrectly
Section titled: 3. Using state incorrectlyState tends to be overused in React; which is unfortunate because the complexity of a component grows exponentially as a function of the number of state values that it manages.
Common ways state is misused are:
-
Multiple state vars are used when one would suffice. A dead giveaway for this is when multiple
setState()calls are often grouped one after the other. That indicates one of them is actually state, and the other should be derived from that state. -
State is used in place of
useMemo. A memoized value is one value derived from another. That could be derived from a piece of state, or derived from a prop. A giveaway for this is if a piece of state is only set inside ofuseEffects.
The Experienced Approach
Section titled: The Experienced ApproachDon’t store derived values in state. Store core values in state and derive everything else. Components will still rerender when the core values change and that prevents “duplicate” state values where you need to make multiple state updates for a single change (and bugs when you forget).
Fewer values stored in state reduces the number of branches in the component, mathematically reducing its complexity.
- Do
- ✅ Keep state to a minimum
- ✅ Derive secondary values from state rather than adding more state
- Don’t
- ❌ Store derived values in state
- ❌ Have state that is only set inside of
useEffect - ❌ Memoize simple calculations
4. Grouping side effects
Section titled: 4. Grouping side effectsMost side effects have dependencies; that’s why useEffect has a dependency array. When you stack multiple effects together those dependencies grow, and every effect now runs when any effect’s dependency changes, instead of only the ones that particular side effect actually depends on. That’s inefficient and confusing.
Depending on how the function is written, you might also have to contend with the effects affecting each other. When you see an early return; you find yourself wondering if that was intentional or a mistake. The overall brainpower required to understand everything that’s happening is much higher than if each piece were split out.
// Don't do thisReact.useEffect( () => { if (shouldShowCancelledBanner && !isDataLoading) { if (cancelledBannerStorage.dismissalExpiration < Date.now()) { setShowCancelledBanner(true) } setShowTrialBanner(false) return }
if (!trialEndDate || isDataLoading || hasOrHadSubscription) { setShowTrialBanner(false) setShowCancelledBanner(false) return }
setDaysLeft(Math.floor((new Date(trialEndDate).getTime() - Date.now()) / ONE_DAY_IN_MILLIS))
if (bannerStorage.dismissalExpiration < Date.now()) { setShowTrialBanner(true) } setShowCancelledBanner(false) }, [ /*...*/ ],)The Experienced Approach
Section titled: The Experienced ApproachEvery useEffect should do exactly one thing. If you have multiple side effects that need doing, invoke useEffect multiple times.
Take this to the next level and pull non-trivial useEffects into custom hooks. This will often let you pull other state and logic along with it to create a hook with a clear purpose and simple interface.
Closely inspect the state that your effect manipulates, keeping the points from 3. Using state incorrectly in mind. When I refactored the effect above, I found that none of those 3 pieces of state were actually necessary; all three just became derived values. Once the state was gone, the whole effect became unnecessary.
// Do this insteadconst daysLeft = React.useMemo(() => { if (!trialEndDate) return 0 return Math.floor((new Date(trialEndDate).getTime() - Date.now()) / ONE_DAY_IN_MILLIS)}, [trialEndDate])
const showCancelledBanner = !cancelledBannerDismissal.isDismissed && !isDataLoading && shouldShowCancelledBanner
const showTrialBanner = !showCancelledBanner && !trialBannerDismissal.isDismissed && !isDataLoading && trialEndDate && !hasOrHadSubscription5. Storing Large Objects in React Context
Section titled: 5. Storing Large Objects in React ContextWhen you useContext() inside a component, your component will re-render whenever that context value reference changes. So if that value is an object, your component will re-render any time any property in that object changes because doing so requires creating a new object (mutating objects would be a bug and would not re-render anything).
So if you have a component that only needs a single property from that context object, it will still re-render when every other property of that object that the component doesn’t care about changes.
// Don't do this<MyContext value={{a, b, c, d, e, f, g, h, i, j, k, l}}>{/* ~stuff~ */}</MyContext>Large context objects create implicit coupling between components that shouldn’t care about each other’s state.
The Experienced Approach
Section titled: The Experienced ApproachKeep context objects small. Break them up into logical groups that components are likely to consume together. If two values don’t usually change together, they probably don’t belong in the same context.
// Do this insteadconst Group1Context = createContext<Group1>(defaultGroup1)const Group2Context = createContext<Group2>(defaultGroup2)
export function Provider({ children }: React.PropsWithChildren) { // Without useMemo here the context would update every time this Provider component re-renders const group1ContextValue = React.useMemo(() => ({a, b, c}), [a, b, c]) const group2ContextValue = React.useMemo(() => ({d, e, f}), [d, e, f])
return ( <Group1Context value={group1Context}> <Group2Context value={group2ContextValue}> {children} </Group2Context> </Group1Context> )}Better solutions are on the horizon
Section titled: Better solutions are on the horizonThere is an open React RFC to support context selectors that allow a component to narrow re-renders to only certain properties of an object, which would help alleviate this footgun.
There is also a use-context-selector library that implements that API in userland (outside of React itself), which I have not personally tried but seems promising.
Conclusion
Section titled: ConclusionThe common thread in these mistakes is unnecessary coupling. React code stays maintainable when responsibilities are clearly defined, dependencies are explicit, and both state and side effects are kept to a minimum.
