Migrating a CRA project to Vite.js

Ori's journey from CRA to Vite.js: The challenges we faced, the benefits we reaped, and why we felt the need to make the shift.

Introduction

For a long time the canonical way to start a new React project was via the create-react-app command line utility provided by the React team. In their updated documentation they now recommend frameworks provided by the community. Since then the web has been awash with blog posts and guides to move away from CRA and adopt a modern alternative.


Why migrate?

Under the hood CRA provides a lot of configuration, presets and utilities: A dev environment with hot-module-reloading, testing, Typescript support (if you chose so), consistent formatting, ESlint support, integration of image files and probably more. No doubt about it: life as a React developer is good.

Of course, there are downsides: Experienced developers complain about the lack of customisation options. With CRA you get a ton of configuration for free, but your options to override the defaults are limited. If you need some advanced Webpack features, you just can't do it with CRA. Some 3rd-party NPM packages exist to extend the configuration for specific use cases but that introduces yet another dependency with an uncertain future and one has to ask why CRA has to be so restrictive in the first place.

Since esbuild and other ESM based bundlers popped up on the scene Webpack is also being criticised for its performance. Waiting for the dev server to start was just one of the things we took for granted; after all, we only did it once at the start of a session and then HMR took care of incremental updates. Webpack didn't suddenly become slower, but once there was an alternative that could bring up the dev environment within seconds we were all less inclined to accept the initial startup time.

CRA has also become a bit dated and some of its numerous dependencies specify rather old package versions. Keeping CRA up to date doesn't seem to be a high priority for the maintainers. Vite.js comes with a lot of features integrated so we don't have to rely on so many additional NPM packages. In a previous project our direct and indirect dependencies were reduced by 1400 packages by migrating from CRA to Vite.js.

And then there is the always present FOMO: When all your tech media channels are full of CRA alternatives, it's easy to feel left out. We don't think this is a good enough reason to give up your existing setup but we do encourage a critical review of the status quo in your project. Try out other bundlers in a PoC projects and then evaluate the benefits and downsides for your production project.


Why now?

For us at Ori the need to migrate arose when we encountered timeout issues during our CI test runs. Our test suite completed when run locally on our laptops but regularly timed out when run on Github. This could have been fixed easily with the Jest timeout configuration option but CRA does not expose this as an option. So we started looking for alternatives and - based on previous experience - focused on evaluating Vite.js.


Existing setup

CRA - Create React App is a collection of settings and scripts to develop, test and build a Javascript or Typescript application without having to go through all the necessary configuration. It was a great starting point when we set up our project a few years ago but is admittedly getting out of touch.

Our application is bundled with different parameters depending on which environment it is built and deployed to, all of which are managed with environment variables. We need to make sure that these still are applied correctly after the migration.

The test stack includes Jest, React Testing Library, and Mock Service Worker (MSW). We also use Storybook to preview our components and work offline when the API is not yet available (thanks to MSW). These tools need to remain usable and stable.


Step 1: Define migration goals

Replacing the foundations of your build framework is a daunting task. Not many know the inner workings of Webpack, Vite.js, etc. well enough to set up a new build configuration from scratch. The rest of us relies on templates and migration guides.

It may be tempting to define the migration goal as a seamless transition from one bundler to the other. Considering the extent of the existing CRA configuration we expected that an alternative tool would not replicate all the settings we were used to immediately. We knew to aim low and set achievable targets that could be reached with a few days of work. We set our acceptance criteria for the initial migration to the following:

  • working deployment bundle - Our existing code base of Typescript, SCSS and SVG files had to be bundled into a deployable Javascript package. That one should be obvious.
  • working dev server - We had to maintain our dev environment with live reload and integration of our CSS and image assets. If the new bundler could not offer the same developer experience as CRA, it was not worth the migration.
  • Typescript setup - Our existing source code structure of Typescript files needed to be supported. Adjustments to the existing Typescript configuration were acceptable.
  • Prettier setup - Yes: form over content! It was important to us that the new setup would integrate well with Prettier. Consistency in the source files is essential for an effective code review, so we have set up Prettier to format our source files.

You should notice one important omission from the list above: Testing. Our existing tests use Jest as the test runner. Jest alone does not support Typescript. If we wanted to continue using it, we had to set up Babel or ts-jest on top of the Vite.js configuration. That meant additional work for the initial migration and that our test code would be treated differently from the code running in our development environment.

