Upgrading an Ancient React Application
While the world of office work has changed for us and many others since we first released Lunch in 2016, the app itself has been happily chugging along, attracting regular users and helping teams with one of the most pressing questions of the day: where’s lunch?
That said, letting an app run, unfettered and generally unsupported, is not the wisest of decisions. At least, Dependabot would increasingly remind us of that, with its rampant suggestions for dependency upgrades, many of which started to become nontrivial to merge. As an isomorphic application based off of React Starter Kit, these upgrades were often quite important as they affected some of the more sensitive parts of the app’s code, such as request parsing, cookie storage, password encryption, and database access.
It was because of this that I decided to take this feature-complete, relatively bug-free app, and begin to rewrite major parts of it to resemble a more 2023-style codebase.
Getting started
When I was first working on Lunch, the underlying React Starter Kit (RSK) library was also undergoing constant improvement. React was in its infancy, as were all of the tools surrounding it, so maintaining Lunch as essentially a fork of React Starter Kit meant we could rebase the code whenever we needed to pull in the latest improvements.
Fast forward 5+ years into the future, and RSK has continued to evolve, continuing to incorporate the hottest technologies in an attempt to keep new projects up-to-date with the latest and greatest. This meant discarding or replacing many of its base technologies:
- Hono (and the CDN edge) instead of Express
- Recoil instead of Redux
- React Router instead of Universal Router
- Emotion instead of Sass
- Material UI instead of React Bootstrap
- Vitest instead of Mocha
- TypeScript instead of Babel
Adapting to all of these new technologies would have meant starting from scratch, which in my view would be a waste of time. I decided to find a middle ground, where I would upgrade Lunch’s existing technologies while replacing those that outlived their usefulness.
The best place to start was with a simple audit of the codebase.
yarn audit
While Dependabot and its ilk are notorious for the amount of false positives they can return, I did begin to receive alarming notifications about many of the server-side technologies that Lunch used. So I decided to start there: see how up-to-date I could get by simply starting with what’s in package.json
.
A simple yarn audit
command returned hundreds of upgradable packages, many of which had simple security fixes applied as patch- or minor-level upgrades, meaning—as long as I trusted an entire industry to be comfortable with the concept of semantic versioning—I could simply run yarn audit fix
and let my package manager take care of the majority of the work.
That said, there were several packages that required more involved implementation changes, as they themselves had been redesigned or rewritten. For the rest of this post, I’ll delve into the steps I took, and the order in which I took them, to get Lunch’s dependency tree looking spick and span.
Bumping front-end dependencies
I started by reading the release notes or changelogs of many of the UI libraries we used for the project. Geosuggest, Autosuggest, Google Map React, to name a few, only needed basic alterations in order to continue working. Anything making use of Google’s APIs benefited from the use of an API loader, ensuring each component didn’t end up unnecessarily needing its own credential setup.
Others had reached their end-of-life, such as React Flip Move, which required some JS/CSS effort to upgrade to something similar or with a broader application, like react-flip-toolkit:
Before:
<FlipMove
typeName="ul"
className={s.root}
disableAllAnimations={!flipMove}
enterAnimation="fade"
leaveAnimation="fade"
staggerDelayBy={40}
staggerDurationBy={40}
>
{ids.map((id) => (
<li key={`restaurantListItem_${id}`}>
<ScrollElement name={`restaurantListItem_${id}`}>
<RestaurantContainer
id={id}
shouldShowAddTagArea
shouldShowDropdown
/>
</ScrollElement>
</li>
))}
</FlipMove>
After:
<Flipper
element="ul"
className={s.root}
flipKey={ids}
handleEnterUpdateDelete={handleEnterUpdateDelete}
staggerConfig={{
default: {
reverse: true,
speed: 0.75,
}
}}
>
{ids.map((id) => (
<Flipped key={id} flipId={id} onAppear={onAppear} onExit={onExit} shouldFlip={shouldFlip} stagger>
<li key={`restaurantListItem_${id}`}>
<ScrollElement name={`restaurantListItem_${id}`}>
<RestaurantContainer
id={id}
shouldShowAddTagArea
shouldShowDropdown
/>
</ScrollElement>
</li>
</Flipped>
))}
</Flipper>
And the long-lived monolithic date library Moment has a project update linking to alternatives, from which I chose Day.js. Its API is very similar, aside from the welcome change of the core Dayjs
object being immutable.
Before:
const comparisonDate = moment().subtract(12, 'hours');
...
comparisonDate.subtract(24, 'hours');
After:
let comparisonDate = dayjs().subtract(12, 'hours');
...
comparisonDate = comparisonDate.subtract(24, 'hours');
The largest effort involved upgrading React Bootstrap, my version of which was several major versions behind the core Bootstrap library. Attributes have been renamed, such as bsSize
and bsStyle
becoming size
and variant
, respectively. Components have also been renamed, such as the collection of Form components (or removed entirely, such as the Jumbotron component):
Before:
<FormGroup controlId="addUserForm-name">
<ControlLabel>
Name
</ControlLabel>
<Row>
<Col sm={6}>
<FormControl
type="text"
onChange={this.handleChange('name')}
value={name}
/>
</Col>
</Row>
</FormGroup>
...
<HelpBlock>
Please tell the user you are inviting to check their spam folder if they don’t receive anything shortly.
</HelpBlock>
After:
<Form.Group className="mb-3" controlId="addUserForm-name">
<Form.Label>
Name
</Form.Label>
<Row>
<Col sm={6}>
<Form.Control
type="text"
onChange={this.handleChange('name')}
value={name}
/>
</Col>
</Row>
</Form.Group>
...
<Form.Text>
Please tell the user you are inviting to check their spam folder if they don’t receive anything shortly.
</Form.Text>
What’s new with Redux
Redux was among the libraries desperately needing a version bump. As one of the first popular state libraries, its reducer pattern was so well-received that it made it into mainline React. The vanilla React useReducer
hook is generally what I reach for when building a new user interface, but Lunch’s components were so entrenched in the app’s Redux store - not to mention making use of Redux actions on the backend for websocket communication - that I decided to put in the work to move to @reduxjs/toolkit
:
Before:
import { combineReducers, createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
const middleware = [thunk.withExtraArgument(helpers)];
const enhancer = applyMiddleware(...middleware);
const store = createStore(
combineReducers(reducers),
normalizedInitialState,
enhancer
);
After:
import { combineReducers } from 'redux';
import { configureStore } from '@reduxjs/toolkit';
const store = configureStore({
reducer: combineReducers(reducers),
middleware: (getDefaultMiddleware) => getDefaultMiddleware({
thunk: {
extraArgument: helpers,
},
}),
});
Redux also became much more vocal about putting non-serializable values in the store, which is fair, as we were admittedly passing around callback functions the wrong way:
index.js:2178 A non-serializable value was detected in the state, in the path: `modals.confirm.action`.
React 16 to 18
Despite the fact that Lunch’s codebase made use of many older or deprecated aspects of React, such as class components and legacy context, it took almost no time to upgrade to React 18. The only required change was making use of the new createRoot
API:
Before:
import ReactDOM from 'react-dom';
const renderReactApp = isInitialRender ? ReactDOM.hydrate : ReactDOM.render;
renderReactApp(
<App context={context}>{route.component}</App>,
container,
() => {
...
}
);
After:
import { createRoot, hydrateRoot } from 'react-dom/client';
const root = createRoot(container);
const AppWithCallbackAfterRender = () => {
useEffect(() => {
...
});
return <App context={context}>{route.component}</App>;
};
if (isInitialRender) {
hydrateRoot(container, <AppWithCallbackAfterRender />);
} else {
root.render(<AppWithCallbackAfterRender />);
}
Enzyme vs. React Testing Library
Before I could update React, though, I had to contend with the apparent abandonment of the Enzyme testing utility. Enzyme was used to shallowly render components and test their output. But the community realized that integration testing components was more rewarding than unit testing them, so React Testing Library (RTL) gained major traction. More importantly, there was no Enzyme “adapter” for anything newer than React 16, so it was time to update some tests.
Fortunately, the extent of our Enzyme usage revolved around testing for the presence of certain elements or components, so the conversion to RTL was pretty straightforward. To make things smoother, since the app was using Mocha and Chai instead of Jest, which RTL supported out-of-the-box, I installed chai-jsdom to add DOM-based assertions to its syntax.
Before:
import { shallow } from 'enzyme';
import { Home } from './Home';
import RestaurantAddFormContainer from '../../../components/RestaurantAddForm/RestaurantAddFormContainer';
it('renders form if user is logged in', () => {
props.user.id = 1;
const wrapper = shallow(<Home {...props} />);
expect(wrapper.find(RestaurantAddFormContainer).length).to.eq(1);
});
After:
import { render, screen } from '@testing-library/react';
import { Home } from './Home';
it('renders form if user is logged in', async () => {
props.user.id = 1;
render(<Home {...props} />);
expect(await screen.findByText('Add Restaurant')).to.be.in.document;
});
Additionally, Enzyme allows you to pass context when shallow rendering. React Testing Library expects you to render your context providers and consumers manually. Given we were still using legacy context, this added some additional bootstrapping logic around my tests:
Before:
const wrapper = shallow(
<Layout {...props} />,
{ context: { insertCss: () => undefined } }
);
After:
class LayoutWithContext extends React.Component {
static childContextTypes = {
insertCss: PropTypes.func.isRequired
};
getChildContext() {
return { insertCss: () => undefined };
}
render() {
return <Layout {...this.props} />;
}
}
render(<LayoutWithContext {...props} />);
Node 18 and Webpack 5
Upgrading Node required no changes, but paved the way for other upgrades, particularly Webpack 5. No major API changes to the Webpack config, aside from naming changes to assets, source maps, and allowlists.
Additionally, I was able to stop using Bluebird, which has a note to use native promises instead. I only had to make sure that any use of Promise.all
was passed an array:
Before:
exports.down = queryInterface => Promise.all(
queryInterface.removeColumn('users', 'created_at'),
queryInterface.removeColumn('users', 'updated_at')
);
After:
exports.down = queryInterface => Promise.all([
queryInterface.removeColumn('users', 'created_at'),
queryInterface.removeColumn('users', 'updated_at')
]);
I was also able to remove almost all PostCSS processors. CSS has evolved.
Sequelize 4 to 6
My ORM of choice, Sequelize, decided to change how it handles database column names containing underscores. This comment describes the change, which after reading it 4 or 5 times, still doesn’t make sense to me. So instead of trying to grapple with haphazard underscores, I simply changed all column names in the database to camel case.
This was a relatively simple change compared to what I had to do with Sequelize once I got TypeScript up and running.
JavaScript to TypeScript
In the midst of all of these upgrades, despite maintaining unit and integration test coverage, I felt as if returning to an old codebase and attempting to greatly refactor it would lead to a whole slew of new bugs. That’s why I decided to incorporate TypeScript, to add an additional guardrail around development - if the types don’t match, the code doesn’t compile.
This was the most significant departure from the old RSK base, as it required rewriting much of the server and client code. (Fortunately, most components and tests could remain untouched with the allowJs
flag being enabled in tsconfig.json
. I also decided to keep using Babel to be able to compile the remaining JavaScript code, whereas I moved to ts-loader, and ts-node for tests, in order to run TypeScript.)
This process is a bit too involved to go far into detail here, but suffice it to say, it took a while. The good news with upgrading an old project is that its old dependencies have likely had entries added to Definitely Typed, meaning that even if upgrades aren’t available for these old dependencies, someone out there has generated types to work with modern code. On the other hand, some dependencies had new versions that removed custom prop types, such as the removal of IntlShape from react-intl, which made this effort a great opportunity to upgrade them.
The TypeScript upgrade also gave me the opportunity to simplify some premature optimizations I had made, like depending too much on constants, and making our Redux reducers a series of Map
s instead of just switch
statements:
Before:
import ActionTypes from '../constants/ActionTypes';
export default new Map([
[ActionTypes.INVALIDATE_TAGS, state => update(state, {
$merge: {
didInvalidate: true
}
})
],
...
]);
After:
import { Reducer } from '../interfaces';
const tags: Reducer<"tags"> = (state, action) => {
switch(action.type) {
case "INVALIDATE_TAGS": {
return update(state, {
$merge: {
didInvalidate: true
}
});
}
};
export default tags;
Not to mention, this effort also revealed a series of subtle bugs regarding transmitting or storing data, which I was all too happy to fix.
Sequelize and TypeScript
Sequelize 6 has partial support for TypeScript, and full support is one of the main efforts of the yet-to-be-released Sequelize 7. Getting my ORM setup to be fully type-safe meant having to either abandon or change my use of association-based methods, as it wasn’t possible for TypeScript to typecheck them.
I ended up installing sequelize-typescript, which helped bridge the gap, but came at the expense of rewriting all of the app’s model definitions:
Before:
import { sequelize, DataTypes } from "./db";
const Invitation = sequelize.define(
"invitation",
{
email: {
type: DataTypes.CITEXT,
allowNull: false,
unique: true,
},
confirmedAt: {
type: DataTypes.DATE,
},
confirmationToken: {
type: DataTypes.STRING,
unique: true,
},
confirmationSentAt: {
type: DataTypes.DATE,
},
},
{}
);
After:
import { Column, DataType, Model, Table } from "sequelize-typescript";
@Table({ modelName: "invitation" })
class Invitation extends Model {
@Column({ allowNull: false, type: DataType.CITEXT, unique: true })
email: string;
@Column
confirmedAt: Date;
@Column({ unique: true })
confirmationToken: string;
@Column
confirmationSentAt: Date;
}
Furthermore, I had originally tasked Babel with transforming TypeScript and applying its many presets and plugins to both JS and TS files, but due to the use of decorators, it became difficult to reconcile the differences between these two languages, and I started telling Webpack to use ts-loader
for TS files instead.
Conclusion
I can now rest a little bit easier knowing that Lunch is resting on a combination of tried-and-tested, but also more secure code. Ironically, the 24k+ lines of code changes didn’t result in any new features or visual updates, but the app is measurably faster and leaner, and this bit of maintenance has made it that much easier to plan out future fixes and upgrades.
Continue the conversation.
Lab Zero is a San Francisco-based product team helping startups and Fortune 100 companies build flexible, modern, and secure solutions.