Refactor: When It Actually Changes Things
March 11, 2026By Emirhan YILDIRIM
When to Refactor (and When to Leave It Alone)
howtocenterdiv.com β "Software engineering is more than just centering a div."
Your part renders. Tests go well. The product is happy. Then, six months later, no one wants to touch that file. That's when refactoring becomes necessary.
But not every problematic file needs to be rewritten. The real talent is knowing when to refactor and when to leave things alone.
The Real Signal: Code That Fights Back
It's not about being clean when you refactor. It's about resistance. When a codebase starts to resist change, delivery slows down, defects multiply, and onboarding takes a week.
| Signal | What It Means | Priority |
|---|---|---|
| A bug fix needs changes in 4+ files | Logic is scattered | π΄ High |
| Adding a feature breaks unrelated tests | Coupling is too tight | π΄ High |
| The bundle size grew 40% without a feature | Dead code / bloated deps | π΄ High |
| You copy-paste instead of reuse | No shared abstraction | π Medium |
| You need to read code twice | Abstraction is wrong | π Medium |
| New devs ask "why is this here?" | Structure doesn't match intent | π‘ Low |
Do you see two or more of these signals? Refactoring gives a direct return on investment.
Before and After: Component Coupling
β Before β Tightly Coupled
tsx1const UserCard = ({ userId }: { userId: string }) => { 2 const [user, setUser] = useState(null); 3 const [loading, setLoading] = useState(false); 4 const [error, setError] = useState(null); 5 6 useEffect(() => { 7 setLoading(true); 8 fetch(`/api/users/${userId}`) 9 .then(r => r.json()) 10 .then(data => { setUser(data); setLoading(false); }) 11 .catch(err => { setError(err.message); setLoading(false); }); 12 }, [userId]); 13 14 if (loading) return <div className="animate-pulse h-10 w-full bg-zinc-200 rounded" />; 15 if (error) return <div className="text-red-500 text-sm">{error}</div>; 16 17 return ( 18 <div className="flex items-center gap-3 p-4 border border-zinc-200 rounded-xl"> 19 <img src={user.avatar} className="w-10 h-10 rounded-full object-cover" /> 20 <div> 21 <p className="font-semibold text-zinc-900">{user.name}</p> 22 <p className="text-sm text-zinc-500">{user.email}</p> 23 </div> 24 </div> 25 ); 26};
You can't test the UI unless you simulate
fetch. The card can't be reused. You can't change the skeleton without touching the business logic.β After β Separated Concerns
tsx1// hooks/useUser.ts 2const useUser = (userId: string) => { 3 const [state, setState] = useState<{ 4 data: User | null; 5 loading: boolean; 6 error: string | null; 7 }>({ data: null, loading: false, error: null }); 8 9 useEffect(() => { 10 setState(s => ({ ...s, loading: true })); 11 fetch(`/api/users/${userId}`) 12 .then(r => r.json()) 13 .then(data => setState({ data, loading: false, error: null })) 14 .catch(err => setState({ data: null, loading: false, error: err.message })); 15 }, [userId]); 16 17 return state; 18}; 19 20// UserCardSkeleton.tsx β no dependencies 21const UserCardSkeleton = () => ( 22 <div className="flex items-center gap-3 p-4 border border-zinc-200 rounded-xl animate-pulse"> 23 <div className="w-10 h-10 rounded-full bg-zinc-200" /> 24 <div className="flex flex-col gap-1.5"> 25 <div className="h-3.5 w-28 rounded bg-zinc-200" /> 26 <div className="h-3 w-40 rounded bg-zinc-200" /> 27 </div> 28 </div> 29); 30 31// UserCard.tsx β orchestrator only 32const UserCard = ({ userId }: { userId: string }) => { 33 const { data, loading, error } = useUser(userId); 34 if (loading) return <UserCardSkeleton />; 35 if (error) return <p className="text-red-500 text-sm">{error}</p>; 36 if (!data) return null; 37 return <UserCardDisplay user={data} />; 38};
You may now test, render, and use
UserCardDisplay anywhere. It is now a pure component.CSS Refactoring: Specificity Wars
β Before
css1.sidebar .nav-list .nav-item a { 2 color: #3b82f6; 3} 4 5.sidebar .nav-list .nav-item a:hover { 6 color: #1d4ed8; 7} 8 9.main-layout .sidebar .nav-list .nav-item a { 10 color: #6366f1 !important; 11}
The
!important is a sign. Three layers of nesting that can't be changed in a predictable way.β After β Flat + Custom Properties
css1:root { 2 --nav-link-color: #3b82f6; 3 --nav-link-hover: #1d4ed8; 4} 5 6.nav-link { 7 color: var(--nav-link-color); 8 transition: color 150ms ease; 9} 10 11.nav-link:hover { 12 color: var(--nav-link-hover); 13} 14 15[data-theme="purple"] { 16 --nav-link-color: #6366f1; 17 --nav-link-hover: #4f46e5; 18}
CSS Custom Properties β Browser Support:
| Browser | Min Version | Support |
|---|---|---|
| Chrome | 49+ | β Full |
| Firefox | 31+ | β Full |
| Safari | 9.1+ | β Full |
| IE 11 | β | β None |
Use
postcss-custom-properties at build time for IE11. Ship natively for everyone else.Refactoring for Performance
Layout Thrashing
tsx1// β Forces reflow on every iteration β 50 panels = 50 reflows 2panels.forEach(panel => { 3 const height = panel.getBoundingClientRect().height; // READ 4 panel.style.minHeight = `${height + 20}px`; // WRITE 5}); 6 7// β One reflow total 8const heights = panels.map(p => p.getBoundingClientRect().height); // batch READ 9heights.forEach((h, i) => { panels[i].style.minHeight = `${h + 20}px`; }); // batch WRITE
This shows up in Chrome DevTools as a "Recalculate Style" block that goes away completely after the fix β saving 200β800ms.
Bundle Bloat
import _ from 'lodash'; // β ~72KB gzipped
import sortBy from 'lodash/sortBy'; // β
~2KB gzipped
| Import Style | Cost | Tree-shakeable |
|---|---|---|
import _ from 'lodash' | ~72KB | β |
import { sortBy } from 'lodash-es' | ~2KB | β |
Native Array.prototype.sort | 0KB | β |
When Refactoring Costs More Than It Saves
| Condition | Refactor? |
|---|---|
| Code is touched 3+ times per month | β Yes |
| Code is a single utility used once | β No |
| You're mid-sprint on a deadline | β No |
| It has no tests | β οΈ Tests first |
| You're refactoring because it "looks bad" | β No |
| A bug traced back here 3+ times | β Yes |
CSS @layer β End Specificity Wars for Good
If you want to mix a design system with CSS from another source,
@layer is the best tool.css1@layer reset, base, components, utilities; 2 3@layer reset { 4 *, *::before, *::after { 5 box-sizing: border-box; 6 margin: 0; 7 } 8} 9 10@layer components { 11 .card { 12 background: white; 13 border: 1px solid #e4e4e7; 14 border-radius: 0.75rem; 15 padding: 1.5rem; 16 } 17} 18 19@layer utilities { 20 .mt-auto { margin-top: auto; } 21 .sr-only { 22 position: absolute; 23 width: 1px; 24 height: 1px; 25 overflow: hidden; 26 clip: rect(0,0,0,0); 27 } 28}
No matter how sophisticated the selector is, a utility inside
@layer utilities has lower specificity than any style that isn't layered. Tailwind comes with its own @layer setup. This is the fix if you're using !important to fight it.Browser Support β
@layer:| Browser | Min Version | Support |
|---|---|---|
| Chrome | 99+ | β Full |
| Firefox | 97+ | β Full |
| Safari | 15.4+ | β Full |
| Edge | 99+ | β Full |
For earlier targets, use
@csstools/postcss-cascade-layers as a polyfill.Tailwind: The cn() Pattern
tsx1// β No conflict resolution β px-4 + px-6 both stay in the DOM 2className={`px-4 py-2 ${variant === 'primary' ? 'bg-blue-600' : 'bg-zinc-100'} ${className}`} 3 4// β tailwind-merge fixes conflicts β last one wins 5import { clsx, type ClassValue } from 'clsx'; 6import { twMerge } from 'tailwind-merge'; 7 8const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs)); 9 10className={cn( 11 'px-4 py-2 rounded-lg font-medium text-sm', 12 variant === 'primary' && 'bg-blue-600 text-white', 13 variant === 'secondary' && 'bg-zinc-100 text-zinc-900', 14 disabled && 'opacity-50 cursor-not-allowed', 15 className 16)}
The Refactoring Checklist
- Does a test suite exist? If not, write tests first.
- Is this on the critical path? β Talk to your team about it.
- What does "done" mean? Define the goal: e.g. "decouple UserCard from fetch."
- PR diff under 400 lines? β Larger diffs can't be properly reviewed or reverted.
- Will this change a metric? Use Lighthouse CI to check CLS, LCP, and INP before and after.