⚙️React & Javascript Optimization Techniques
date
Jan 24, 2024
slug
javascript-optimization-techniques
status
Published
tags
Chia sẻ
Sưu tầm
Study
summary
When we begin a project, we tend to focus on things like scalability, usability, availability, security, and others. But, as the application grows, we may observe a decline in its speed and performance. It is often only at this point that we recognize the need for optimization.
type
Post
When we begin a project, we tend to focus on things like scalability, usability, availability, security, and others. But, as the application grows, we may observe a decline in its speed and performance. It is often only at this point that we recognize the need for optimization.
In this article, we will present some of the most common techniques for optimizing code, which can be implemented in any application; we will also show optimization techniques using sample code written in JavaScript and React. The following techniques are gonna be covered:
- Debouncing
- Throttling
- Memoization
- Pure Components
- Lazy Loading
- Virtualization (or Windowing)
- Error Boundaries
- Inline Functions
There are many more techniques available, but in this article, we will focus on the ones already mentioned.
Debouncing
Debouncing is a programming technique used to optimize the processing of functions that consume a lot of execution time. This technique involves preventing those functions from executing repeatedly without control, which helps improve the performance of our applications.
In the case of applications that must respond to certain user actions, we often cannot avoid certain functions from being executed repeatedly. For example, events such as
mousemove
or window.resize
can trigger hundreds of calls to these functions with a simple mouse movement or browser window resizing. It is in these cases that we resort to techniques like Debouncing to limit these calls and solve performance issues that may be caused by such events or functions.The operation of Debouncing is quite simple. It involves creating a function that acts as an interceptor to limit the call to the callback function we want to optimize. This function will have at least two parameters:
time
and callback
. The time
parameter is used to indicate to the Debounce how long the function should wait before being called, and the callback
parameter is the function that will be conditioned to this time limit. Once the control mechanism is created, the debounce
function returns a new optimized function that will serve in place of the original function.It is worth noting that in Debouncing, if multiple calls to the callback occur within the defined time window, only the last call will be considered for execution and the previous ones will be discarded. Additionally, while this is happening, the time window will also be renewed each time a call occurs. For example, if we define the time as 2 seconds, the callback defined in the
debounce
function will only be executed after 2 seconds. If multiple calls occur within the time window, the time will be renewed for the same period, and only the last function that entered the debounce
function will be executed once the defined time is met.Here is a simple example of how to implement Debouncing in code using JavaScript:
// Example 1 const debounce = (callback, time = 300) => { let timer; return () => { clearTimeout(timer); timer = setTimeout(callback, time); }; };
In this simplified version, the
debounce
function returns another function that will handle the debounce. The returned function clears the timer variable of any previously created timeout and sets a new timeout with the callback
it received as a parameter. Each time the new debounced function is executed, it will access the same timer variable, clear it, and replace the timeout each time.Thus, we have created our debounce, and the way to use it would be as follows:
// Example 2 // this is the function we want to debounce const showMessage = () => console.log("Hello Message"); // this is the debounced function with 1 second of delay const debouncedMessage = debounce(showMessage, 1000); // we are calling it 10000 times in this loop for (let i = 0; i < 10000; i++) { setTimeout(debouncedMessage, i); }
In this example, the
debouncedMessage
function will be called 10,000 times in a for
loop. However, due to the debounce, the message will only be displayed once instead of 10,000 times.Throttling
Throttling is a technique similar to debouncing, as both are used to limit the frequency of function calls. The difference is that throttling does not clear the timer every time the function is called, but instead uses a pause condition to avoid creating new timers. In other words, while the function is being called, it will not wait until the last call to execute, but will only call the function if it enters the time interval where the pause is disabled.
Let’s see an example of this to better understand it:
// Example 3 const throttle = (callback, time = 300) => { let pause = false; return () => { if (pause) return; pause = true; callback(); setTimeout(() => { pause = false; }, time); }; };
In the above example, we can see that unlike debounce, we now have a
pause
variable instead of a timer, and the returned function now has different behavior. We can see that it now checks if the pause
is true
to return, which is basically to prevent the rest of the code from executing, it is an escape clause. In case the execution is not interrupted in that condition, what we will do is activate the pause, so that subsequent calls to this function will not be executed because the pause will be activated. Then, we have the call to the callback
, and finally, we close the throttling process leaving a setTimeout
to disable the pause when the time we have defined is fulfilled.What will happen with throttling, as we explained earlier, is that we will be limiting the calling of our functions, but as long as they continue to be called, they will be executed every certain amount of time. On the other hand, with «debouncing», what happens is that it will be waiting for the last call to be able to execute the function only once.
For a better understanding, let’s see the following example:
// Example 4 // this is the function we want to throttle const showMessage = () => console.log("Hello Message"); // this is the throttled function with 1 second of delay const throttledMessage = throttle(showMessage, 1000); // we are calling it 10000 times in this loop for (let i = 0; i < 10000; i++) { setTimeout(throttledMessage, i); }
In the case of debouncing, the message was only displayed once because, in each execution, the timeout was renewed until the last call lasted 10 seconds (10,000 milliseconds). In contrast, for throttling, the message was displayed 10 times, once every second (1000 milliseconds), due to the pause that conditions its execution. As you can see, both techniques have the same goal but are slightly different in code and behavior. This slight difference allows us to choose one mechanism or another depending on the circumstances. That is why it is important to know these two techniques to know when to implement one or the other and thus correctly optimize our code.
Memoization
Memoization is a technique that involves storing the result of a function in a memory space that allows for later retrieval. This is done to avoid having to recalculate the result every time the function is called with the same parameters. This technique is used for functions that consume a lot of resources. In such cases, memoization can improve performance and speed in obtaining results.
This technique can be used in React as well, we can make use of the following memoization features in React:
React.memo
useMemo
useCallback
However, before explaining how these features are used in React, let’s first discuss memoization in JavaScript using the algorithm for calculating the factorial of a number as an example. The implementation would look something like this:
// Example 5 const getFactorial = (n) => { if (n === 1 || n === 0) return 1; return n * getFactorial(n - 1); };
At first glance, this algorithm looks simple. However, being a recursive function, we must be careful when passing it very large numbers, as depending on the value we provide, this function can be very expensive in terms of processing. As you may know, the factorial of a number is simply the recursive multiplication by its predecessors until it reaches 1, whose formula would be as follows: n! = n · (n -1) · (n — 2) · … · 1.
For example, if we want to calculate the factorial of 6, it can be calculated like this: 6! = 6 · 5 · 4 · 3 · 2 · 1. The thing with this algorithm is that many of those multiplications could be done only once and then save their results in a lookup table or an object. This way, when we need to obtain those values again, we will no longer have to recalculate them. One way to memoize this function would be like this:
// Example 6 const memoize = fn => { const cache = new Map(); return (...args) => { const key = args.join("-"); if (cache.has(key)) return cache.get(key); const result = fn(...args); cache.set(key, result); // you can add a console.log(cache) here to see how cache is being filled. return result; }; }; const getFactorial = memoize(n => { if (n === 1 || n === 0) return 1; return n * getFactorial(n - 1); }); // it runs 100 times getFactorial(100); // all values below 100 were memoized previously, so it runs just once getFactorial(99);
In this example, a
memoize
function is defined first, which takes a function as an argument and returns a new function that memoizes the results of the original function. This new function uses a Map
object to cache previously calculated results. When the memoized function is called with certain arguments, it first checks if there is already a result stored for those arguments in the cache. If so, it returns the stored result. If not, it calculates the result by calling the original function with those arguments, stores the result in the cache, and returns it.Then, the
getFactorial
function is passed to the memoize
function and the result is assigned to a new variable. The memoized version of getFactorial
can now be called like any other function but stores its previous results in the cache, making it more efficient for repeated calculations.In other words, when we calculate the factorial of 100, the function will run 100 times. However, by that time, we will have already stored all the calculations in the
cache
object. So when we run the function again with the same value or a value below 100, it will no longer be necessary to recalculate everything again, but we will obtain its value from the cache
object that we have created; consequently, the function will only be executed once.In this way, we optimized this function to avoid re-calculations and improve the response speed. In summary, memoization is a good technique for optimizing components. This is one of the biggest advantages of using optimization techniques.
React.memo
This is a useful tool for avoiding unnecessary renders of components when the props they receive do not change. The purpose of
React.memo
is to improve application performance. However, it is important to note that React.memo will not prevent component renders caused by state or context (React.Context
) updates, as it only considers props to avoid re-renders.Using
React.memo
is pretty straightforward to implement. Below is an example of how to use it:// Example 7 import { memo } from "react"; import { MyComponent } from "./MyComponent"; export const App = () => { // using a component const MemoizedComponent = memo(MyComponent); // using a function expression const MemoizedComponent2 = memo(function ({ data }) { return <div>Some interesting {data}</div>; }); // using an arrow function const MemoizedComponent3 = memo(({ data }) => { return <div>Some interesting {data}</div>; }); const someDataPassedAsProp = {}; return <MemoizedComponent data={someDataPassedAsProp} />; };
This way, as long as the props maintain the same value over time, the component will not be re-rendered. This is especially useful in applications with components that have a large number of children or in situations where props do not change frequently. By using, unnecessary updates can be avoided and application performance can be improved.
React.useMemo
This hook, allows us to cache the results of a costly function between component renders, and only re-execute the function if its dependencies change. But be careful, this hook should only be used within components or other hooks, not within loops or conditionals.
To understand how it works, let’s take the
expensiveCalculationFn
function as an example, which theoretically requires performing a series of complex and costly calculations. In our component, we can use useMemo
to prevent it from being executed on every component render, which can slow down the application. In this way, the hook will return the last cached value and only update that value if the dependencies change.Here’s an example of how to use
useMemo
in a component:// Example 8 import { useMemo, useState } from "react"; const expensiveCalculationFn = (a, b, c) => { // Let's say this function does an expensive calculation return a*b*c; }; export const MyComponent = (props) => { const { a, b, c } = props; // useMemo will run expensiveCalculationFn // only if its dependencies have changed. const result = useMemo(() => expensiveCalculationFn(a,b,c), [ props ]); return <h1>{result}</h1>; };
In this example, we use useMemo to avoid the costly execution of
expensiveCalculationFn
on every component render. If the dependencies, which in this case are props
, do not change, the hook will return the last cached value. Additionally, it is recommended that the function used with useMemo
be a pure function.If you want to learn more about this hook, we recommend reviewing the official documentation, which contains several examples of use cases to better understand its functionality and applicability in different situations.
Please take in mind that useMemo is not a general javascript function for memoization, it’s only a built-in hook in React that is used for memoization, also its use it’s recommended in only few cases, here you can read more about it.
React.useCallback
The
useCallback
hook is very similar to the previous one, but it differs in that this hook caches the function definition and not the resulting value of its execution. Its update will occur only if the defined dependencies change in value. It is important to note that useCallback
does not execute functions, it only saves and updates their definition to be executed later by us. In contrast, React.useMemo
executes functions, saving and updating in cache only their resulting value.This hook can be useful, for example, to cache callbacks that are passed to child components as props, of which we want to avoid re-rendering due to a render of the parent component. By caching the function references, and leaving a callback cached, it will remain the same in case it is not updated, and therefore, in case our child component is encapsulated with
React.memo
, it will not trigger a render in the component where it was passed as a prop since the function did not change, maintaining its previous reference. To see how useCallback
can be used, we will optimize the following component:// Example 9 import { useEffect, useState} from "react"; // this is our child component we want to optimize const ChildComponent = ({ callback }) => { console.log("Child was rendered..."); return <button onClick={callback}>Click me!</button>; }; export const MyComponent = (props) => { // here we have some states and variables. const [color, setColor] = useState("#ff22ff"); const [otherState, setOtherState] = useState(false); const otherVariables = "other variables..."; // this is our callback we want to cache. const callbackFn = () => { console.log("Hello, you clicked!"); }; // this is for trigger a re-render by updating state. useEffect(() => { setTimeout(() => { setColor("#00ddd2"); }, 3000); }, []); // this is our child component, with the callback passed as prop. return <ChildComponent callback={callbackFn} />; };
Here we have a normal component, without optimization, which has a few states, and variables, and also renders a child component that receives the function
callbackFn
as a prop. In this case, every time we update the state of our component, by default, our child component will re-render. If we want to avoid our child component from re-rendering, we need to encapsulate it within React.memo
, as you may remember, React.memo
avoids the re-render of a component if its props do not change. So let’s do that:// Example 10 import { useEffect, useState, memo } from "react"; // this is our child component we want to optimize const ChildComponent = memo(({ callback }) => { console.log("Child was rendered..."); return <button onClick={callback}>Click me!</button>; }); export const MyComponent = (props) => { // here we have some states and variables. const [color, setColor] = useState("#ff22ff"); const [otherState, setOtherState] = useState(false); const otherVariables = "other variables..."; // this is our callback we want to cache. const callbackFn = () => { console.log("Hello, you clicked!"); }; // this is for trigger a re-render by updating state. useEffect(() => { setTimeout(() => { setColor("#00ddd2"); }, 3000); }, []); // this is our child component, with the callback passed as prop. return <ChildComponent callback={callbackFn} />; };
As you can see, we have now cached our component with
React.memo
. However, we haven’t fully solved the problem yet. The function inside our MyComponent
that we are passing to our ChildComponent
is actually a reference, so every time MyComponent
updates the reference to that function will also update. To prevent this from causing unnecessary re-renders in ChildComponent
, we can use useCallback
. By using useCallback
, the function will retain its definition and reference as long as its dependencies remain unchanged. This will prevent the props of ChildComponent
from being updated and therefore avoid unnecessary re-renders. Here’s the final version of the component:// Example 11 import { useEffect, useState, memo, useCallback } from "react"; // this is our child component we want to optimize const ChildComponent = memo(({ callback }) => { console.log("Child was rendered..."); return <button onClick={callback}>Click me!</button>; }); export const MyComponent = (props) => { // here we have some states and variables. const [color, setColor] = useState("#ff22ff"); const [otherState, setOtherState] = useState(false); const otherVariables = "other variables..."; // this is our callback we want to cache. const callbackFn = useCallback(() => { console.log("Hello, you clicked!"); }, [props, otherState, otherVariables]); // these could be the dependencies // this is for trigger a re-render by updating state. useEffect(() => { setTimeout(() => { setColor("#00ddd2"); }, 3000); }, []); // this is our child component, with the callback passed as prop. return <ChildComponent callback={callbackFn} />; };
If you want to learn more about the usage of
useCallback
, you can continue reading its official documentation, where more use cases are explained.Pure Components
A React component is considered pure if it renders the same output for the same state and props. For this type of component, React provides the
PureComponent
class. Class components that extend the PureComponent class are treated as pure components. Pure components have some performance improvements and render optimizations since React implements the shouldComponentUpdate()
method for them with a shallow comparison for props and state.However, nowadays, functional components are more commonly used. If you are already using class components and even class pure components, you might want to read about how to migrate from pure class components to functional components. But apart from that, it is always good to know how pure components work and how we can use them as classes. Let’s see some examples of pure components.
// Example 12 import React, { Component, PureComponent, memo } from "react"; // class normal component - not optimized class MyComponent extends Component { render(){ return ( <div>My Component</div> ); } } // class pure component - optimized - validates props and state equality class MyComponent extends PureComponent { render(){ return ( <div>My Component</div> ); } } // functional component - not optimized const MyComponent = (props) => <div>My Component</div>; // functional component optimized with memo const MyComponent = memo((props) => <div>My Component</div>);
By the way, we can use the
shouldComponentUpdate
()
method which is a lifecycle method that is called by React to determine if a component should re-render. By default, it always returns true, meaning that the component will always re-render when its state or props change.This means we can define the
shouldComponentUpdate()
method in a class Component (not PureComponent) to optimize performance. This method receives two parameters: the nextProps
and the nextState
. You can compare these values with the current props
and state
and return false if you determine that the component does not need to re-render.In a PureComponent, React automatically implements
shouldComponentUpdate()
for you with a shallow comparison of props
and state
. This means that if props and state are the same as in the previous render, the component won’t re-render.In functional components, the equivalent of
shouldComponentUpdate()
is using the React.memo
or the useMemo
hook, which can help prevent unnecessary re-renders by performing a shallow comparison of props, in combination with useState
, to handle the state of those components.Lazy Loading
React.lazy
is a React technique that allows us to lazily import our components until the moment they are first rendered. This can be quite useful when we don’t want to import the entire bundle of components at once, as it can slow down the application’s load time. The idea behind React.lazy
is to speed up the loading of a specific page by importing only what the page needs at that moment. This can be especially helpful in large or complex applications that contain many components.Here’s how we can use lazy loading:
// Example 13 import { lazy } from 'react'; // normal import import MyNormalComponent from './MyNormalComponent'; // lazy import const MyLazyComponent = lazy(() => import('./MyLazyComponent'));
It is recommended that components imported using
React.lazy
be encapsulated within another React component called Suspense. This component allows us to display a fallback while we wait for the lazy component to load. In this fallback, we can display a message or loading animation to let the user know that something is being loaded. Here’s an example of how to use a lazy component with React.Suspense
:// Example 14 import { lazy, Suspense } from 'react'; import LoadingAnimation from './LoadingAnimation'; const MyLazyComponent = lazy(() => import('./MyLazyComponent')); const MyApp = () => { return ( <Suspense fallback={<LoadingAnimation />}> <MyLazyComponent /> </Suspense> ); };
It’s important to note that
React.lazy
can only be used with default exports. Therefore, if your component was not exported in this way, you will have to modify the export of your component so that React.lazy
can take it without any problems. You can read more about this here.Another important point to highlight is that if you have your application built with create-react-app you can use
React.lazy
in conjunction with React Router, which optimizes the navigation of your application by generating a code splitting by routes automatically. Instead of having to load the entire bundle, the bundle is loaded as you navigate through the application.This is because
create-react-app
has webpack configured out of the box already, otherwise, you will have to configure it manually in order to enable the code-splitting feature. Here’s an example of how to use lazy loading with React Router:// Example 15 import { lazy, Suspense } from 'react'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import LoadingAnimation from './LoadingAnimation'; // here we are importing our lazy components const Home = lazy(() => import('./Home')); const Login = lazy(() => import('./Login')); const Register = lazy(() => import('./Register')); const About = lazy(() => import('./About')); const MyApp = () => { return ( <Router> <Suspense fallback={<LoadingAnimation />}> <Routes> <Route path="/" element={<Home />} /> <Route path="/login" element={<Login />} /> <Route path="/register" element={<Register />} /> <Route path="/about" element={<About />} /> </Routes> </Suspense> </Router> ); };
By using this technique with routes, we separate our bundle and generate smaller bundles called «chunks» in which each route is contained in each one. Therefore, when it’s time to load a route, instead of downloading the entire bundle at once, the bundle is loaded as it’s needed. As mentioned before, this technique is known as code splitting, and you can learn more about it here. Ultimately, this optimizes the loading speed of our applications, making them smoother and providing a better user experience.
Virtualization (Windowing)
React virtualization is a powerful technique for creating high-performing user interfaces that only display the elements currently in use. Whether you use a React virtualized library or build your own algorithm, the technique is specifically designed to handle large datasets and improve UI performance. By selectively rendering only the elements needed at any given time, virtualization in React leads to faster load times and a smoother user experience overall.
To better understand this technique, consider the following example:
Figure 1: List virtualization compared to the regular list.
Virtualization can also be applied to tables, where both rows and columns can be virtualized to achieve significant performance improvements. This technique is especially useful for components that display large amounts of data, such as tables. By only rendering the rows and columns that are currently visible, virtualization in React can greatly improve the performance of these components. To illustrate this further, let’s take a look at the following example:
Figure 2: Table virtualization.
If you would like to implement your own virtualized tables or lists, I recommend this react-window package, which provides all the necessary tools to virtualize your components.
Error Boundaries
Error boundaries are React components that catch JavaScript errors anywhere in their child component tree, log those errors, and display a fallback UI instead of the component tree that crashed. Error boundaries catch errors during rendering, lifecycle methods, and constructors of the whole tree below them. It’s important to note that error boundaries do not catch errors for event handlers, asynchronous code, or errors that occur in the error boundary itself. You can find more details about error boundaries here.
Here’s an example of how you can implement error boundaries in your components using React:
// Example 16 import { PureComponent } from 'react'; export class ErrorBoundaries extends PureComponent { constructor(props) { super(props); this.state = { hasError: false, errorInfo: null }; } static getDerivedStateFromError(error) { // Update state so the next render will show the fallback UI. // this is similar to => this.setState({ hasError: true }) return { hasError: true, errorInfo: error }; } componentDidCatch(error, errorInfo) { // You can also log the error to an error reporting service console.log(error, errorInfo, this.state.errorInfo); } render() { if (this.state.hasError) { // You can render any custom fallback UI return <h1>Something went wrong.</h1>; } // this could return the children or anything else: this.props.children; return ( <div> This is the main returned component with no error yet. </div> ) } }
With this implementation, you can wrap any component that might throw an error with the ErrorBoundary component, and it will catch and handle any errors that occur within it or its child components. The getDerivedStateFromError method updates the state to show a fallback UI when an error occurs, and the componentDidCatch method logs the error to the console or an error reporting service.
Inline Functions
There are several reasons why it is recommended to avoid using inline functions in React. Here are some of them:
- Performance: Every time a component is rendered in React, all inline functions are recreated. This can lead to slower performance in your application, especially if you have many components with inline functions.
- Maintenance: Inline functions are defined within the component and cannot be reused in other parts of the application. This can make the code more difficult to maintain as the application grows.
- Readability: Inline functions can make code more difficult to read and understand, especially if they are long or have many arguments.
In the example below, we will see inline functions for each button. However, as previously mentioned, this can lead to performance issues, which may not be noticeable in this example, but can have a significant impact on larger applications. Therefore, it is recommended to optimize these types of things at a larger scale.
// Example 17 import { useState } from "react"; export const InlineFunctions = () => { const [counter, setCounter] = useState(0); return ( <div> <h1>{counter}</h1> <button onClick={() => { setCounter((prevCount) => prevCount + 1); }} > Increase Counter </button> <button onClick={() => { setCounter((prevCount) => prevCount - 1); }} > Decrease Counter </button> </div> ); };
A better approach to handle click events, in this case, would be to define a separate function for handling the click event and then use that function as a callback for the
onClick
event of each button. This way, the function is not recreated every time the component is rendered, improving performance. Here’s an updated example using a separate function:// Example 18 import { useState } from "react"; export const InlineFunctions = () => { const [counter, setCounter] = useState(0); const handleClick = (value) => () => { setCounter((prevCount) => prevCount + value); }; return ( <div> <h1>{counter}</h1> <button onClick={handleClick(1)}>Increase Counter</button> <button onClick={handleClick(-1)}>Decrease Counter</button> </div> ); };
In conclusion, while inline functions can be useful in small components or for quick prototyping, it is recommended to avoid using them in larger applications or components that are rendered frequently. By extracting inline functions into separate functions that can be reused and optimized, you can improve the performance, maintenance, and readability of your code. In the example provided, we were able to optimize the code by creating a separate function to handle the button click events, which reduces the number of inline functions and improves the overall performance of the component.
Summary
In conclusion, we have covered a range of topics related to optimizing React applications. Debouncing, throttling, and memoization are all techniques that can help improve performance by reducing unnecessary rendering and processing. Pure components are another important optimization, ensuring that components only re-render when necessary.
Lazy loading and virtualization are techniques that can help improve the initial load time of an application, as well as its overall performance. Lazy loading allows us to load only the necessary components or resources when they are actually needed, while virtualization (or windowing) allows us to render only the visible portion of a large dataset, avoiding unnecessary rendering and processing of off-screen elements.
Error boundaries provide a way to handle errors that might otherwise crash our application. By using error boundaries, we can log and handle errors gracefully, providing a fallback UI instead of a complete application crash.
Finally, we discussed the importance of avoiding inline functions whenever possible due to their impact on performance, maintenance, and readability.
Overall, by implementing these techniques in our Javascript or React applications, we can create more efficient, responsive, and scalable applications. If you want to learn more about any of these topics, I encourage you to explore the React documentation, and we recommend having a look at the other articles that Globant has on Medium.