Recently, we released Retool Mobile in public beta to make it fast for any company to build and ship an internal mobile app for iOS/Android. As a critical part of this product, we developed a cross-platform mobile app in React Native: its purpose is to render and update applications built in the Retool visual editor.
When creating a foundation that other applications are built on, performance is absolutely critical. In this post, we'll explain three performance optimizations we had to make in React Native to make our app as fast and fluid as possible:
- Replacing third-party component libraries with our own bespoke lightweight libraries
- Avoiding unnecessary re-rendering of components when managing state change with Immutable.js and Redux
- Removing code-splitting (a relic from our React codebase) that was holding up the UI thread in React Native
The team that started building Retool Mobile about a year ago consisted of only a few engineers. We also didn’t know if the product was going to be successful, so to develop our cross-platform mobile app, we did two things:
- We decided to use React Native, which let us fork the core Retool codebase (built on React) and get started quickly. But React and React Native aren’t exactly twins, they are more like siblings, resulting in some issues and surprises that we’ll explore in this article.
- We leveraged open-source libraries where possible to get to an MVP quickly.
When we started alpha trials of Retool Mobile, we received reports that our mobile app was extremely slow at rendering customers’ apps, especially on Android. Our alpha customers were building mobile apps with hundreds of components—much more complex than the smaller applications we’d used to test and build internally.
In one instance, the customer’s app had over 320 components and over 25 API calls across 14 screens. In the screen-recording below, I’m trying to switch each tab from left to right. After I touch each tab, the screen hangs for a long time before switching.
So, what would cause the app to hang like this? As a starting point, let’s dig into how React Native works.
Note: React Native is in the process of rolling out a New Architecture, which we’re excited about and which should solve many of the issues discussed here. In this article, we’ll focus on the previous/existing architecture.
React Native is single-threaded and works by sending asynchronous JSON updates across the “Bridge”—a bus that connects the Native layer (where the main application is running, handling UI events and updating the UI) and the JavaScript layer (where the React Native bundled JS is executed and performs the business logic of the application). The JS layer can spawn threads for custom modules to offload some of the large computation work, but it still keeps the main UI thread on hold until everything is done processing.
Due to this architecture, step 4 in the diagram above—which can involve expensive computations, large data processing, component mounting logic, and animations—can block the main UI thread, causing stuttering and slow navigation transitions.
With this context in mind, let’s take a look at some of the main culprits of our performance problems, and how we resolved those issues.
When we started off building Retool Mobile, we leveraged some open-source React Native component libraries for things like buttons, containers, etc. so we could iterate quickly on the product. After investigating our performance issues, we found that one source of the slowdown was some heavy computation and rendering that these component libraries performed. Third-party libraries are trying to serve a broad audience of developers, so functionality is often the priority over performance.
For example, the functionality of the `Container` component can range a lot. You can write basic Container components that support different layout types and padding within 100 lines of code. On the other hand, you can write a fully customizable container that supports different background colors, different types of padding, device responsiveness, etc. — but you might have to sacrifice some performance.
Lesson learned: Be mindful of heavy computation and unnecessary rendering when you write your components. We decided to rewrite our components and only add the functionality that we needed. For example, for a basic Container component, a third-party library’s implementation was about over 1000 lines, whereas our custom component was 100 lines. We estimate that by writing components ourselves, we improved performance by 15–20%.
With React/React Native and Redux, you can create powerful apps, but it’s also easy to create slow apps due to unnecessary re-rendering. Let’s say that we want to build a To-do App and we have a To-do List component.
1/*
2const initialState = {
3 data: {
4 todos: []
5 }
6}
7*/
8const getState = state => state
9const dataSelector = createSelector(
10 [getState], (state) => state.data
11)
12
13const TodoList = () => {
14 const data = useSelector(dataSelector)
15
16 return <List todos={data.todos} />
17}
18
19const App = () => (
20 <div>
21 <TodoList />
22 </div>
23);
24
At a glance, we’re using createSelector (or connect) to cache the result of `dataSelector`(if any of the results are === different than before, it re-runs the output selector). This means we won’t have to re-render the To-Do List if the `data` doesn’t change, and that should help us with performance…right?
Well, let’s say that we added a `User` component leveraging `dataSelector` and the `user` field inside the `data` field in the state. The User component will re-render when we add any items to the To-do List component. That’s because caching `dataSelector` will be meaningless as the `data` field in the state is constantly changing—even though for this component, we only need `user` in the `data`.
1/*
2const initialState = {
3 data: {
4 todos: []
5 user: {name: “James”}
6 }
7}
8*/
9
10const getState = state => state
11const dataSelector = createSelector(
12 [getState], (state) => state.data
13)
14
15const TodoList = () => {
16 const data = useSelector(dataSelector)
17
18 return <List todos={data.todos} />
19}
20
21const User = () => {
22 const data = useSelector(dataSelector)
23
24 return (
25 <div>
26 User: {data.user.name}
27 </div>
28 );
29};
30
31const App = () => (
32 <div>
33 <User />
34 <TodoList />
35 </div>
36);
37
Now we know the problem, how can we prevent re-rendering?
We can create a new `userSelector` that will cache the result of the `user` field in the state. Even if other fields are changing, since we have a specific selector for `user`, it will not re-render the component.
1/*
2const initialState = {
3 data: {
4 todos: []
5 user: {name: “James”}
6 }
7}
8*/
9
10const getState = state => state
11const dataSelector = createSelector(
12 [getState], (state) => state.data
13)
14const userSelector = createSelector(
15 [getState], (state) => state.data.user
16)
17
18const TodoList = () => {
19 const data = useSelector(dataSelector)
20
21 return <List todos={data.todos} />
22}
23
24const User = () => {
25 const user = useSelector(userSelector)
26
27 return (
28 <div>
29 User: {user.name}
30 </div>
31 );
32};
33
34const App = () => (
35 <div>
36 <User />
37 <TodoList />
38 </div>
39);
40
Note that re-rendering can be a problem on both React and React Native, but it will cause more performance issues on mobile due to resource constraints. You can imagine that multiple re-rendering at the root level will critically slow down an app’s performance.
It’s important to keep track of whether your component is doing unnecessary re-rendering, and the simplest way to do so is to use a counter. Below, we show a counter tracking how the User component is re-rendered when we add items to the To-Do List component. The slow version is running on the left, and the fixed version that prevents re-rendering is on the right. Here’s an interactive CodeSandbox example that shows the same results.
Most React apps have their JS files bundled with a tool like Webpack. For our Retool web app, we configured our webpack to support code-splitting. This improves performance by letting us serve more complex bundles on an as-needed basis, also called “lazy loading.”
Here’s an example of lazy loading with code-splitting in React:
1const Header = asyncComponent({
2 resolve: () => System.import('./Header),
3 LoadingComponent: () => <div>Loading header</div>,
4 ErrorComponent: ({ error }) => <div>{error.message}</div>
5});
6
7const Body = asyncComponent({
8 resolve: () => System.import('./Body),
9 LoadingComponent: () => <div>Loading body </div>,
10 ErrorComponent: ({ error }) => <div>{error.message}</div>
11});
12
13const Footer = asyncComponent({
14 resolve: () => System.import('./Footer),
15 LoadingComponent: () => <div>Loading footer</div>,
16 ErrorComponent: ({ error }) => <div>{error.message}</div>
17});
18
19
20// We are loading header.js, body.js, footer.js in parallel
21const App = () => {
22 return (
23 <>
24 <Header/>
25 <Body/>
26 <Footer/>
27 </>
28 )
29}
30
After porting this code to React Native, we realized it was causing performance issues for our mobile app. It’s not common to do code-splitting in React Native because all the JavaScript is already bundled in the app. (For web, your browser needs to download all the JS files, whereas a mobile app already comes with those files when you download it from the app store.)
In other words, we were lazy loading components when we didn’t need to, which blocked the JS processing layer, causing the UI thread to become unresponsive.
Our solution to this was to load all the JS at once when the application loads:
1import Header from './Header'
2import Body from './Body'
3import Footer from './Footer'
4
5const App = () => {
6 return (
7 <>
8 <Header/>
9 <Body/>
10 <Footer/>
11 </>
12 )
13}
14
In addition to the other two changes above, this resulted in our app having much better performance.
Fast performance is a must on mobile. During the alpha trials for Retool Mobile, we focused on identifying and ironing out performance issues that mainly stemmed from choices that we made early on in development. There is always a tradeoff between fast iteration to prove out an idea and having to do work later on to get production ready! This is just the beginning of our performance improvement. We are in progress with a few other projects (like the new Fabric architecture) to bring the product to the next level.
This work led to some interesting learnings, and so we wanted to share with you—we hope this helps someone else! If you haven’t played around with Retool Mobile yet, you can get started for free. We’d love to hear what you think!
Reader