Blog post

Lessons learned upgrading to React 18 in SonarQube

Blog Author Phil Nash

Phil Nash

Developer Advocate JS/TS

6 min read

  • JavaScript
  • TypeScript
  • React
  • SonarQube
The SonarQube and React logos

The SonarQube interface is written in React and we recently went through the process of upgrading from version 17 to 18. To give you a bit more of the picture, the app is also written in TypeScript and uses Jest with React Testing Library (RTL) for testing.


We wanted to share the biggest three issues we faced and the lessons we learned as we carried out the upgrade. In brief, they were:


  1. Some TypeScript types changed
  2. React Testing Library must also be updated
  3. React 18 brings breaking changes


Let's get into what these meant and how we dealt with them.


Note: this post was co-written by the SonarQube front-end team of David Cho-Lerat, Ambroise Christea, and Philippe Perrin.

TypeScript type changes

The React 18 upgrade guide points out that both @types/react and @types/react-dom must be updated as you upgrade, and the "most notable change is that the children prop now needs to be listed explicitly when defining props."


The good news for this update is that Sebastian Silbermann, from the React core team, maintains a collection of codemods that help to automatically update the types when upgrading from React 17. 


You can run the codemod using npx like so:

npx types-react-codemod preset-18 ./src

It will present a number of transforms you can apply and will default to the transforms that are required.


For example, the transform to list the children prop explicitly will take a component that looks like this:

MyComponent: React.ComponentType<P>

and replace it with:

MyComponent: React.ComponentType<React.PropsWithChildren<P>>

Watch out though, we found that the codemod can end up nesting the PropsWithChildren type and your type might end up looking like this:

MyComponent: React.ComponentType<React.PropsWithChildren<React.PropsWithChildren<<P>>>

While this isn't harmful, you will want to correct these types as you come across them.


The new types are also more picky in some areas. For example, previously we were able to override the type of children in an interface like this:

interface ComponentProps {
  children: React.ReactNode;
}

interface Props extends ComponentProps {
  children: () => React.ReactNode;
}

With the React 18 types, this no longer works and you must now omit the declaration of children first.

interface Props extends Omit<ComponentProps, 'children'>  {
  children: () => React.ReactNode;
}

The new types also don't allow implicit any types for the parameters to a useCallback function. You will need to explicitly declare the types, for example:

import { useCallback, MouseEvent } from 'react';

export function SubmitButton(props: ButtonProps) {
  const handleClick = useCallback((event: MouseEvent) => {
    event.preventDefault();
    // Do something else.
  }, [...]);
  return <button onClick={handleClick}>Submit</button>;
}

When you upgrade @types/react to version 18, expect to see a few issues like this.

React Testing Library update

We found that many of our tests that used to pass now failed after updating React and RTL. There were two categories of failure: timing and calls to act().

Fake timers

RTL uses a setTimeout for a defined delay when simulating user events, but this does not play nicely with Jest's fake timers. This caused tests to hang and fail with a timeout.


In version 14.1.0, RTL added an advanceTimers option to the setup step for user-event so that you can provide your own timer. We were able to fix our tests by passing the jest.advanceTimersByTime method.

const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime })

Acting out

The dreaded act(...) warning had plagued our codebase for a while and in some cases had been patched up by adding an extra call to act around some RTL events and helpers.

RTL helpers use act internally, so while adding an extra call to act was initially a valid workaround to suppress the warning, it now caused the tests to fail. Removing the excess calls to act got the tests passing again. If you still receive warnings, Kent C. Dodds has a comprehensive post on what causes the act(...) warning and how to fix it in the context of RTL.

React 18 breaking changes

The biggest change in React 18 is right at the root of the application. ReactDOM.render is no longer supported and should be replaced with createRoot. While on the surface this seems like a simple change that provides a better way for React to manage the root of the application, it actually changes how React renders your application. Two new features are enabled: automatic batching and the new concurrent renderer.


