React being massively used for frontend-intensive applications comes with its unique ways of performance and size optimizations. Improving both will have a considerable measurable impact on the React package bundle size. The lower the bundle size, the faster the loading time, considering that we are focusing on client-rendered applications.
Server-side rendering would further improve load time. In server-side rendering when a user requests a web page, the React components are rendered as HTML code in the server itself. Then this pre-rendered page is sent to the browser, allowing the user to see the page immediately without the overhead of the JS loading time.
But that’s a different story altogether. Let’s mainly focus on trying to improve our client-side rendered site by working on improving the Package bundle size by making tweaks in the code. Let’s dive deep.
1. Code Splitting and Dynamic Imports
“Bundling” of React code is the process of following through all imports and codes and combining it into a single file called a ‘Bundle’. Webpack, Browserify, etc., already do this for us.
Webpack has a feature called ‘Code Splitting’ that is responsible for splitting a single bundle into smaller chunks, deduplicating the chunks, and importing them ‘on demand’. This significantly impacts the load time of the application.
module.exports = {
// Other webpack configuration options...
optimization: {
splitChunks: {
chunks: 'all', // Options: 'initial', 'async', 'all'
minSize: 10000, // Minimum size, in bytes, for a chunk to be generated
maxSize: 0, // Maximum size, in bytes, for a chunk to be generated
minChunks: 1, // Minimum number of chunks that must share a module before splitting
maxAsyncRequests: 30, // Maximum number of parallel requests when on-demand loading
maxInitialRequests: 30, // Maximum number of parallel requests at an entry point
automaticNameDelimiter: '~', // Delimiter for generated names
cacheGroups: {
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
priority: -10,
reuseExistingChunk: true,
},
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
},
};
Lazy Loading Components with React Suspense (React 18): This combined with dynamic imports will show a visible improvement in Component Loading time.
Generally, when we import child components within a parent component, we import it statically. To prevent importing this component till we actually have to render it, we can use a combination of dynamic imports with React Suspense. React Suspense enables loading a component on demand. It shows a Fallback UI while the corresponding components are dynamically imported and then rendered.
import { lazy } from 'react';
// The lazy loaded Component has to be exported as default
const BlogSection = lazy(() => import('./BlogSection.tsx'));
export default function HomePage() {
return (
<>
<Suspense fallback={<Loading />}>
<BlogSection />
</Suspense>
</>
);
}
function Loading() {
return <h2>Component is Loading...</h2>;
}
2. Tree Shaking
This is a technique used by JavaScript bundlers to remove all unused code before creating bundles. ES6 code can be tree-shaken; however, code that is based out of CommonJS (i.e., uses ‘require’) cannot be tree-shaken.
Webpack Bundle Analyzer is a plugin that will help you visualize the size of a webpack with an interactive map.
npm install --save-dev webpack-bundle-analyzer
npm install -g source-map-explorer
Then configure your webpack to add the above as a plugin:
plugins: [
new BundleAnalyzerPlugin(),
new HtmlWebpackPlugin({
template: './public/index.html', // Path to your HTML template
filename: 'index.html', // Output HTML file name
inject: true, // Inject all assets into the body
}),
];
Make sure your script is configured to run Webpack:
"build": "webpack --config webpack.config.js --mode production"
Run yarn build
to generate a report.html
that will help you visualize your bundle size effectively.
It will look something like this:
3. Concurrent Rendering
Let’s start by understanding what Blocking Rendering is. Blocking rendering is when the main thread (UX updates) is blocked because React was doing some less important tasks in the background. This used to be the case till React 16.
React 18 has introduced concurrent features, which means it will:
- Give you more control around how background updates get scheduled and will create a smooth end-user experience by not blocking the main thread.
- Initiate automatic batching of state updates: Batching refers to grouping multiple re-renders due to multiple state updates in a way that the state updates just once.
Use the startTransition()
hook to manage React updates as non-urgent, helping React prioritize urgent updates like user-input and user-interaction with components over the prior.
import React, { useState, startTransition } from 'react';
function App() {
const [value, setValue] = useState('');
const [list, setList] = useState([]);
const handleChange = (e) => {
const newValue = e.target.value;
setValue(newValue);
startTransition(() => {
const newList = Array(20000).fill(newValue);
setList(newList);
});
};
return (
<div>
<input type="text" value={value} onChange={handleChange} />
<ul>
{list.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
);
}
export default App;
In this example, when the input value changes, the handleChange
function is called. The startTransition
function is used to mark the update to the list
state as non-urgent. This allows React to prioritize the update to the value
state, ensuring that the input remains responsive even when the list is large.
Use the useDeferredValue
hook to defer a value (usually an expensive calculation) until the UI is less busy.
import React, { useState, useDeferredValue } from 'react';
function App() {
const [value, setValue] = useState('');
const deferredValue = useDeferredValue(value);
const handleChange = (e) => {
setValue(e.target.value);
};
const list = Array(20000).fill(deferredValue);
return (
<div>
<input type="text" value={value} onChange={handleChange} />
<ul>
{list.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
</div>
);
}
export default App;
In this example, the useDeferredValue
hook is used to defer the value
state until the UI is less busy. This helps keep the input responsive by deferring the rendering of the large list until after the input update is processed.
Key Benefits of Concurrent Rendering:
- Improved Responsiveness: By allowing React to interrupt rendering work, the UI remains responsive to user interactions.
- Prioritization: React can prioritize urgent updates over non-urgent ones, ensuring a smoother user experience.
- Better Performance: Expensive updates can be deferred, reducing the impact on the main thread and improving the app’s overall performance.
4. Support Pre-loading of Resources (React 19)
If you are aware of any heavy resources that your application would be fetching during loading, then a good idea would be to preload the resource. These resources could be fonts, images, stylesheets, etc.
Scenarios where preloading would be beneficial:
- A child component would use a resource. In that case, you can preload it during the rendering stage of the parent component.
- Preload it within an event handler, which redirects to a page/component that would be using this resource. This is, in fact, a better option than preloading it during rendering.
import { preload } from 'react-dom';
function CallToAction() {
const onClickButton = () => {
preload("https://travellingprogrammer.com/css/styles.css", { as: "style" });
navigateToPage();
};
return <button onClick={onClickButton}>Redirect</button>;
}
Interesting fact: After implementing preloading, many sites, including Shopify, Financial Times, and Treebo, saw 1-second improvements in user-centric metrics such as Time to Interactive and User Perceived Latency.
Please leave a feedback
I hope you found this blog helpful! Your feedback is invaluable to me , so please leave your thoughts and suggestions in the comments below.
Feel free to connect with me on LinkedIn for more insights and updates. Let's stay connected and continue to learn and grow together!
Top comments (0)