jahed.dev

Migrating to React v18

It's been well over a year since React 18 was released. I recall trying to upgrade soon after its release and being swamped with TypeScript errors. So I put it in the backlog. Like most things in the backlog, it went further and further back, out of sight, out of mind.

Well, today I thought I'd finally get to it. I wanted to try out the Suspense API which has been talked about for years now.

To give some background, my project, FrontierNav, is a web application that's been in development for over 7 years now. It's kind of like a public garden, where I can try out different ideas while building towards something useful that people can use. It's gone through several major migrations already, this being the latest.

What needs updating

Usually when a core dependency has a major update, a bunch of other dependencies update too to take advantage of it. In my case, I have:

And, that's it. React v18 doesn't introduce any major breaking changes to existing APIs so most dependencies work as-is. React Redux v8 uses some new features from React v18, though it does still support v17. I don't use server-side rendering and I try to keep API boundaries under control so this should be straight forward.

React Redux v8

Since React v16.8 introduced Hooks, I've been slowly moving away from React Redux. But there's a large amount of core features still reliant on it. Upgrading to v8 was straight forward, no breaking changes, they even include types out of the box now.

However, I did notice Redux deprecated createStore and instead recommends using a completely separate library called Redux Toolkit which provides its own helpers such as configureStore. It's weird, and a bit annoying. It's not a migration path for anyone that wants to use Redux without Redux Toolkit, so flagging it as deprecated is just adding noise.

I assume at some point, Redux will become something more like @reduxjs/core and the toolkit will become the default. Looking through Redux Toolkit, it's providing a similar API to what I've cooked up specific to my needs, so that's interesting. But I'd rather do the work to finish off removing Redux, rather than tie myself to yet another API that might get deprecated in a few years.

@types/react and @types/react-dom

The most immediate issue, the one that made me hold off on this migration for over a year, is that TypeScript's checks now fail with hundreds of new type errors. This isn't React v18, it's the Definitely Typed definitions, which is maintained by a different group of people. Frustratingly, because Definitely Typed is a monorepo and their READMEs, I assume, are auto-generated with only basic metadata, finding easy to digest release notes for these types is difficult. So I just focused on fixing the type errors one by one.

After an hour or so, all of the types were fixed. These changes only affected types, so the behaviour of the code was still the same. As long as TypeScript said it was good, there wasn't much to worry about.

No children prop by default

The largest change was that FunctionComponent<Props> no longer defaults Props with a children prop. So I had to manually go through over a hundred files and add that. I could've introduced a wrapper, but I'd still need to update the imports of those files. The change makes sense. I've been using an explicit Props for most components anyway and found it strange how children was always accepted.

Explicit event handlers

Previously, I could get away with using useCallback and passing in the result to an onClick with no type declarations. Now, I need to specify the type of the function explictly for it to pass type checks. So functions need to be tagged with MouseEventHandler<HTMLButtonElement>, ChangeEventHandler<HTMLInputElement> and so on, or their arguments had to be explicitly typed.

I may have forgotten the sequence of events here, but it seems updating React's types made things a lot more strict. Was TypeScript smarter with inference previously or was it just treating functions as any? I'm not entirely sure, but I wasn't bothered to rollback and check, I had over a hundred files to go through!

React DOM v18

While React doesn't have breaking API changes, React DOM does. It no longer uses a top-level render export. Instead, creating the root and rendering to it has been split into two steps.

render(<App />, document.getElementById("#app"));
const root = createRoot(document.getElementById("#app"));
root.render(<App />);

And that's all. React DOM's migrated.

React v18

Now for the meat of the migration.

Rendering differences

As I mentioned, React v18 does not change any existing APIs. However, it has changed how it renders underneath. Because of this, unless if the application is extremely well tested, it's possible to miss some things that might have broken or changed in behaviour in certain scenarios. I encountered one such change while manually testing FrontierNav's Data Tables. The virtual scrolling was broken, it would sometimes scroll back to an older value.

I think this is related to the new "Automatic Batching" feature. So previously, what would trigger 2 renders, now triggers 1. Though it's also possible the lifecycle of useEffect changed enough to cause it to use an older value. Either way, changing it to useLayoutEffect so that the newest value is used did the trick. I know that useLayoutEffect can apparantly reduce performance but I didn't notice any differences in my measurements. The Data Tables' scrolling behaviour needs to be improved anyway so whatever works for now is acceptable.