Considering our options, we thought that we should give Vitest a go instead. Vitest uses the same configuration file as Vite.js. This eliminates the extra configuration files and should ensure that the code that is tested is the same code that is bundled into the JS package. Vitest is a rather young project, but it already supports all the features we need. So we set an additional stretch-goal:

  • Migrate tests to Vitest - We could afford migrating our tests as a stretch-goal because we could stop developing new features for a sprint and convert all existing tests to Vitest in a follow-up task. If Vitest proved not to be mature enough, we could always fall back to Babel/ts-jest to get our tests working again.

All or nothing

An additional consideration was that our deployment pipeline is based on the main branch of the frontend code. We have multiple development environments but they all run the same code at various stages of maturity. We could not just create a branch for the migration and dedicate a development environment until we worked out the kinks with the new bundler. Once the changes for Vite.js were committed, we had to see the conversion through until we had the deployment pipeline working again - or roll back and stick with CRA.

To minimise the impact, one of our engineers used his laptop to implemented and verify all migration goals except for the actual deployment part. We then had an opportunity to test and complete the deployment when our stakeholders agreed to a 'consolidation sprint' that was dedicated to engineering tasks only. That gave us enough time to test the deployment and go through a few rounds of debugging if necessary.

In the end, the Vite.js configuration worked the first time around, and we found no issues with the new bundle in our development environments.


Step 2: Functional migration

We read several blog posts and tutorials about migrating from CRA to Vite and began the migration process by following this one by Cathal Mac Donnacha that matched our project particularly well. Here are a few additional comments:

Convert react-app-env.d.ts to vite-env.d.ts

Our react-app-env.d.ts contained global type definitions. So instead of just replacing it with an empty vite-env.d.ts file we moved our custom types over to the new file and used the opportunity to clean up definitions that were no longer used. We also have a top-level types folder with TS type definition files and moved some of our types into that location.

Rename REACT_APP_ environment variables to VITE_

At the time of the migration our development environments and the production bundle were set up with environment variables. These needed to be renamed from REACT_APP_ to VITE_. That's an easy enough update using search and replace.

On reviewing the use cases for these variables we discovered that most of them were quick hacks to inject a runtime value into the frontend code. Since then we have eliminated environment variables from our code and replaced them with virtual API endpoints. But that will be the subject of another blog post.

Verify deployment

Our build pipeline generates the React app Javascript bundle and builds a Docker image based on Nginx to server the HTML and JS files. This image is then deployed into multiple environments. The environment specific configuration is done with environment variables applied to the container. So the only way to confirm a working deployment was to create new image, deploy it and try to access or web app online.

To reduce the risk we first built the container image locally using the same Dockerfile that is used during CI. With the right environment variables we were able to connect the React app served by this container to the API provided by one of our online development environments.

With that confirmation we merged our development branch with the Vite.js based build setup into the main branch. This triggers our release process and after a few minutes we were able to confirm that the new UI version was deployed and could be used to log in to all of our development environments.

We had successfully completed the essential part of our migration. Now we needed to restore our ability to run tests locally during development and in CI as part of our QA and deployment pipeline.

Stretch Goal: Use Vitest instead of Jest

To use Vitest, we installed the required packages:

npm install --save-dev vitest jsdom


We then configure Vitest to use
jsdom as environment. Vitest supports many of the configuration options you may be used to from Jest. So we can finally increase our test timeout value; the one setting that prompted our migration in the first place.

import type { InlineConfig } from "vitest"; ... const config: UserConfig & { test: InlineConfig } = { ... test: { environment: "jsdom", testTimeout: 15000, ... } }


Jest injects its test functions into the global namespace, so
describe or test are available without explicitly importing them into a file. You can configure Vitest to do the same (globals config option) but we prefer a clean global namespace and instead added explicit imports of the used functions to each test file. That was the biggest effort during the migration but it is in no way challenging.

Test mocks created with jest.fn() can simply be replaced with vi.fn(). We also had to replace jest.requireActual with vi.importActual in places where we spy on library functions (react-router-dom specifically).

With these updates most of our tests were working again, both locally and in our Github CI pipeline. The later part was especially exciting because we could now just rely on the status of the test step instead of a close inspection of the test logs to understand if the failure was due to a random timeout or an actual error.

This was the primary goal of our migration and we had achieved it without any unforeseen issues along the way.


Step 3: Things that we missed

