Writing/React Best Practices: Writing Code That Scales

React Best Practices: Writing Code That Scales

Eight habits that keep a growing codebase honest — without slowing you down.

Matrix Studio
Team
5 min read · July 7, 2026
Share
#react, #tech, #reactbestpractises, #coding · 01

Every React codebase starts clean. A handful of components, a couple of hooks, maybe one useEffect that everyone understands. Then six months pass. Features stack up, deadlines compress, and suddenly you're staring at a component with fourteen useState calls and an effect that fires three times for reasons nobody can explain.

This isn't a failure of React. It's a failure of habits. The good news is that the habits which keep a codebase healthy are learnable, and most of them take no extra time once they become automatic.

At a glance

  1. Single-responsibility components — reach for this when a component needs "and" to describe what it does.
  2. Custom hooks — reach for this when the same useState + useEffect pair shows up twice.
  3. Scoped state — keep state local by default, and only lift it when it's truly shared.
  4. Measured memoization — reach for this when a profiler, not a hunch, shows a bottleneck.
  5. Honest effects — use them for syncing with something outside React, never for deriving a value.
  6. Props as a public API — treat this as the standard for any component another developer will reuse.
  7. Error boundaries — wrap these around any independent, potentially-flaky UI section.
  8. Behavior-based tests — write these for anything a user can see, click, or read.

1. Components Should Do One Thing

The single most common source of React pain is components that grew too many responsibilities. A <UserProfile /> that fetches data, manages form state, handles validation, and renders three different layouts based on props is not one component — it's four components wearing a trench coat.

A useful test: if you can't describe what a component does in one sentence without using the word "and," it's time to split it.

graph TD
subgraph Before
A["UserProfile<br/>(fetch + validate + edit + render)"]
end
subgraph After
B[UserProfile] --> C["useUser()<br/>data fetching"]
B --> D[ProfileSkeleton]
B --> E[ProfileView]
end
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState({});
// fetch logic, validation logic, render logic, all tangled together
}
function UserProfile({ userId }) {
const { user, isLoading } = useUser(userId);
if (isLoading) return <ProfileSkeleton />;
return <ProfileView user={user} />;
}

The split doesn't just look nicer — it makes each piece independently testable, and a bug in form validation can't accidentally break data fetching.

2. Let Custom Hooks Carry the Logic

Custom hooks are React's most underused tool for cleanliness. Any time you find yourself copying the same useState + useEffect combination across components, that's a hook waiting to be extracted.

function useUser(userId) {
const [user, setUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);

useEffect(() => {
let cancelled = false;
setIsLoading(true);

fetchUser(userId).then((data) => {
if (!cancelled) {
setUser(data);
setIsLoading(false);
}
});

return () => {
cancelled = true;
};
}, [userId]);

return { user, isLoading };
}

Here's the race condition the cancelled flag prevents — the user clicks a second profile before the first one's request resolves:

sequenceDiagram
participant U as User
participant C as Component
participant S as Server
U->>C: open profile #1
C->>S: fetch(userId=1)
U->>C: open profile #2
C->>S: fetch(userId=2)
S-->>C: response for #2 (fast)
Note over C: sets user = #2
S-->>C: response for #1 (slow)
Note over C: without the flag,<br/>overwrites with stale #1
[!WARNING] Without the cancelled flag, a fast-changing userId can let a stale response overwrite a newer one. This race condition is one of the most common invisible bugs in React apps, and it's cheap to prevent.

3. Don't Reach for Global State Too Early

Redux, Zustand, Jotai, Recoil — they all solve real problems, but the problem they solve is cross-tree state sharing, not "I have more than one useState." A huge share of state that ends up in global stores never needed to leave the component that owns it.

flowchart TD
A[New piece of state] --> B{Used by more than<br/>one component?}
B -- No --> C[useState / useReducer<br/>in the component that owns it]
B -- Yes --> D{Does it come from<br/>a server?}
D -- Yes --> E[React Query / SWR<br/>treat it as a cache, not state]
D -- No --> F{Shared by nearby<br/>siblings only?}
F -- Yes --> G[Lift state up to<br/>the common parent]
F -- No --> H[Context or a<br/>global store]
  1. Local — belongs to one component and its children. Reach for useState / useReducer.
  2. Shared — belongs to siblings or distant components. Lift it up, or use Context.
  3. Server — backend data that can go stale. Reach for React Query / SWR.

Treating server data as client state is where a lot of unnecessary complexity creeps in — manual loading flags, manual refetch logic, manual cache invalidation. Libraries built for this handle it in a fraction of the code.

4. Memoization Is a Scalpel, Not a Habit