Concurrent rendering allows React to interrupt the rendering of a component if there is other work that needs to be done at a higher priority. You opt-in to this behaviour by defining a state update as a transition using the useTransition hook. If you don't opt-in, your components will render sequentially as before, so this should make no difference as you upgrade your application.


However, automatic batching is enabled immediately. Automatic batching is a performance improvement in React 18 to reduce the number of renders by collecting state changes into one update. It can cause some unexpected behaviour though.


We discovered some parts of our code fell foul of this new batching when several tests started failing. The tests were expecting parts of components to be rendered, yet found them to be empty.


We realized that this batching includes any execution sub-context in the same scope! This means that if you have a setState, then a Promise that also does a setState when it resolves/rejects, both state changes will be batched at the end of the scope if they happen close to each other (for instance in tests, where mocked queries are almost instantaneous).


In this simplified example of two methods in a class-based component we set a state, then, within the body of an asynchronous function, we relied on that new state in a conditional.

class MyComponent extends React.Component {
  // ...

  fetchProjects = async () => {
    const { shouldFetch } = this.state;

    if (shouldFetch) {
      this.setState({ loading: true });
      const projects = await this.fetchProjects();
      this.setState({ loading: false, projects: projects });
    }
  }

  handleFetchProjectsClick = async () => {
    this.setState({ shouldFetch: true });
    await this.fetchProjects();
  }

  // ...
}

In React 17 when handleFetchProjectsClick was called it would set the shouldFetch state to true, then call on fetchProjects. Within fetchProjects the test for shouldFetch would be true and the data was fetched. This is because the state update task happens before the fetchProjects promise is handled.


In React 18 with createRoot the projects aren't fetched because the state update is deferred until the end of handleFetchProjectsClick, so when fetchProjects runs shouldFetch would still be falsy.


If you need to ensure code runs after state is set, you can either use the callback form of setState or the new ReactDOM.flushSync() method.

class MyComponent extends React.Component {
  // ...

  handleFetchProjectsClick = async () => {
    this.setState({ shouldFetch: true }, () => {
      await this.fetchProjects();
    });
  }

  // ...
}

Asynchronous renders in tests

The above fixed the component rendering on the page, but we found that the tests continued to fail. Debugging these failures step-by-step showed that the content was not present on the initial render, but when we re-rendered the component it then appeared. Because we now used the setState callback method to fetch the data, the initial render didn't include the content.


Our tests were using RTL's synchronous methods to find that content on the page, like this:

expect(screen.getByText('Content')).toBeInTheDocument();

Replacing RTL's synchronous getBy queries with the asynchronous await findBy query fixes the issue. For example:

expect(await screen.findByText('Content')).toBeInTheDocument();

Using a findBy query uses waitFor under the hood to give the DOM time to update when it doesn't happen immediately.

The upgrade was a success

The SonarQube UI is now running successfully on React 18. While some of the issues we came across had to do with the test suite needing an upgrade, others were caught because we have a comprehensive test suite across both unit tests for components and end-to-end tests avoiding production failures when it was time to deploy. Writing testable code is one part of writing adaptable code, one of the properties of Clean Code. Those tests highlighted things that needed to be updated and gave us the confidence that when they passed, the application was ready. 


If you are running React 17 and planning an upgrade, hopefully, these experiences can help you with some of the pitfalls.


Get new blogs delivered directly to your inbox!

Stay up-to-date with the latest Sonar content. Subscribe now to receive the latest blog articles. 

By submitting this form, you agree to the Privacy Policy and Cookie Policy.

the Sonar solution

SonarLint

Clean Code from the start in your IDE

Up your coding game and discover issues early. SonarLint takes linting to another level empowering you to find & fix issues in real time.

Install SonarLint -->
SonarQube

Clean Code for teams and enterprises

Empower development teams with a self-hosted code quality and security solution that deeply integrates into your enterprise environment; enabling you to deploy clean code consistently and reliably.

Download SonarQube -->
SonarCloud

Clean Code in your cloud workflow

Enable your team to deliver clean code consistently and efficiently with a code review tool that easily integrates into the cloud DevOps platforms and extend your CI/CD workflow.

Try SonarCloud -->