Most React apps don’t break overnight.
They decay.
At first, everything feels clean. Then features pile up, quick fixes sneak in, and decisions you didn’t think twice about start showing up everywhere.
Six months later:
- changing one thing breaks another
- performance feels “off” but no one knows why
- and parts of the codebase become… untouchable
This isn’t about writing “better components.”
It’s about making a few decisions early that don’t age badly.
Start Simple — But Not Careless
You don’t need a complicated setup. You need a boring, reliable one.
React + TypeScript + Vite is more than enough.
npm create vite@latest my-app -- --template react-ts
Why this works in practice:
- TypeScript catches things like:
function getUserName(user: { name: string }) {
return user.name
}
// later...
getUserName(null) // ❌ caught early instead of crashing at runtime
- Vite keeps dev fast even as the app grows (this matters more than you think)
You’re not optimizing for day 1.
You’re optimizing for when the codebase stops fitting in your head.
Your Folder Structure Will Either Save You or Kill You
This looks clean early on:
components/
hooks/
utils/
Until you try to work on a feature and end up opening 12 files across 6 folders.
Instead, group by feature:
features/
auth/
Login.tsx
useAuth.ts
api.ts
dashboard/
Dashboard.tsx
useDashboard.ts
Now when you work on login:
- everything is in one place
- you don’t hunt for logic vs UI vs API
Real-world benefit:
// ❌ old way
import { login } from '../../services/api'
import { useForm } from '../../hooks/useForm'
// ✅ feature-based
import { login } from './api'
import { useAuthForm } from './useAuth'
Less jumping around = less cognitive load.
Routing Is Not an Afterthought
Routing shapes your app more than you think.
A simple setup early:
// app/router.tsx
const router = createBrowserRouter([
{ path: '/', element: <Home /> },
{ path: '/dashboard', element: <Dashboard /> },
])
Now you can:
- lazy load routes
- define layouts per route
- scale navigation cleanly
Example with code splitting:
const Dashboard = lazy(() => import('../features/dashboard/Dashboard'))
<Suspense fallback={<div>Loading...</div>}>
<Dashboard />
</Suspense>
If you delay routing, you’ll eventually have to:
- untangle component hierarchies
- rewrite layouts
- move logic around
That’s painful mid-project.
State Management: Most Problems Come From Misuse
Not all state is the same — treat it differently.
1. Local UI state
const [isOpen, setIsOpen] = useState(false)
Simple, predictable.
2. Server state (this is where people mess up)
Bad:
useEffect(() => {
fetch('/api/users')
.then(res => res.json())
.then(setUsers)
}, [])
Now repeat this across 10 components:
- loading state?
- error handling?
- retries?
- caching?
It becomes inconsistent fast.
Better approach (conceptually):
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
Now:
- caching is automatic
- loading/error is consistent
- refetching is built-in
3. Global state (only when needed)
Don’t do this:
// ❌ everything goes global
const useStore = create(() => ({
user: null,
theme: 'light',
modalOpen: false,
}))
You end up with a dumping ground.
Instead:
- keep most state local
- promote only what truly needs to be shared
Keep Components Boring
If a component is doing too much, it becomes fragile.
Bad:
function UserCard() {
const [user, setUser] = useState(null)
useEffect(() => {
fetch('/api/user').then(res => res.json()).then(setUser)
}, [])
return <div>{user?.name}</div>
}
Problems:
- tightly coupled
- hard to reuse
- hard to test
Better:
function useUser(id: string) {
return useQuery({
queryKey: ['user', id],
queryFn: () => fetchUser(id),
})
}
function UserCard({ id }: { id: string }) {
const { data } = useUser(id)
return <div>{data?.name}</div>
}
Now:
- logic is reusable
- UI is clean
- testing is easier
Performance: Fix the Right Problem
Most performance issues aren’t about heavy logic.
They’re about rendering too much.
Example: unnecessary re-renders
function Parent() {
const [count, setCount] = useState(0)
return <Child onClick={() => setCount(count + 1)} />
}
Every render creates a new function → child re-renders.
Fix (only if needed):
const handleClick = useCallback(() => {
setCount(c => c + 1)
}, [])
Example: large lists
// ❌ renders 1000 items
items.map(item => <Row key={item.id} />)
Better:
- virtualize the list
- render only visible items
Example: loading too much JS
// ❌ everything bundled together
import Dashboard from './Dashboard'
Better:
const Dashboard = lazy(() => import('./Dashboard'))
Load only when needed.
Responsive Design: Build It Into Components
Don’t fix responsiveness later.
Instead of:
/* page-level hacks later */
@media (max-width: 768px) {
.dashboard { ... }
}
Do this at component level:
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
Now every place using card-grid is responsive by default.
That’s how you avoid messy overrides later.
Security: Small Mistakes, Big Problems
Bad:
localStorage.setItem('token', jwt)
Any script can access it → vulnerable to XSS.
Better:
- store tokens in httpOnly cookies
- let the browser handle sending them
Another common issue:
<div dangerouslySetInnerHTML={{ __html: userInput }} />
If userInput is not sanitized → XSS risk.
Safer approach:
- sanitize input before rendering
- or avoid raw HTML entirely
Testing: Focus on Behavior, Not Implementation
Bad test:
expect(component.state.isLoggedIn).toBe(true)
Breaks when you refactor.
Better:
render(<Login />)
await userEvent.type(screen.getByLabelText('Email'), 'test@mail.com')
await userEvent.click(screen.getByText('Login'))
expect(screen.getByText('Welcome')).toBeInTheDocument()
Now you’re testing what matters:
- what the user sees
- not how the component works internally
CI: Because Good Intentions Don’t Scale
A simple pipeline catches a lot:
- run: npm run lint
- run: npm run test
- run: npm run build
Also add pre-commit hooks:
npx husky-init
Now:
- broken code doesn’t get committed
- issues are caught early
Without this, standards slowly slip.
What Actually Makes an App Last
Not a library. Not a pattern.
It’s a bunch of small decisions:
- organizing by feature, not file type
- separating logic from UI
- treating server state properly
- avoiding unnecessary global state
- thinking about performance early
- not ignoring security basics
- testing behavior, not internals
- automating quality checks
None of this is hard.
The hard part is doing it when the app is still small,
when it feels like overkill.
Because later, it’s not optional —
it’s expensive.
Top comments (0)