EthicsComputerCourses
Join training +91 90532 10052

Migrating Legacy React Projects

React · Lesson 38 of 41 Advanced 8 min read Updated

Your first React job will probably not start with npm create vite. It is far more likely that you will be handed an app written in 2019 or 2021: created with Create React App, full of class components, using Redux with connect, React Router 5 and Enzyme tests. That app earns money every day, so you cannot simply rewrite it. You must upgrade it safely while it keeps working.

In this lesson you will learn to recognise how old a codebase is, move a Create React App project to Vite, upgrade React 18 to React 19 with the official codemods, fix the APIs React 19 removed, and modernise the rest one piece at a time. The individual legacy features are covered in Class Components (Legacy) and Choosing State Management.

Recognise the era

A quick look at a few files tells you what you are dealing with:

You seeIt means
react-scripts start in package.jsonCreate React App (deprecated in February 2025)
ReactDOM.render(<App />, root)React 17 or older style (removed in React 19)
class X extends React.Component everywhereWritten before hooks were common (before 2019 to 2020)
connect(mapStateToProps)(X), createStoreRedux before Redux Toolkit
<Switch>, useHistory(), withRouterReact Router 5
import { shallow } from "enzyme"Enzyme tests, which do not support modern React
X.propTypes = {...}, X.defaultProps = {...}Runtime prop checks from before TypeScript was common

Write down the versions from package.json before you change anything, make sure the app builds and the tests pass, and commit. Every later step should be a small commit that leaves the app working, so you can always go back.

Step 1: Create React App to Vite

CRA is no longer maintained, and Vite starts and rebuilds far faster. The migration is mostly mechanical:

  1. Install Vite and the React plugin: npm install -D vite @vitejs/plugin-react, and remove react-scripts.
  2. Move public/index.html to the project root, delete every %PUBLIC_URL%, and add a script tag for your entry file.
  3. Rename files that contain JSX from .js to .jsx, because Vite treats only .jsx and .tsx files as JSX by default.
  4. Rename environment variables from REACT_APP_X to VITE_X, and change process.env.REACT_APP_X to import.meta.env.VITE_X and process.env.NODE_ENV to import.meta.env.MODE.
  5. Replace the scripts in package.json and add vite.config.js.
React JSX
// vite.config.js
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: { port: 3000 }, // optional: keep CRA's port so bookmarks still work
  build: { outDir: "build" }, // optional: keep CRA's folder if your host expects it
});
Diff
 <!-- index.html, now in the project root -->
-<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
+<link rel="icon" href="/favicon.ico" />
 ...
 <div id="root"></div>
+<script type="module" src="/src/index.jsx"></script>
Diff
 "scripts": {
-  "start": "react-scripts start",
-  "build": "react-scripts build",
-  "test": "react-scripts test"
+  "dev": "vite",
+  "build": "vite build",
+  "preview": "vite preview",
+  "test": "vitest"
 }

CRA ran tests with Jest; switching to Vitest is usually easy because the APIs match closely (jest.fn() becomes vi.fn()), as described in Testing with Vitest. Run the app, click through every page, check the console, then build and preview before you commit.

Step 2: React 18 to React 19

The React team's upgrade guide recommends going through React 18.3 first. It is identical to 18.2 but prints warnings for everything React 19 removes, so you can fix them while the app still runs:

Terminal
npm install react@18.3 react-dom@18.3
# run the app and tests, fix every deprecation warning, then:
npm install react@^19 react-dom@^19
npx codemod@latest react/19/migration-recipe
# TypeScript projects also need the type changes:
npx types-react-codemod@latest preset-19 ./src

A codemod is a program that rewrites your source code automatically, for example turning every ReactDOM.render call into createRoot. Review its changes like any other commit. These are the removals you are most likely to hit:

Removed in React 19Use instead
ReactDOM.render, ReactDOM.hydratecreateRoot(el).render(...), hydrateRoot from react-dom/client
unmountComponentAtNode(el)root.unmount()
findDOMNodeA ref on the element
String refs (ref="input", this.refs)createRef, useRef or a ref callback
Legacy context (contextTypes, getChildContext)createContext with static contextType or useContext
propTypes checksTypeScript (they are now silently ignored)
defaultProps on function componentsDefault values in the parameter list
act from react-dom/test-utilsact from react

The defaultProps change deserves a closer look, because nothing crashes; values are simply missing. Run this in React 19:

React JSXTry it yourself
import { Component } from "react";
import { createRoot } from "react-dom/client";

// Legacy style: React 19 ignores defaultProps on function components
function OldGreeting({ name, city }) {
  return <p>Old: Hello {String(name)} from {city}</p>;
}
OldGreeting.defaultProps = { name: "Guest", city: "Pune" };

