DEV Community

Cover image for How to Build a React App from Scratch That Doesn't Fall Apart in 6 Months
Anisubhra Sarkar (Ani)
Anisubhra Sarkar (Ani)

Posted on • Edited on

How to Build a React App from Scratch That Doesn't Fall Apart in 6 Months

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
  • 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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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 /> },
])
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

Simple, predictable.

2. Server state (this is where people mess up)

Bad:

useEffect(() => {
  fetch('/api/users')
    .then(res => res.json())
    .then(setUsers)
}, [])
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

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,
}))
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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)} />
}
Enter fullscreen mode Exit fullscreen mode

Every render creates a new function → child re-renders.

Fix (only if needed):

const handleClick = useCallback(() => {
  setCount(c => c + 1)
}, [])
Enter fullscreen mode Exit fullscreen mode

Example: large lists

// ❌ renders 1000 items
items.map(item => <Row key={item.id} />)
Enter fullscreen mode Exit fullscreen mode

Better:

  • virtualize the list
  • render only visible items

Example: loading too much JS

// ❌ everything bundled together
import Dashboard from './Dashboard'
Enter fullscreen mode Exit fullscreen mode

Better:

const Dashboard = lazy(() => import('./Dashboard'))
Enter fullscreen mode Exit fullscreen mode

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 { ... }
}
Enter fullscreen mode Exit fullscreen mode

Do this at component level:

.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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 }} />
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Also add pre-commit hooks:

npx husky-init
Enter fullscreen mode Exit fullscreen mode

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)