EXPEDIA GROUP TECHNOLOGY — SOFTWARE

A (Small) Case for Function-Based React Components

Comparing the impact of React component implementation style on bundle size

Drew Walters
Expedia Group Technology

--

Code overlay of transpiled function vs class component

Can using function-based React components instead of classes make your bundles smaller? Let’s find out.

At Vrbo™ (part of Expedia Group™), we maintain a set of around 80 common React components that various internal and external applications use. We recently audited these common React components to see what changes we could make to improve the performance of the components within applications that are using them. One of the areas we identified that could be a quick small win was to convert stateless class-based components to function components. We hoped this would produce smaller files for the converted components. We knew it wouldn’t be a huge performance boost, but was a relatively safe change (unless some consumer was using a ref on the component) that could cumulatively decrease an application’s bundle size.

The developer typically creates React components using one of two methods: a function or an ES6 class. I’ve always been curious about how certain syntactic sugar and language features affect transpilation and file sizes. Let’s dive into examples of the two syntaxes and the resulting transpiled output for stateless, state-enabled, and render-optimized versions of each method of defining a component.

Stateless components

Stateless components are the simplest components in the React ecosystem. Here’s a comparison of the two ways to define a trivial React component that renders a button:

Function

import React from 'react';export default function Simple() {
return <button>{'Click'}</button>;
}

Note: There are multiple ways of defining the export (arrow function, exporting a variable, etc.). We used the format above because our transpiler configuration produced the smallest output file for it.

Class

import React, {Component} from 'react';export default class Simple extends Component {
render() {
return <button>{'Click'}</button>;
}
}

As shown above in italicized bold text, there are only a few differences in the source code when creating a function component and a stateless class-based component:

  • The class-based version imports and extends Component from react.
  • The class-based version uses a render method to return the JSX.

Now let’s compare the transpiled output of the two options. The function- and class-based source code above was run through Babel transpilation, which makes use of the following plugins:

Function (transpiled)

import React from 'react';export default function Simple() {
return React.createElement('button', null, 'Click');
}

Class (transpiled)

import _inheritsLoose from '@babel/runtime/helpers/inheritsLoose';
import React, { Component } from 'react';
var Simple =
/*#__PURE__*/
function (_Component) {
_inheritsLoose(Simple, _Component);
function Simple() {
return _Component.apply(this, arguments) || this;
}
var _proto = Simple.prototype;
_proto.render = function render() {

return React.createElement('button', null, 'Click');
};
return Simple;
}(Component);
export { Simple as default };

Transpilation increased the differences between the two implementations. Using the class syntactic sugar results in additional boilerplate injected into the output compared to the function example. We ran the transpiled code through terser to minify and remove any differences due to formatting. After minification, the raw difference between the two implementations is 180 bytes. So not a huge difference, but it is something :-). Notice the class-based version is invoking the inheritsLoose Babel helper, which results in additional code paths that will be taken during execution.

Components with state

Historically, once a component needed internal state or lifecycle methods, it meant moving the component to the ES6 class-based format. React 16.8.0, however, introduced hooks, which allow similar functionality of state and lifecycle management but through a function-based component. Here’s a comparison of the simple stateless component above modified to include some minimal state:

Function with hook state

import React, {useState} from 'react';export default function Simple() {
const [click, setClick] = useState(0);
function handleClick() {
setClick(click + 1);
}
return (
<button onClick={handleClick}>
{`Click ${click}`}
</button>
);
}

Class state

import React, {Component} from 'react';export default class Simple extends Component {
state = {
click: 0
}
handleClick = () => {
this.setState({
click: this.state.click + 1
});
}
render() {
return (
<button onClick={this.handleClick}>
{`Click ${this.state.click}`}
</button>
);
}
}

Ignoring the previous differences between function and class based components, there are new differences highlighted in italicized bold text when state management is introduced:

  • The function version is now importing and using useState to create and manage the state for click count while the class-based version is using a class property for state and using the React setState API to modify the value.
  • The class-based version requires the developer to have a proper understanding of this in JavaScript and to understand when the scope needs to be bound (i.e., property initializer syntax for handleClick).

Now, the comparison of transpiled output using the same build process as before:

Function with hook state (transpiled)

import _slicedToArray from '@babel/runtime/helpers/slicedToArray';
import React, { useState } from ‘react’;
export default function Simple() {
var _useState = useState(0),
_useState2 = _slicedToArray(_useState, 2),
click = _useState2[0],
setClick = _useState2[1];


function handleClick() {
setClick(click + 1);
}
return React.createElement('button', {
onClick: handleClick
}, 'Click '+ click);
}

Class state (transpiled)

import _inheritsLoose from '@babel/runtime/helpers/inheritsLoose';
import React, { Component } from 'react';
var Simple =
/*#__PURE__*/
function (_Component) {
_inheritsLoose(Simple, _Component);
function Simple() {
var _this;
for (var _len = arguments.length, args = new Array(_len),
_key = 0; _key < _len; _key++) {
args[_key] = arguments[_key];
}
_this =
_Component.call.apply(_Component,
[this].concat(args)
) || this;
_this.state = {
click: 0
};
_this.handleClick = function () {
_this.setState({click: _this.state.click + 1});
};
return _this;
}
var _proto = Simple.prototype;
_proto.render = function render() {
return React.createElement('button', {
onClick: this.handleClick
}, 'Click '+ this.state.click);
};
return Simple;
}(Component);
export { Simple as default };