useMemo, useCallback, and React.memo are frequently sprinkled across a codebase defensively, "just in case." This has a cost: extra memory, extra comparison work, and code that's harder to read for a performance gain that, most of the time, doesn't exist.

xychart-beta
title "Render cost: list of 5,000 rows"
x-axis ["No memo", "Memo, no bottleneck", "Memo, real bottleneck"]
y-axis "Time (ms)" 0 --> 50
bar [8, 11, 9]
bar [8, 11, 42]

The middle bar is the trap: memoizing a cheap computation adds overhead (comparison cost) without removing any. The right bar is the case that actually justifies it — a genuinely expensive computation, measured, not guessed.

Memoize when you have a measured problem:

  1. Expensive computation — sorting or filtering large lists, heavy calculations.
  2. Expensive re-renders — preventing work in a component wrapped in React.memo.
  3. Stable references — stopping a function or object from re-triggering another hook's dependency array.
[!TIP] If none of these apply, plain re-rendering is usually cheaper than the machinery meant to prevent it. Profile first, memoize second.

5. Keep Effects Honest

useEffect is for synchronizing with something outside React — a subscription, a DOM measurement, a request tied to a prop change. It is not a general-purpose "do this after render" hook, and it's not where derived values belong.

flowchart LR
A[Render] --> B[Commit to DOM]
B --> C[Browser paints]
C --> D["Effects run<br/>(async, after paint)"]
D -.->|"only for real syncing:<br/>subscriptions, fetches, DOM reads"| A
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
const fullName = `${firstName} ${lastName}`;

The rule of thumb: if you can compute something during render, do it during render. Effects run after paint, one render cycle behind — a derived value would just make the component briefly render the wrong thing first.

6. Design Props Like a Public API

Props are the interface other developers — including future you — will use to interact with a component. Treat them with the same care you'd give a public API:

  1. Prefer a small number of well-named props over a config object that hides what's being passed.
  2. Avoid boolean props that multiply — isLarge, isPrimary — in favor of one variant or size prop.
  3. Destructure with defaults at the top of the function so the full contract is visible at a glance.
function Button({ variant = "primary", size = "md", children, ...rest }) {
return (
<button className={`btn btn-${variant} btn-${size}`} {...rest}>
{children}
</button>
);
}

A component whose props read clearly is a component nobody's afraid to reuse.

7. Error Boundaries Aren't Optional Polish

It's easy to ship an app where one broken component takes down the entire page with a blank white screen. Error boundaries catch rendering errors in their subtree and let you show a fallback instead.

graph TD
App --> Header
App --> B1["ErrorBoundary"]
App --> B2["ErrorBoundary"]
B1 --> Sidebar["Sidebar widget<br/>(third-party)"]
B2 --> Comments["Comments section<br/>(user-generated content)"]
App --> MainContent["Main content<br/>(unaffected if either fails)"]
class ErrorBoundary extends React.Component {
state = { hasError: false };

static getDerivedStateFromError() {
return { hasError: true };
}

componentDidCatch(error, info) {
logErrorToService(error, info);
}

render() {
if (this.state.hasError) return this.props.fallback;
return this.props.children;
}
}
[!TIP] Wrap boundaries around independent sections of your UI — a sidebar widget, a comments section, a chart — so a failure in one doesn't take out the whole page. Especially valuable around third-party components or unpredictable, user-generated data.

8. Test Behavior, Not Implementation

A test suite that breaks every time you refactor internals — without any actual bug — is actively working against you. Tools like React Testing Library push toward testing what a user would experience: what's rendered, what happens on click, what text appears.

test("shows validation error on empty submit", async () => {
render(<SignupForm />);
await userEvent.click(screen.getByRole("button", { name: /sign up/i }));
expect(await screen.findByText(/email is required/i)).toBeInTheDocument();
});

This test survives a rewrite of the component's internal state management. A test that checks wrapper.state().errors.length would not.

Match the Tool to the Problem

Look back at these eight points and a pattern emerges: almost every one is about matching the tool to the actual problem rather than reaching for the most powerful option by default. Split components only when responsibilities multiply. Reach for global state only when state truly is global. Memoize only measured bottlenecks. Use effects only for real synchronization.

React rewards restraint. The framework gives you enormous flexibility — you can put everything in one component, sync everything through effects, memoize everything defensively. The best React codebases are the ones where developers consistently chose not to, and let the shape of the code stay honest about what it's actually doing.

[!NOTE] The whole practice, really, isn't memorizing more patterns — it's noticing which problem you actually have before reaching for a solution.
Matrix Studio · Let's build

This is the kind of thing
we build.

Start a projectMore writing →