And yes — there's a difference. A massive one.
I want to tell you something that took me an embarrassingly long time to learn.
In 2017, I shipped a feature I was proud of. A live order tracking component at Elemnus — real-time updates, smooth UI, clean-looking code by my standards at the time. My team lead reviewed it, nodded slowly, and said: "It works. But when the next requirement comes, we're going to regret this."
I thought he was being dramatic.
Three weeks later, a new requirement landed: "Add a cancellation reason modal when the user cancels an order." Simple, right? I opened the component. It was 340 lines long. It was fetching data, managing five different UI states, animating transitions, and handling form submissions — all in one place, all tangled together like earphones at the bottom of a backpack.
I spent two days on a change that should have taken two hours. I introduced three bugs fixing one. We shipped on Friday. Something broke on Saturday.
My team lead didn't say "I told you so." He just opened the component and said: "How many reasons does this thing have to change?"
I counted. Seven. At least seven different reasons this single component would need to be touched if anything in the system changed.
That question — how many reasons does this have to change? — is the one I want to answer with you today. Because if you've ever felt the specific dread of opening a component you wrote six months ago, if you've ever thought "I'll just add this one thing here" and immediately regretted it — this article is for Bishoy at 3 AM. The one staring at a 400-line component wondering where to even start.
What you'll be able to DO after reading this:
- Diagnose exactly why a component is hard to change (not just that it's "too big")
- Apply the Change Isolation Test to any component in under 60 seconds
- Refactor a bloated component into a structure that lets you sleep at night
- Know when to memoize and when memoization is costing you more than it saves
- Walk away with a mental model that makes every future component decision faster
This is not a "here's the before, here's the after" tutorial. Those exist. They're fine. This is the thinking behind the refactor — the stuff that actually stops you from recreating the same mess six months later in a different component with a different name.
The Uncomfortable Truth Nobody Says Out Loud
Here's what the React documentation won't tell you, what most tutorials gloss over, and what I got wrong for longer than I'd like to admit:
Single Responsibility Principle is not about size. It's about reasons to change.
I know. You've heard "keep your components small." You've nodded. You've maybe even split a big component into three smaller ones and felt good about yourself. But here's the trap: you can split a component into five pieces and still violate Single Responsibility completely if those pieces are coupled to the same reasons for change.
Let me be specific. Imagine you have a UserProfile component. You decide to be responsible and split it into UserAvatar, UserBio, and UserActions. Excellent. Three components now. But all three of them directly call the same API endpoint, and all three of them have their own loading state. Now you have three components, all of which need to change the moment the API contract changes. You've split the file. You haven't split the responsibilities.
This distinction is the whole game. Everything else is noise.
The question to ask about any component, large or small, is this: "If X changes, does this component need to change too?" Ask that for every X you can think of — API shape, UI design, business logic, error handling strategy, animation library. Count the yeses. That number is your responsibility score. The goal is not zero — one is fine, even two can be fine in context. But when you hit five, six, seven? You've built a component that is afraid of the future. And it should be. Because every future change is coming for it.
What "Doing Too Much" Actually Looks Like in Production
Let's build the actual broken thing first. Not Hello world. A real-world UserProfile component the way it grows organically in a real codebase under real deadline pressure.
// 🚫 The component that "just grew this way"
// Responsibility Score: 7 (fetching, loading state, error state,
// display logic, edit mode toggle, form state, save logic)
// Time to understand cold: ~25 minutes
// Time to safely add a feature: "let me just try this and see"
import React, { useState, useEffect } from 'react';
interface User {
id: string;
name: string;
email: string;
bio: string;
avatarUrl?: string;
}
// These would normally live in an API layer — but they ended up here
// because "it was faster at the time"
async function fetchUser(userId: string): Promise<User> {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch');
return response.json();
}
async function updateUser(userId: string, data: Partial<User>): Promise<User> {
const response = await fetch(`/api/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to update');
return response.json();
}
const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
// Responsibility 1: Fetched data
const [user, setUser] = useState<User | null>(null);
// Responsibility 2: Loading state (shared between fetch AND save —
// which means the spinner shows when you save, which blocks the form,
// which you'll only notice in QA if you're lucky)
const [loading, setLoading] = useState(true);
// Responsibility 3: Error state (also shared — same problem)
const [error, setError] = useState<string | null>(null);
// Responsibility 4: Edit mode toggle
const [isEditing, setIsEditing] = useState(false);
// Responsibility 5 & 6: Form state for name and bio
// (These will get out of sync with `user` in subtle ways)
const [editName, setEditName] = useState('');
const [editBio, setEditBio] = useState('');
useEffect(() => {
const loadUser = async () => {
setLoading(true);
setError(null);
try {
const userData = await fetchUser(userId);
setUser(userData);
// Syncing form state with fetched data — a sign that
// these should not live in the same place
setEditName(userData.name);
setEditBio(userData.bio);
} catch (err) {
setError('Failed to fetch user.');
} finally {
setLoading(false);
}
};
loadUser();
}, [userId]);
// Responsibility 7: Save logic
const handleSave = async () => {
if (!user) return;
setLoading(true); // ← This disables the form AND shows the spinner.
// If you only wanted to show a spinner on the save button,
// you'd need ANOTHER state variable. This is how state multiplies.
setError(null);
try {
const updatedUser = await updateUser(user.id, { name: editName, bio: editBio });
setUser(updatedUser);
setIsEditing(false);
} catch (err) {
setError('Failed to update user.');
} finally {
setLoading(false);
}
};
if (loading) return <div>Loading profile...</div>;
if (error) return <div style={{ color: 'red' }}>Error: {error}</div>;
if (!user) return null;
return (
<div>
<h2>{user.name}'s Profile</h2>
{isEditing ? (
<div>
<label>
Name:
<input
type="text"
value={editName}
// Every keystroke re-renders the ENTIRE UserProfile component.
// Including the header. Including any siblings.
// This is invisible in development. Painful at scale.
onChange={(e) => setEditName(e.target.value)}
/>
</label>
<label>
Bio:
<textarea
value={editBio}
onChange={(e) => setEditBio(e.target.value)}
/>
</label>
<button onClick={handleSave} disabled={loading}>Save</button>
<button onClick={() => setIsEditing(false)} disabled={loading}>Cancel</button>
</div>
) : (
<div>
<p><strong>Email:</strong> {user.email}</p>
<p><strong>Bio:</strong> {user.bio}</p>
<button onClick={() => setIsEditing(true)}>Edit Profile</button>
</div>
)}
</div>
);
};
Now let me apply the Change Isolation Test. I'll go through every realistic change request and ask: does this component need to change?
- The API endpoint changes → Yes. The fetch functions are defined inside the file.
- The loading spinner design changes → Yes. Loading state is here.
- We want different loading behavior for fetch vs. save → Yes. And it's painful — you need to add a whole new state variable.
- The display design changes (new layout, colors, added avatar) → Yes.
- The form validation rules change → Yes.
- We add a new editable field (phone number) → Yes. And it touches every section of this component.
- The error handling strategy changes (toast instead of inline error) → Yes.
Seven reasons. Seven. Every one of those is a future regression waiting to happen. Every one of those is a code review comment waiting to become a heated discussion. Every one of those is a bug that shows up in production on a Friday.
Open your React DevTools Profiler right now and record a session while typing in the edit form. You'll see UserProfile lighting up on every single keystroke. The entire component tree underneath it re-renders. For a name input. Because the form state, the display state, and the data state are all the same state in the same component.
The Refactor: Change Isolation, Not Just Code Splitting
here's the approach. We're not just moving code around. We're identifying each reason to change and giving it its own home.
Reason to change #1 & #7: Data fetching and saving → Custom Hook
The hook's only job is to know about the user. It fetches. It saves. It knows nothing about forms, nothing about edit mode, nothing about what the UI looks like. If the API contract changes tomorrow, you touch exactly one file.
// useUser.ts
// Responsibility: ONE — managing the user data lifecycle
// Reason to change: ONLY when the data fetching or update logic changes
// Time to understand cold: ~5 minutes
import { useState, useEffect, useCallback } from 'react';
interface User {
id: string;
name: string;
email: string;
bio: string;
avatarUrl?: string;
}
async function fetchUser(userId: string): Promise<User> {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Network response was not ok');
return response.json();
}
async function saveUserToApi(userId: string, data: Partial<User>): Promise<User> {
const response = await fetch(`/api/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to save');
return response.json();
}
interface UseUserReturn {
user: User | null;
isFetching: boolean; // ← Separate loading states. Intentional.
isSaving: boolean; // ← Now the form can show a save spinner
fetchError: string | null; // without blocking the entire UI.
saveError: string | null;
saveUser: (data: Partial<User>) => Promise<User | undefined>;
refetch: () => void;
}
export function useUser(userId: string): UseUserReturn {
const [user, setUser] = useState<User | null>(null);
// Notice: two separate loading states.
// This solves the "spinner blocks the form during save" bug
// that you'd only catch in QA — or production.
const [isFetching, setIsFetching] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [fetchError, setFetchError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const loadUser = useCallback(async () => {
setIsFetching(true);
setFetchError(null);
try {
const userData = await fetchUser(userId);
setUser(userData);
} catch (err) {
setFetchError('Could not load profile. Please try again.');
} finally {
setIsFetching(false);
}
}, [userId]);
// saveUser is separate from loadUser.
// It has its own loading state, its own error state.
// If the product team asks "can we show a spinner only on the save button?"
// the answer is: already done.
const saveUser = useCallback(async (data: Partial<User>) => {
if (!user) return;
setIsSaving(true);
setSaveError(null);
try {
const updatedUser = await saveUserToApi(user.id, data);
setUser(updatedUser); // Update local state on success
return updatedUser;
} catch (err) {
setSaveError('Could not save changes. Please try again.');
throw err; // Re-throw so the calling component can react (e.g., keep form open)
} finally {
setIsSaving(false);
}
}, [user]);
useEffect(() => {
loadUser();
}, [loadUser]);
return {
user,
isFetching,
isSaving,
fetchError,
saveError,
saveUser,
refetch: loadUser,
};
}
Reason to change #5 & #6: Form state and validation → UserEditForm
This component knows nothing about the API. It knows nothing about the user data structure beyond what it receives as props. Its only job: manage the inputs, validate them locally, and call onSave when the user is done.
// UserEditForm.tsx
// Responsibility: ONE — managing the edit form experience
// Reason to change: ONLY when form fields, validation, or form UX changes
// Note: Every keystroke now re-renders ONLY this component.
// UserDisplay, UserProfileContainer — completely unaffected.
import React, { useState } from 'react';
interface User {
id: string;
name: string;
email: string;
bio: string;
}
interface UserEditFormProps {
// We pass the initial values explicitly so the form owns its state fully.
// The form doesn't know what the "current user" is — it just knows
// what it started with and what the user wants to change it to.
initialName: string;
initialBio: string;
// isSaving comes from the hook via the container.
// The form doesn't know WHY it's saving — just that it is.
isSaving: boolean;
onSave: (name: string, bio: string) => Promise<void>;
onCancel: () => void;
}
export const UserEditForm: React.FC<UserEditFormProps> = React.memo(({
initialName,
initialBio,
isSaving,
onSave,
onCancel,
}) => {
const [name, setName] = useState(initialName);
const [bio, setBio] = useState(initialBio);
// Local form error — separate from the API save error.
// "Name is required" is a form concern. "Server rejected the request" is an API concern.
// They live in different places.
const [validationError, setValidationError] = useState<string | null>(null);
const handleSubmit = async () => {
// Local validation before hitting the API.
// If validation rules change, this is the only place you touch.
if (!name.trim()) {
setValidationError('Name cannot be empty.');
return;
}
if (bio.length > 500) {
setValidationError('Bio must be 500 characters or less.');
return;
}
setValidationError(null);
await onSave(name.trim(), bio);
};
return (
<div>
{validationError && (
// This is a local validation error — shows inline immediately.
// The save error from the API is shown in the container.
<p style={{ color: 'orange' }}>{validationError}</p>
)}
<label>
Name:
<input
type="text"
value={name}
// This onChange now only re-renders UserEditForm.
// Nothing else in the tree is affected.
onChange={(e) => setName(e.target.value)}
disabled={isSaving}
/>
</label>
<label>
Bio ({bio.length}/500):
<textarea
value={bio}
onChange={(e) => setBio(e.target.value)}
disabled={isSaving}
/>
</label>
<button onClick={handleSubmit} disabled={isSaving}>
{isSaving ? 'Saving...' : 'Save Changes'}
</button>
<button onClick={onCancel} disabled={isSaving}>
Cancel
</button>
</div>
);
});
UserEditForm.displayName = 'UserEditForm';
Reason to change #4: Display logic → UserDisplay
Pure. Presentational. No state. No side effects. It receives data, it renders data. If the entire visual design of the profile changes, this is the only file you touch.
// UserDisplay.tsx
// Responsibility: ONE — rendering the user profile view
// Reason to change: ONLY when the display design or displayed fields change
// Testable with zero mocks. Just render and assert.
import React from 'react';
interface User {
id: string;
name: string;
email: string;
bio: string;
avatarUrl?: string;
}
interface UserDisplayProps {
user: User;
onEditClick: () => void;
}
export const UserDisplay: React.FC<UserDisplayProps> = React.memo(({ user, onEditClick }) => (
<div>
{user.avatarUrl && (
<img
src={user.avatarUrl}
alt={`${user.name}'s avatar`}
style={{ width: 80, height: 80, borderRadius: '50%' }}
/>
)}
<p><strong>Name:</strong> {user.name}</p>
<p><strong>Email:</strong> {user.email}</p>
<p><strong>Bio:</strong> {user.bio}</p>
<button onClick={onEditClick}>Edit Profile</button>
</div>
));
UserDisplay.displayName = 'UserDisplay';
Reasons to change #2, #3, #4 (orchestration): The Container
The container is the conductor. It doesn't play any instrument. It just knows what everyone else is doing and coordinates between them.
// UserProfileContainer.tsx
// Responsibility: ONE — orchestrating the profile feature
// Reason to change: ONLY when the feature's flow or composition changes
// (e.g., "add a confirmation step before saving")
import React, { useState } from 'react';
import { useUser } from './useUser';
import { UserDisplay } from './UserDisplay';
import { UserEditForm } from './UserEditForm';
interface UserProfileContainerProps {
userId: string;
}
export const UserProfileContainer: React.FC<UserProfileContainerProps> = ({ userId }) => {
const {
user,
isFetching,
isSaving,
fetchError,
saveError,
saveUser,
} = useUser(userId);
const [isEditing, setIsEditing] = useState(false);
const handleSave = async (name: string, bio: string) => {
try {
await saveUser({ name, bio });
// Only close the form if save succeeded.
// If it throws, the form stays open — user doesn't lose their work.
setIsEditing(false);
} catch {
// The error is already in saveError from the hook.
// We don't need to do anything here except NOT close the form.
}
};
// Fetch loading state — full page spinner, reasonable here
if (isFetching) return <div>Loading profile...</div>;
if (fetchError) return <div style={{ color: 'red' }}>{fetchError}</div>;
if (!user) return null;
return (
<div style={{ border: '1px solid #ccc', padding: '20px', borderRadius: '8px' }}>
<h2>{user.name}'s Profile</h2>
{/* API save error — shown at the container level, not buried in the form */}
{saveError && (
<div style={{ color: 'red', marginBottom: '12px' }}>
{saveError}
</div>
)}
{isEditing ? (
<UserEditForm
initialName={user.name}
initialBio={user.bio}
isSaving={isSaving}
onSave={handleSave}
onCancel={() => setIsEditing(false)}
/>
) : (
<UserDisplay
user={user}
onEditClick={() => setIsEditing(true)}
/>
)}
</div>
);
};
Now apply the Change Isolation Test again:
-
API endpoint changes → Touch
useUser.tsonly -
Loading spinner design changes → Touch
UserProfileContainer.tsxonly (or create aLoadingSpinnercomponent — one file) -
We want a different spinner during save → Already handled.
isSavingvsisFetchingare separate. -
Display design changes → Touch
UserDisplay.tsxonly -
Form validation rules change → Touch
UserEditForm.tsxonly -
Add a new editable field → Touch
UserEditForm.tsxanduseUser.tsonly -
Error handling strategy changes → Touch
UserProfileContainer.tsxonly
Seven changes. Seven files affected. But now each file is affected by exactly one of those changes, not all seven. That's not just cleaner code. That's confidence. That's the difference between shipping on Friday and sweating on Saturday.
The Performance Win You Didn't Know You Were Getting
Here's the thing tutorials always get backwards: they show you React.memo and useCallback as the solution to re-render problems. They're not. They're tools that become useful once you've fixed the architecture.
Before the refactor, every keystroke in the name input caused UserProfile to re-render. The entire component. Including the display section, including the header, including any sibling components that happened to be nearby. You could slap React.memo on the whole thing and it wouldn't help — because the state was inside the component, so React.memo has nothing to compare against.
After the refactor, every keystroke in the name input causes UserEditForm to re-render. Only UserEditForm. UserDisplay is sitting there completely unbothered. UserProfileContainer is unbothered. The rest of your page is unbothered.
You got the performance win for free. Not from memoization. From architecture.
Now, about memoization specifically — because this is where I've watched developers cargo-cult their way into slower apps:
What Most Developers Think: useCallback and React.memo prevent re-renders, so more of both = faster app.
What's Actually True in Production: useCallback preserves function reference identity across renders. Whether that prevents re-renders depends entirely on whether the receiving component is wrapped in React.memo. Without React.memo, useCallback adds memory overhead and dependency comparison on every render with zero benefit.
Why the Gap Exists: Because tutorials show the "happy path" where you add React.memo to the child AND useCallback to the parent AND it works. They don't show the case where you add useCallback to a function that's only used inside a useEffect — which is valid — or the case where you wrap a function in useCallback and pass it to a component that isn't memoized — which is just overhead with no upside.
// ⚠️ Common memoization mistake: paying the cost, getting nothing back
// Parent component
const Parent = () => {
const handleClick = useCallback(() => {
// some handler
}, []); // ← useCallback here creates a stable reference
// But if Child is NOT wrapped in React.memo...
return <Child onClick={handleClick} />;
// ...then the stable reference doesn't matter.
// Child re-renders whenever Parent re-renders regardless.
// You've paid for the dependency array comparison on every render
// and received nothing in return.
};
// ✅ useCallback is justified when the child IS memoized:
const Child = React.memo(({ onClick }: { onClick: () => void }) => {
// Now the stable reference matters.
// React.memo can say "onClick reference didn't change — skip re-render."
return <button onClick={onClick}>Click me</button>;
});
The rule: Profile first. Optimize second. Open React DevTools Profiler. Record a session while using the feature. Look for components that render more than once when you'd expect them to render zero times. Then reach for React.memo and useCallback. Not before.
I've seen codebases where every function was wrapped in useCallback "for performance." The actual profiler showed zero difference in render counts. What it did show was slightly longer render times — from the dependency array comparisons that were happening on every render for no reason.
The State Discipline Rule You'll Use Every Day
Before you add a new useState call, ask one question: "Can this be derived from state that already exists?"
Derived state is one of the most common sources of bugs in React applications. It's not dramatic — it doesn't throw errors. It just silently gets out of sync.
// 🚫 The bug factory
const ProfileStats = ({ user }: { user: User }) => {
// These three are derived from user — but stored as separate state.
// They'll be correct on mount. But what happens when user changes?
// You have to remember to update all three. Every time. Forever.
const [displayName, setDisplayName] = useState(user.name);
const [isVerified, setIsVerified] = useState(user.email.endsWith('@company.com'));
const [bioWordCount, setBioWordCount] = useState(user.bio.split(' ').length);
// Later, when user updates...
// Did you remember to update displayName?
// Did you remember to update isVerified?
// Did you remember to update bioWordCount?
// This is how subtle data inconsistency bugs are born.
return <div>{displayName} ({bioWordCount} words in bio)</div>;
};
// ✅ Derive it. Don't store it.
const ProfileStats = ({ user }: { user: User }) => {
// These are computed on every render.
// They are ALWAYS in sync with user. No exceptions. No bugs.
// And because they're simple calculations, the performance cost is negligible.
const displayName = user.name;
const isVerified = user.email.endsWith('@company.com');
const bioWordCount = user.bio.split(' ').length;
return <div>{displayName} ({bioWordCount} words in bio)</div>;
};
The only time to reach for useMemo on derived values is when the computation is genuinely expensive — we're talking sorting a list of ten thousand items, running a heavy calculation, or building a complex data structure. For everything else, compute it inline. React is fast. Your render functions are fast. Trust the platform.
The Uncomfortable Truth: You Will Recreate This Problem
Here's what I want to tell you that nobody else will.
You can do this refactor perfectly today. Every responsibility in its own file, every hook clean, every component pure. And six months from now, after three sprints of feature requests and two rounds of "just put it here for now, we'll clean it up later" — you'll have a new component with seven reasons to change.
This is not a failure of discipline. This is gravity. Codebases accumulate complexity the way kitchen drawers accumulate miscellaneous objects. The question isn't "how do I prevent it forever?" It's "how do I recognize it early enough to fix it before it becomes a legacy nightmare?"
The Change Isolation Test. Run it on your components every time you're about to open one to add a feature. Not as a refactoring ceremony — just as a quick gut-check. "How many reasons does this component have to change?" If the number is more than two, and you're about to add another feature to it, stop. Spend twenty minutes extracting just the thing you're about to add into its own home. You don't have to refactor the whole thing. Just don't make it worse.
Real Objections, Real Answers
Objection 1: "This is over-engineering for a simple component. Not everything needs four files."
Completely valid — with a caveat. A truly simple component with one responsibility doesn't need to be split. A LoadingSpinner that takes a message prop and renders a spinner? That's one file, one responsibility, done. The rule applies when the component has multiple reasons to change, not as a blanket "always split into hooks and containers." Use the Change Isolation Test. If the number is one, you're fine. If it's growing, that's the signal.
Objection 2: "Prop drilling gets worse with this pattern. Now I'm passing isSaving through multiple layers."
Fair. And the answer depends on your scale. For a contained feature like UserProfile, passing isSaving one level down (from container to form) is absolutely fine — it's one prop, one level. When you're passing the same prop through five layers to reach a deeply nested component, that's when context or a state management solution earns its place. The pattern I've shown here doesn't create prop drilling — it creates clear data flow. Those are different problems.
Objection 3: "Custom hooks are just moving the problem. The hook is still doing multiple things."
If your hook is doing multiple things, yes — it's the same problem, different location. useUser as I've written it has one job: manage the lifecycle of user data (fetch and save). If your hook is also managing UI state, routing, and analytics events, then the hook needs the same treatment the component got. The principle doesn't change based on whether you're looking at a component or a hook.
That said — there ARE cases where a single hook managing fetch AND save makes sense because they're tightly coupled to the same data entity. The test is still "how many reasons does this have to change?" If the fetch strategy and save strategy will always change together, they belong together. If they could evolve independently, separate them.
Resuources
-
📄 Article: "Making Sense of React Hooks" by Dan Abramov
- Why it made my brain itch: He explains the why behind hooks in a way that makes the design feel inevitable rather than arbitrary. Read it after you feel comfortable with hooks, not before.
-
📄 Article: "Application State Management with React" by Kent C. Dodds
- Why it made my brain itch: He makes the case for colocation — keeping state as close to where it's used as possible. Changed how I think about where things live.
-
🎥 Video: "React Performance" by Jack Herrington on YouTube
- Skip to minute 8:00: Where he shows an actual real-world app getting slower from too much memoization. Worth every second.
-
💬 Quote I'm stealing:
"Make it work, make it right, make it fast."
— Kent BeckWhy this stays with me: In that order. Most re-render problems I've seen were caused by trying to make it fast before making it right.
Here's what I'm still working through:
Where does the line between "this hook is doing too much" and "this hook is appropriately managing a domain" live in practice? In theory, useUser should only know about user data. But in a real application, saving a user profile might invalidate a cache, trigger an analytics event, update a global notification — at what point does the hook need to hand those concerns off, and to whom?
I've gone back and forth on this. My current position: the hook hands back the raw result and lets the caller decide what to do with it. Side effects like analytics belong in the container. Cache invalidation belongs in the API layer or a library like React Query that handles it natively.
But I've been wrong about this before. And I'll probably refine it again.
What's your current pattern for managing side effects that cut across concerns? I'm genuinely curious — drop it in the comments.
Code is not a sculpture. It's a public square. Other people have to walk through it after you.
The UserProfile component I described at the start of this article — the one with seven reasons to change — it wasn't written by a bad developer. It was written by me, under deadline pressure, making what felt like a reasonable call at the time. "It's just one component. I'll clean it up later."
Later never came. The feature shipped. The component grew. The Saturday bug happened.
The Change Isolation Test is the "clean it up later" that you do now, in twenty minutes, before the component becomes someone else's problem. Or your future self's problem. Which is worse.
Simple? Yes. Easy? Absolutely not. Welcome to the craft.
✨ Let's keep the conversation going!
If you found this interesting, I'd love for you to check out more of my work or just drop in to say hello.
✍️ Read more on my blog: bishoy-bishai.github.io
☕ Let's chat on LinkedIn: linkedin.com/in/bishoybishai
📘 Curious about AI?: You can also check out my book: Surrounded by AI
Top comments (0)