Transpiling the hook-enabled function component injects the usage of a Babel helper method, slicedToArray, and then uses that to do the array destructuring. Otherwise, the code is left untouched. For the class-based component, code is injected to manage the scope of this. The arguments are also shallowly copied. Overall, there is more modification of the class-based code to enable the supported browser configuration, but the file size hasn’t grown significantly. The difference between the two minimized transpilations grew slightly to 259 Bytes (180 previously).

Render-optimized components

Unnecessary renders are a common React performance issue. It is very easy to create components that render when there has been no change. This can be a major drain on performance for an application. Thankfully, the React team is always looking for ways to optimize the performance of applications. They have provided several APIs to mitigate the re-render risk. APIs like memo, shouldComponentUpdate and PureComponent have all been added to help the developer restrict when a render of the component will occur.

In a function component, React.memo is used to wrap the component definition and instruct React to automatically do a shallow comparison of the properties passed into the component and, if they haven’t changed, don’t re-render. In a class-based component, extending PureComponent essentially performs the same shallow comparison of properties.

Function with hook state and memo

import React, {useState} from 'react';function Simple({disabled = false, onClick}) {
const [click, setClick] = useState(0);

function handleClick() {
const newClick = click + 1;
setClick(newClick);
onClick(newClick);
}
return (
<button disabled={disabled} onClick={handleClick}>
{`Click ${click}`}
</button>
);
}
export default React.memo(Simple);

Class state with PureComponent

import React, {PureComponent} from 'react';export default class Simple extends PureComponent {
state = {
click: 0
}
handleClick = () => {
const newClick = this.state.click + 1;

this.setState({
click: newClick
});
this.props.onClick(newClick);
}
render() {
return (
<button disabled={this.props.disabled}
onClick {this.handleClick}>
{`Click ${this.state.click}`}
</button>
);
}
}

In the above examples, we added some new properties (disabled, onClick) to simulate a use case where the component would unnecessarily re-render if the properties aren’t checked to determine if they have actually changed. To implement the property comparisons, React.memo has been added to wrap the function based component and the class based component has been modified to extend PureComponent.

Now let’s compare transpiled output using the same build process as before:

Function with hook state and memo (transpiled)

import _slicedToArray from '@babel/runtime/helpers/slicedToArray';
import React, { useState } from 'react';
function Simple(_ref) {
var onClick = _ref.onClick,
_ref$disabled = _ref.disabled,
disabled = _ref$disabled === void 0 ? false : _ref$disabled;

var _useState = useState(0),
_useState2 = _slicedToArray(_useState, 2),
click = _useState2[0],
setClick = _useState2[1];
function handleClick() {
var newClick = click + 1;
setClick(newClick);
onClick(newClick);
}
return React.createElement('button', {
onClick: handleClick,
disabled: disabled
}, 'Click '+ click);
}
export default React.memo(Simple);

Class state with PureComponent (transpiled)

import _inheritsLoose from '@babel/runtime/helpers/inheritsLoose';
import React, { PureComponent } from 'react';
var Simple =
/*#__PURE__*/
function (_PureComponent) {
_inheritsLoose(Simple, _PureComponent);
function Simple() {
var _this;
for (var _len = arguments.length, args = new Array(_len),
_key = 0; _key < _len; _key++) {
args[_key] = arguments[_key];
}
_this = _PureComponent.call.apply(_PureComponent,
[this].concat(args)) || this;
_this.state = {
click: 0
};
_this.handleClick = function () {
var newClick = _this.state.click + 1;
_this.setState({
click: newClick
});
_this.props.onClick(newClick);
};
return _this;
}
var _proto = Simple.prototype;
_proto.render = function render() {
return React.createElement('button', {
disabled: this.props.disabled,
onClick: this.handleClick
}, 'Click '+ this.state.click);
};
return Simple;
}(PureComponent);
export { Simple as default };

The impact of transpilation on the React.memo and PureComponent additions are really minimal as shown in Red above. In the function case, some transpilation has occurred to handle the property destructuring. In the class-based example, it simply replaced the use of Component with PureComponent. Overall, these changes have reduced the difference between the two options slightly as the minimized difference is now 245 bytes (259 previously).

Did I learn anything?

We as developers live in a world where we get to use the latest and greatest language syntax now and let the tooling worry about compatibility. Instead of always treating the transpiler as a black box, we can learn by diving into what is actually being generated to determine if there are better optimizations that still provide a great developer experience.

Transpiled and Minified Size Comparison

In this rudimentary exercise, switching a stateless class-based component to a function component saved us at least 180 bytes minified per component based on our browser support. Your mileage may vary, of course, depending on the configuration of your transpilation/minimization and component complexity, but the boilerplate for class transpilation is what it is. While 180 bytes is not a large amount, when we changed our stateless common components to function components, we achieved an overall~5% reduction in minified file size. We can reduce file size further if we additionally change class-based components with state over to use the new hooks feature. However, we are not prepared to enforce React 16.8.0 on all of our consumers quite yet.

Switching saved us at least 180 bytes per component

Beyond the file size benefit, with the introduction of hooks, React is recommending writing new components as function components instead of class-based components. Although they’ve stated they have no plans to remove class support, as their recommendation is for new components to use hooks, why not start to make the switch now and gain a few bytes in the process?

--

--