DEV Community

Cover image for Escaping Dependency Hell: How I Migrated a Legacy CRA App to React 19 & Vite
Imamul Islam Ifti
Imamul Islam Ifti

Posted on

Escaping Dependency Hell: How I Migrated a Legacy CRA App to React 19 & Vite

If you are maintaining a React application created 4 or 5 years ago, you know the feeling. You run npm install, and the terminal bleeds red with vulnerability warnings. You try to upgrade one package, and three others break. You are trapped in Dependency Hell.

After years of building features on top of Create React App (CRA) with React 17, React Router 5, Bootstrap 4, our project had accumulated significant technical debt.

The pain points were real:

  • Build times: 3-5 minutes for production builds
  • Hot reload: Not there. Had to refresh manually to see the changes
  • Security vulnerabilities: multiple npm audit warnings
  • Modern tooling: Couldn't use Tailwind CSS v4 or Shadcn/ui
  • Dead dependencies: Libraries like connected-react-router and redux-form hadn't been updated in years

I recently took on the challenge of upgrading this massive legacy project to the modern era of React 19, Vite, Tailwind v4, and React Router v7.

Running npm update was not an option. That path leads straight into dependency hell. Here is how I did it, avoiding the "npm update" trap, and the specific challenges I faced along the way.

The Strategy: "The Vite Lift & Shift"

Instead of trying to modernize in place, I decided to create a fresh Vite project and systematically move code over.

The Dependency Purge

Before moving code, I audited my dependencies. A lot of libraries from 2020 are dead or have better alternatives in 2025.

Legacy Package New Equivalent Why?
react-scripts Vite Instant dev server, native ESM support, faster builds, no Webpack 4 baggage.
react-router-dom@v5 react-router-dom@v7 v5 is outdated and risky; v7 aligns with modern React and data routers.
connected-react-router (Removed) React Router v6+ manages history internally; Redux no longer needs routing state.
react-helmet react-helmet-async react-helmet is not compatible with React 18/19 concurrent & streaming rendering.
jquery (Removed) React 18/19 and jQuery conflict; direct DOM mutation breaks React’s render model.

Phase 1: Create a Clean Vite Shell

1.1. Initialize Vite (React)

npm create vite@latest my-new-app -- --template react
cd my-new-app
npm install
Enter fullscreen mode Exit fullscreen mode

No CRA. No react-scripts. No Webpack.

1.2. Install Only “Safe” Dependencies

Before touching legacy code, install:

  • axios
  • react-hook-form
  • date library (moment or date-fns)
  • state management (Redux Saga)
  • etc.

This prevents compounding errors later.

Phase 2: The Core Migration (Structure & Logic)

Now that we have a fresh Vite shell, I copied my old /src folder into the new project. But before touching any React code, I had to fix the "Physical" structure.

2.1. The index.html Shift

In CRA, index.html lived in the /public folder, and Webpack "magically" injected the script.
In Vite, index.html is the front door. It must live in the root directory.
I moved index.html from /public to the root /, and manually added the script tag to point to my React entry file (which Vite does not auto-inject):

<!-- index.html (Moved to Root) -->
<body>
  <div id="root"></div>
  <!-- Vite requires this explicit module entry -->
  <script type="module" src="/src/main.jsx"></script>
</body>
Enter fullscreen mode Exit fullscreen mode