Typescript type checking

Cathal proposes the following update to the scripts section in package.json:

"scripts": { "start": "vite", "build": "tsc && vite build", "serve": "vite preview" },


Did you notice the tsc && in the build step? Vite.js does no type checking during the build process. It just transforms TS files to JS and merges them into the necessary chunks. If you want to have some assurance that your files don't contain any type errors you can run tsc before the the vite build command and abort if tsc encounters an error. This slows down your build but you can be sure that no type inconsistencies sneak into your JS bundle.

To enable 'live' type checking in the preview dev environment, we installed the vite-plugin-checker plugin and added the following to vite.config.ts:

const config: UserConfig & { test: InlineConfig } = { plugins: [ ..., checker({ typescript: true, }), ], ... }


This runs a type checking process in a dedicated thread in the preview environment and during tests. When you preview code and you introduce an error you now get an overlay in your browser window that displays the error message and source code location.

Update ESLint rules

As a CRA user you may not have set up custom linting rules and relied on the quite sensible defaults. These defaults will be gone now. Even if you had custom rules defined, they would have extended the defaults and you now have to replicate the missing base rules.

Pick a good base configuration

We tried to base our rule set on the popular AirBnB rules, but found that they no longer provide a significant benefit over the combination of Typescript and VSCode's format-on-save based on Prettier. These two combined eliminate most - if not all - potential Javascript errors that the AirBnB rules used to address. Beyond that, we found that AirBnB rules were too opinionated for our code base and would have required far too many exceptions or updates to make them worthwhile.

We based our setup on this list of most recommended rules:

  • eslint:recommended
  • plugin:react/recommended
  • plugin:react/jsx-runtime
  • plugin:prettier/recommended
  • plugin:import/recommended
  • plugin:storybook/recommended
  • plugin:@typescript-eslint/recommended

On top of that we have a few exceptions defined in the rules section. You should embrace the opportunity to define your own set of linter rules that are supported by your entire team. There is no "one size fits all".

Storybook setup

To update Storybook we removed the addons @storybook/preset-create-react-app and @storybook/builder-webpack5builder we were using. Instead we installed @storybook/react-vite and updated the configuration in .storybook/main.ts:

import type { StorybookConfig } from "@storybook/react-vite"; import { mergeConfig } from "vite"; const config: StorybookConfig = { // ... framework: { name: "@storybook/react-vite", options: {}, }, }; export default config;


Startup time vs loading time

While the startup time for the dev server or Storybook is now probably below 1 second, the time it takes until the browser has loaded all files that are required to render a page is significant. This is especially noticeable when we reload a page during a session, instead of relying on HMR to update the content.

Looking at the network tab in the browser, it's easy to understand why: Every single source file is requested, redirected and finally successfully loaded. Our project requires around 1000 unbundled files every single time a page is loaded. After multiple reloads in Chrome, the page becomes unresponsive and we have to close the browser tab and reopen another one to continue our session. There may be ways to improve this via lazy loading of dependencies, but we haven't found the time to investigate this further.

We also noticed that a local preview of Storybook does not always completely load the dependencies for a story in the right order, or a story does not update when dependencies finally become available. This may be related to our heavy use of MSW to provide mock API responses for our stories. Together with the long time for a full page reload our developer experience here is somewhat worse that it was before.


Summary

All things considered we think that the upgrade has improved our project setup:

  • Our unit tests run successfully in CI and don't time out any more.
  • We have a fewer configuration files and each file is concise enough to understand (with a bit of effort)
  • We have fewer dependencies that need to be installed during CI and the remaining dependencies are more up-to-date.
  • The migration allowed us to clean up legacy aspects of our project setup that would otherwise not have been touched
  • Based on our new understanding of the project setup we now have the confidence to make changes when we agree they are beneficial, not only when they become necessary because something is broken

Our migration from CRA to Vite.js was just supposed to make our CI test runs more reliable. Updating our build system to Vite.js gave a us an opportunity to look at our setup from a new perspective. In the process we have gained a better understanding of our tools and their settings. That new knowledge improves our project more than the fixed CI pipeline.

Ori Global Cloud

Using multiple clouds? We connect, secure and deliver your applications.

Whether you're developing cutting-edge AI solutions or maintaining vital legacy applications, we run your applications seamlessly on any cloud, on-premises or edge environment. So you can keep your focus on driving your business forward.

LEARN MORE

 

Similar posts