With the growth of web applications, a key challenge seems to be maintaining optimal performance. One of the many problems that occur for the user can be slow-loading web applications. They contribute to a degraded user experience, but also affect a site's PageSpeed parameters, which translates into search engine rankings and a diminished user base. The solution to these problems can be dedicated strategies that affect application performance. In the following article, I will discuss specific techniques that minimize the occurrence of unnecessary re-rendering, code optimization through the use of lazy loading components and more.
Table of Contents:
- Reducing re-renders with memoization
- List virtualization (windowing)
- Lazy loading
- Usage on the example of graphics
- Using components as an example
- Bundle optimization
- Selection of libraries
- Optimization from the code level
- Optimization from the config level
Reducing re-renders with memoization
Reducing the number of re-render occurrences can be the first step to improve application performance. Many times, components undergo redundant re-renders due to alterations in the state, properties, or context within their parent components. When a component consistently conveys the same values to its child components, one effective approach to mitigate this issue is through memoization. This term describes remembering the previous value and then checking it to see if it has experienced any changes using a dependency table. If nothing has changed then the old value is used. The principles involved are twinned with caching.
Hook is used to cache the previous value of a function in order to reuse its definition between successive re-renderings of the application. With this technique, you can avoid unnecessary rerender in the component where the function occurs.
The above code snippet demonstrates the use of useCallback on an example of a simple function. In this case, the function is used to display the second power of the number value. The hook has a specific property in its dependency table, so if a rerender occurs in the application and its value does not change, the hook will reuse the same function definition. Thanks to these actions, the component will not be rerendered.
The operation of this hook is very similar to the previous example, however, in this case the value that undergoes memoization is a value that is not a function, it can be a variable of simple (string, boolean, number) or complex (array, object) type. It is good practice to make sure that the values cached with useMemo are ones that are difficult to recalculate between re-renderings and require a fair amount of CPU resources. This is worth paying attention to, as overuse of this type of practice can be counterproductive and worsen memory usage in the application.
In this example, the component takes two properties name and number, a change of which will trigger a rerender of the entire component. Using useMemo on an example of an expensive calculation such as a recursion in a Fibonacci string will save its result between re-renderings. This way, changing the value of name will not cause the string expression to be recalculated, this will only happen when the value of number changes.
This method causes the component on which it is used to not rerender until one or more properties have changed, even though the parent has been updated. You could say that its operation is twinned to the use of the aforementioned hooks on each of the component's properties. In this case, the example needs to be a bit more complex to fully demonstrate the memo's capabilities.
The code snippet above is a simple application to add new users to a list and then display them on the screen. Each time a user is added, the list items would be rerendered with the same data. Fortunately, thanks to the use of memos in the User component, the rerender won't be executed until the data changes. The User component displays the first name, and calculates the expression of the Fibonacci string based on the number specified when creating the user. Calculating it is an expensive calculation, as in the example with useMemo. The value of the name property cannot be changed, so it would be good practice to use memoization in this case to avoid rerendering the entire component.
As long as the values passed to the User component do not change, the component will not be redrawn. It is also available to create your own function comparing the properties of the component, just pass it as the second argument when calling the memo method.
Above is an example of using a manual comparison of the properties taken by a component. In the case of complex functions, it is worth making sure that it will not be more costly to perform it than to re-render the component. As a formality, it remains to present the component responsible for the form for adding a new user.
In conclusion, memoization should be used with caution. Implementing it too often or simply not correctly can have the opposite effect and negatively affect application performance. List virtualization (windowing) That pattern is awesome in rendering really long lists like 1k items and more. With the use of windowing, the number of DOM nodes is significantly reduced, so application performance will increase. The disadvantage is the need to install a new bundle, making the final bundle larger. The key is rendering items which are currently visible for the user instead of all. It can be easily done with a light-weight library called react-window.
List virtualization (windowing)
That pattern is great in rendering really long lists like 1k items and more. With the use of windowing, the number of DOM nodes is significantly reduced, so the performance of the application will increase. The disadvantage is the need to install a new bundle, making the final bundle larger. The key is rendering items which are currently visible for the user instead of all. It can be easily done with a light-weight library called react-window.
The above component shows an implementation of a list using react-window. A list containing only a small portion of the items specified under the itemSize property will be rendered to the screen instead of the entire list, which has 1000 of them. This will undoubtedly have a positive effect on rendering components that have such a large number of items.
The main idea of this performance optimization method is to load an item not from the beginning of the application loading as it happens by default, but only when it is needed for display. Lazy loading can be used when loading graphics or other often quite heavy resources, as well as React components.
Lazy loading of components ensures a smaller size of the main bundle, which is generated by module bundlers such as Webpack, by breaking it into several smaller parts. In addition, as of React 18, there is a built-in tool for displaying an additional element such as a loading spinner before the component is loaded.
Usage on the example of graphics
HTML tags such as <img /> and <iframe /> provide built-in, basic lazy loading support. Unfortunately, this is limited and the effect is often not as satisfactory as when using dedicated libraries to achieve this type of loading. Unfortunately, not all browsers support the <iframe /> tag attributes required for this optimization method.
Libraries can help in the case of getting a better effect and more control over the loading behavior of the image. An example of a package to support this method can be react-lazy-load-image-component.
Undoubtedly, the advantage of this approach is the additional capabilities provided by the library's built-in methods. In addition, the photo can have a placeholder set that displays before the graphic is loaded, an effect that appears during loading, as well as functions that call themselves at different stages of loading the photo. As you can see, the control over the behavior of the element here is much greater than with the native approach. Unfortunately, this results in increasing the main bundle by the size of the new library.
Full list of methods available in the library can be found at the following link: https://www.npmjs.com/package/react-lazy-load-image-component
Using components as an example
React also allows you to dynamically import components using the lazy method. This approach allows you to load the files needed to render a component only when the application requires it, for example, after navigating to a particular subpage. This way of optimization includes the possibility of using boundary Suspense, which can display a specific element before the component is loaded.
An example of dynamic component importing will be demonstrated using a basic view consisting of navigation and page content:
The component above is a simple example of an element used in the application as a navigation through successive subpages.
The above component is responsible for displaying the sample content on the page, in this case it is an article containing a title, graphics and text. Assuming that the following code snippet is an About subpage, in this case the lazy loading of the content component will be the right choice, because there is no need to load the next subpage before the user visits the next tabs.
Selection of libraries
Optimization from the code level
One method of optimizing the size of an application from the code level can be lazy loading of components in React presented in the subsections above. It affects the final size of the main bundle by splitting it into several smaller parts. Another method can be an issue called tree shaking, which involves removing unused code used to import or export modules in an application. Module bundlers have the ability to remove these pieces of code automatically.
Optimization from the config level
A proper module bundler config can also prove helpful when optimizing the final bundle of an application. Processes worth highlighting are minification and code compression. The former simply removes unnecessary characters such as spaces, comments or simply long variable or function names in order to maximally reduce the number of characters and thus the size of the code needed to run the application. The Terser plugin can be used to achieve this effect. The second process is simply compressing the bundle to reduce its size, the most popular method being the Gzip algorithm, supported by all browsers. Compression can be also handled on server side (nginx) but in some cases better results can be achieved by tuning compression algorithms in bundler.
Optimizing web application performance is a never-ending journey, especially in today's fast-paced digital landscape. Implementing the right techniques early in your development process is crucial to ensure a smooth and responsive user experience. By reducing re-renders with memoization, adopting list virtualization, and utilizing lazy loading for components, you can make significant strides in your web application's performance. Additionally, smart bundle optimization techniques can help you deliver a streamlined, faster-loading application to your users.
Remember that maintaining a performant web application is an ongoing process. As new features are added and your user base grows, it's essential to monitor and fine-tune your application regularly. By implementing the strategies outlined in this article and staying proactive about performance optimization, you can ensure your web application provides a smooth, efficient, and engaging experience for your users.