Discover why more companies are adopting a multi-cloud strategy to improve flexibility, reduce vendor lock-in, and enhance overall performance and...
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.
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.
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.
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.
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 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:
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.
REACT_APP_ environment variables to
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
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.
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:
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.
Jest injects its test functions into the global namespace, so
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
vi.importActual in places where we spy on library functions (
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
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
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 based our setup on this list of most recommended rules:
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".
To update Storybook we removed the addons
@storybook/builder-webpack5builder we were using. Instead we installed
@storybook/react-vite and updated the configuration in
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.
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.
Using multiple clouds? We connect, secure and deliver your applications.