This was the only rendering issue I encountered. Though, I still had to manually test everything else just in case.

React.lazy

FrontierNav uses React Loadable to dynamically import code-splitted components. Now with React.lazy introduced in v18, React Loadable is no longer needed.

React.lazy works with the new Suspense API. So it's possible to create boundaries outside of the component to more broadly handle loading states. It's similar to the flexibility provided by Error Boundaries.

Here's what loading a component with React Loadable looked like:

import loadable from "@loadable/component";

// ...

const importComponent = (loader) => {
    return loadable<P>(loader, { fallback: <Loading /> });
}

const ComponentA = importComponent(() => import("./ComponentA"))
const ComponentB = importComponent(async () => (await import("./ComponentB")).ComponentB)

// ...

<Switch>
    <Route path="/about">
        <ComponentA {...props} />
    </Route>
    <Route>
        <ComponentB {...props} />
    </Route>
</Switch>

I've simplified this example a bit. FrontierNav uses wrappers which provide additional features but they're not relevant.

After the migration, it becomes:

import { ComponentType, lazy, Suspense } from "react";

// ...

const LoadableComponent = async (loader) => {
    return lazy<ComponentType<P>>(async () => {
        const result = await loader();
        if ("default" in result) {
            return result as { default: ComponentType<P> };
        }
        return { default: result };
    });
}

const ComponentA = importComponent(() => import("./ComponentA"))
const ComponentB = importComponent(async () => (await import("./ComponentB")).ComponentB)

// ...

<Suspense fallback={<Loading />}>
    <Switch>
        <Route path="/about">
            <ComponentA {...props} />
        </Route>
        <Route>
            <ComponentB {...props} />
        </Route>
    </Switch>
</Suspense>

I had to use a default hack as some components used named exports rather than default exports. React.lazy really wants a default-exporting module from import for some reason.

Suspense

At this point, the migration was done. Everything was working as before. I can finally try out Suspense! Or so I thought...

It turns out Suspense isn't ready for public use. It's only available through select frameworks like Next.js.

In React 18, you can start using Suspense for data fetching in opinionated frameworks like Relay, Next.js, Hydrogen, or Remix. Ad hoc data fetching with Suspense is technically possible, but still not recommended as a general strategy.

FrontierNav uses its own router implementation, so this realisation sucked. In a way, it feels like integrations outside of a popular few are second-class citizens in the React ecosystem now. It's similar to that change Redux did with createStore. I can understand where its coming from, working with a smaller group to finalise a feature, but it's leaving a bad taste.

Conclusion

There's a lot to think about after this migration.

I should prioritise removing React Redux before it gets stale again. It looks like Redux v5 is coming out soon and I'm pretty sure I don't need it. The thing I loved about Redux is its simplicity, but it seems the ecosystem around it is always trying to complicate things (Redux Thunk, Redux Saga, etc.). I can move over to a combination of useReducer and useContext probably.

I'll still be using React for the foreseeable future. It's still an enjoyable way to develop web applications. Though, my app is in partial limbo now. Some parts use "Concurrent React" with "Suspense", and others are stuck doing things the old way. Like Redux and Redux Tookit, React is now a "core" to a much larger system of dependencies, with Next.js being the default. So it seems more and more that the React ecosystem is becoming the Next.js ecosystem, which is a shame.

I'm definitely not migrating to Next.js, it's going to need an almost full rewrite, by which point, something else will probably show up. In fact, I just checked after writing this. Next.js is moving to a new "App Router" API. Maybe it's already live, React's docs say it's in beta, but Next.js's docs default to it without such a warning. I'm already getting a headache.

I am seeing a trend here though for FrontierNav. Over the long run, it's best to reduce the burden of maintaining compatibility with third-party APIs as much as possible. This usually means replacing dependencies with smaller scoped project-specific implementations. I've done this previously by removing React Router, React Leaflet, and a bunch of others that I don't remember.

Having said that, some dependencies are more difficult to wean off of. React being one of them. There's also Firebase. I am dreading the day when Firebase decides to kill off its "legacy" APIs in favour of its new modular ones. I've been going on about moving over to a standard SQL database for a while, but Firebase has been too convenient. It has been a bit unreliable this past month though, with slow authentication and random 403s in places that never failed before. Hopefully if that continues, it'll serve as good motivation.

In short, dependencies are great for getting started, but at some point, it's nice having foundations that aren't constantly moving.

Thanks for reading.