(Note: My imports were mostly relative, so I didn't need to configure path aliases yet to get the app running.)

Phase 3: The Big Breaking Changes

3.1. The Router Revolution (v5 to v7)
React Router completely redesigned its API:
SwitchRoutes

// OLD
import { Switch, Route } from 'react-router-dom';

<Switch>
  <Route path="/dashboard" component={Dashboard} />
  <Route path="/profile" component={Profile} />
</Switch>

// NEW
import { Routes, Route } from 'react-router-dom';

<Routes>
  <Route path="/dashboard" element={<Dashboard />} />
  <Route path="/profile" element={<Profile />} />
</Routes>
Enter fullscreen mode Exit fullscreen mode

useHistoryuseNavigate

// OLD
import { useHistory } from 'react-router-dom';
const history = useHistory();
history.push('/login');

// NEW
import { useNavigate } from 'react-router-dom';
const navigate = useNavigate();
navigate('/login');
Enter fullscreen mode Exit fullscreen mode

I used VS Code's find-and-replace with regex to batch convert this:

Find: useHistory\(\)
Replace: useNavigate()

Find: history\.push\(
Replace: navigate(
Enter fullscreen mode Exit fullscreen mode

3.2. Updating the State Machine: Redux

The most archaic part of my project was the Redux configuration. I was using the deprecated createStore, manually composing middleware, and—worst of all—using connected-react-router to force routing state into Redux.
In modern React (v18/v19) combined with React Router v6/v7, we don't need routing data in our global store. Hooks like useNavigate and useLocation handle that natively.

3.2.1 High-level comparison

Area Old Code (Legacy Redux) New Code (Redux Toolkit)
Store creation createStore + compose + applyMiddleware configureStore
Router integration connected-react-router + history Removed
DevTools Manual / commented Automatic
Middleware setup Manual, error-prone Safe defaults + explicit
Redux best practices Optional Enforced by default
Boilerplate High Low
Future support Deprecated patterns Official Redux path

3.2.2 The biggest win: configureStore
Before (manual & fragile):

// store.js (Legacy)
import { createStore, applyMiddleware, compose } from "redux";
import createSagaMiddleware from "redux-saga";
import { createBrowserHistory } from "history"; // <--- Dependency heavily coupled to DOM
import rootReducer from "./reducers";
import { routerMiddleware } from "connected-react-router"; // <--- Obsolete

const history = createBrowserHistory();
const sagaMiddleware = createSagaMiddleware();

export const store = createStore(
  rootReducer(history), // <--- Passing history into reducers
  compose(
    applyMiddleware(
      routerMiddleware(history), 
      sagaMiddleware
    )
  )
);
Enter fullscreen mode Exit fullscreen mode

Problems

  • Easy to misconfigure
  • No defaults
  • DevTools must be wired manually
  • No safety checks

After (The Modern Redux Toolkit):

I switched to configureStore from @reduxjs/toolkit. It automatically sets up the Redux DevTools extension and comes with better default middleware.
I also completely removed connected-react-router and the history object dependency. The store is now purely for data, not navigation.

configureStore({
  reducer: rootReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({ serializableCheck: false })
      .concat(sagaMiddleware),
});
Enter fullscreen mode Exit fullscreen mode

What I unlocked for the future

Because of this change, I can now:

  • Migrate reducers to createSlice
  • Introduce RTK Query gradually
  • Remove boilerplate reducers
  • Drop Redux Saga later if needed

None of this was cleanly possible before.

Phase 4: Dependencies to Replace (Not Update)

4.1. react-helmet → react-helmet-async

The old react-helmet breaks with React 18+'s concurrent rendering:

// OLD (breaks in React 18+)
import { Helmet } from 'react-helmet';

// NEW (concurrent-safe)
import { Helmet, HelmetProvider } from 'react-helmet-async';

// In your root component
<HelmetProvider>
  <App />
</HelmetProvider>
Enter fullscreen mode Exit fullscreen mode

4.2. Remove connected-react-router
React Router now handles history internally. I no longer need to sync it with Redux:

import { connectRouter } from "connected-react-router";

// OLD: Had to pass history through Redux
const rootReducer = (history) => combineReducers({
  router: connectRouter(history),
  auth: authReducer,
  // ... other reducers
});

// NEW: Just combine your reducers
const rootReducer = combineReducers({
  auth: authReducer,
  // ... other reducers
});
Enter fullscreen mode Exit fullscreen mode

4.3. The Silent Cleanup
Surprisingly, my app didn't crash with the dreaded "Polyfill Errors" (missing Buffer or process) that plague many Vite migrations.
Why? Because I updated my dependencies first.
By jumping to axios v1.13, and removing jQuery, I effectively removed the libraries that relied on legacy Node.js globals. Modern libraries use browser-native standards (like Uint8Array) instead of Node's Buffer.

However, I still had housekeeping tasks to finish the job:

4.3.1. The Environment Variable Swap

CRA uses process.env.REACT_APP_. Vite uses import.meta.env.VITE_.
I had to rename all my .env variables and update the code calls.

// Old
const apiKey = process.env.REACT_APP_API_KEY;

// New
const apiKey = import.meta.env.VITE_API_KEY;
Enter fullscreen mode Exit fullscreen mode

Phase 5: The "Silent" Breakers (Redux, CSS & SVGs)

Even after the dependencies were fixed and the server started, the app wasn't quite right. I ran into three specific runtime issues where the "Old Way" simply didn't work in the "New World."

5.1. Redux Toolkit: The "Mutation" Police

In my old Redux setup, I was essentially running in "Wild West" mode. I could accidentally mutate state in a reducer, and React might still re-render, hiding the bug.

The Problem:
Redux Toolkit (which I switched to via configureStore) comes with immutableCheck middleware enabled by default. As soon as I clicked a button, the app crashed with:
Invariant failed: A state mutation was detected inside a dispatch.

The Fix:
I had to find the reducers where I was directly modifying objects and rewrite them to return new objects (standard Redux pattern).

// Old Reducer (Previously worked silently, but bad practice)
case 'UPDATE_USER':
  state.currentUser.name = action.payload; // Direct mutation!
  return state;

// New Reducer (Immutable pattern)
case 'UPDATE_USER':
  return {
    ...state,
    currentUser: {
      ...state.currentUser,
      name: action.payload
    }
  };
Enter fullscreen mode Exit fullscreen mode

Note: If you use createSlice in Redux Toolkit, you CAN mutate state (thanks to Immer), but since I was migrating legacy reducers, I had to fix the manual mutations.

5.2. The CSS "Zebra Striping" Shift

I noticed my tables looked wrong. Previously, I used :nth-of-type to color alternate rows. After the update (likely due to changes in how Bootstrap 5 or React renders the DOM tree), this selector started targeting columns instead of rows, or just behaving erratically.

The Fix:
I switched the selector to :nth-child. It’s a subtle difference, but :nth-of-type looks at the element tag type, while :nth-child looks at the strict index in the parent.

5.3. The SVG Crash

CRA (Webpack) used to perform "magic" that allowed you to import SVGs as components or strings indiscriminately. Vite is more explicit.
When I tried to use my SVGs, the whole page crashed.

The Fix:
I had to install vite-plugin-svgr and update my configuration to explicitly allow SVGs to be imported as React components.

Update vite.config.js

import svgr from "vite-plugin-svgr";

export default defineConfig({
  plugins: [
    react(),
    svgr(), // Enable SVG transformation
  ],
});
Enter fullscreen mode Exit fullscreen mode

Update the imports
Vite requires a query parameter ?react to know you want the component version, not the URL string

// ❌ Old (CRA Magic)
import { ReactComponent as Logo } from './logo.svg'; 

// ✅ New (Vite Explicit)
import Logo from './logo.svg?react'; 
Enter fullscreen mode Exit fullscreen mode

Phase 6: Infrastructure Polish & Deployment

Once the app was running and bug-free, I did some final cleanup to make the developer experience (DX) better and ensure deployment worked.

6.1. Setting up Path Aliases

My project originally used relative imports (../../components/Button), which worked fine in Vite out of the box. However, I wanted to modernize the codebase to use cleaner imports.

I updated vite.config.js to recognize some paths:

resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
      '@assets': path.resolve(__dirname, './src/assets'),
      '@components': path.resolve(__dirname, './src/components'),
      '@Icons': path.resolve(__dirname, './src/Icons'),
    },
  },
Enter fullscreen mode Exit fullscreen mode

Before (The "Dot-Dot" Hell):

// Hard to read, breaks if I move this file
import Button from "../../../components/Button";
import { formatDate } from "../../../utils/dateHelpers";
import { UserContext } from "../../../context/UserContext";

const Profile = () => { ... }
Enter fullscreen mode Exit fullscreen mode

After (Clean & Refactor-Safe):

// Clean, readable, and works from anywhere
import Button from "components/Button";
import { formatDate } from "@/utils/dateHelpers";
import { UserContext } from "@/context/UserContext";

const Profile = () => { ... }
Enter fullscreen mode Exit fullscreen mode

6.2. The build vs dist Trap

This is a silent killer for CI/CD pipelines.

  • CRA compiles your production app into a folder named build.
  • Vite compiles your production app into a folder named dist.

Conclusion: A Junior Dev’s Journey

As a junior engineer, taking on a full-scale migration from Webpack to Vite felt like performing surgery while reading the manual. I’m proud that the app is now faster, cleaner, and running on modern React 19, but I also know I have a lot left to learn. This guide represents my current "best effort" solution. If you are a senior dev reading this and spot a security risk I missed, or a cleaner way to handle the Redux refactor, please drop a comment below. I’m sharing this to help others facing the same "Legacy Code" anxiety, but I’m also here to learn from you.

Top comments (0)