// Modern style: default values in the parameter list
function Greeting({ name = "Guest", city = "Pune" }) {
  return <p>New: Hello {name} from {city}</p>;
}

// Class components still support defaultProps
class ClassGreeting extends Component {
  static defaultProps = { name: "Guest" };
  render() {
    return <p>Class: Hello {this.props.name}</p>;
  }
}

createRoot(document.getElementById("root")).render(
  <>
    <OldGreeting city="Kochi" />
    <Greeting city="Kochi" />
    <ClassGreeting />
  </>
);

The page shows "Old: Hello undefined from Kochi", "New: Hello Guest from Kochi" and "Class: Hello Guest". Search the codebase for defaultProps and propTypes before upgrading; the codemod npx codemod@latest react/prop-types-typescript can turn propTypes into TypeScript types.

Step 3: modernise gradually

After the tooling and React version are current, the old patterns still remain. Do not stop feature work for a big rewrite. Use the "boy scout rule": leave every file you touch a little better than you found it.

  • New code uses modern patterns: function components, hooks, Redux Toolkit slices or a lighter store, TanStack Query for server data.
  • Convert a class component when you change it, as shown in Class Components (Legacy). Keep class error boundaries; they are still needed.
  • Replace connect with hooks component by component. Old and new can live side by side in the same store:
React JSX
// Before: connect injects props
const mapStateToProps = (state) => ({ count: state.cart.items.length });
export default connect(mapStateToProps)(CartBadge);

// After: the component asks for what it needs
export default function CartBadge() {
  const count = useSelector((state) => state.cart.items.length);
  return <span>Cart ({count})</span>;
}
  • Upgrade the router one major version at a time, following each official upgrade guide (React Router 5 to 6 is the biggest change: <Switch> becomes <Routes>, component={Page} becomes element={<Page />}, useHistory() becomes useNavigate()). Version 8 also needs React 19.2 or later and imports from react-router instead of react-router-dom, as described in Routing with React Router.
  • Rewrite Enzyme tests with Testing Library when you touch a component. Enzyme tested component internals and has no support for modern React.

Common mistakes

  • Rewriting from scratch. It takes months, and the old app keeps changing meanwhile. Migrate in small, working steps.
  • Jumping several major versions at once. Go 18.3 to 19, router 5 to 6 to 7 to 8, each with its own commit and tests.
  • Skipping the 18.3 warnings. They list exactly what will break in 19.
  • Missing .jsx renames. Vite reports a JSX syntax error in a .js file.
  • Forgetting environment variables. process.env.REACT_APP_API is undefined in Vite; everything still builds, and requests go to the wrong place.
  • Trusting codemods blindly. Review and test their changes.

Key points

  • Identify the era from package.json and patterns such as react-scripts, ReactDOM.render, connect and <Switch>.
  • Move CRA to Vite: root index.html with a module script, .jsx files, VITE_ variables through import.meta.env, new scripts.
  • Upgrade React through 18.3, fix its warnings, then install 19 and run npx codemod@latest react/19/migration-recipe.
  • React 19 removed ReactDOM.render, findDOMNode, string refs, legacy context, propTypes checks and function-component defaultProps.
  • Modernise gradually: hooks in new code, convert classes when touched, connect to useSelector, router one major version at a time.
  • Protect every step with tests and small commits.

Exercise

You are given this file from an old CRA project, src/index.js. List the changes it needs for Vite and React 19, then write the new version as src/index.jsx. import React from "react"; import ReactDOM from "react-dom"; import App from "./App"; const api = process.env.REACT_APP_API_URL; if (process.env.NODE_ENV !== "production") console.log("API:", api); ReactDOM.render(<App apiUrl={api} />, document.getElementById("root"));

Show solution

Changes: rename the file to .jsx (it contains JSX), drop the unneeded import React (the automatic JSX runtime handles it), read the variable as import.meta.env.VITE_API_URL after renaming it in .env, use import.meta.env.DEV for the development check, and replace ReactDOM.render with createRoot.

React JSX
// src/index.jsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";

const api = import.meta.env.VITE_API_URL;
if (import.meta.env.DEV) console.log("API:", api);

createRoot(document.getElementById("root")).render(
  <StrictMode>
    <App apiUrl={api} />
  </StrictMode>
);

index.html in the project root must load it with <script type="module" src="/src/index.jsx"></script>. Adding StrictMode is optional, but it helps reveal old unsafe patterns during development.

Quick Quiz

1. Which version does the React team recommend installing before upgrading to React 19?

2. In a project moved from CRA to Vite, process.env.REACT_APP_API_URL is now undefined. What is the fix?

3. A function component uses Greeting.defaultProps = { name: "Guest" }. What happens in React 19?

4. What is the safest way to modernise a large legacy codebase?

Learn React with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call