Peter Perpich

Inside React's Runtime: Understanding the Whys of Best Practices and Pitfalls

Introduction

Hi I am Peter a senior software engineer I write in order to better learn subjects and to share what I learned. When I first started, React felt like a black box and I was just using without really understanding what was happening under the hood. I tried to follow best practices, but wasn't really sure why most of the time. My aim for the post is to help others understand the why behind the best practices and the common pitfalls that developers face. Hopefully this helps to write better code with less bugs and more efficient applications.

What is React?

React, at its core, is a library designed for building user interfaces. It does this through a system of components. These components are functions that return React elements, which are simply JavaScript objects that represent the UI. When a component’s state changes, React takes care of updating the UI in the most efficient way possible using a process called reconciliation.

Reconciliation phases:

- The Render Phase - useState, useEffect (setup phase), useContext, useReducer
React constructs a new version of the UI in memory to reflect any updates. This version is called the Virtual DOM. This is where state is accessed and updated using the useState hook, and where effects are set up using the useEffect hook.
- The Commit Phase - useEffect (execution after DOM updates)
During this phase React then updates the actual browser DOM to match the Virtual DOM. This is where useEffect is most visible, as it runs after the component output has been committed to the screen. This is also important to know, especially if you are setting state in a useEffect, since that will start the whole process over again

Understanding these two phases is crucial because it affects how you write your code. Here are some common pitfalls related to the reconciliation process and how you can avoid them:

  • Using Keys for Lists: Always use keys when rendering lists of elements. They help React identify which items have changed, added, or removed, which is important for performance and accurate UI updates.
  • Direct DOM Manipulation: Avoid manipulating the browser’s DOM directly. Doing so can lead to inconsistencies between the actual DOM and the Virtual DOM that React manages.
  • State Updates in Render: Exercise extreme caution when setting state during the render phase. It's generally best to avoid doing this, as it can cause React to enter an infinite loop. In cases where state updates during rendering are absolutely necessary, ensure they are conditional to prevent such loops. React processes state updates in batches after the render phase, and direct state modifications during rendering can yield unexpected outcomes. To manage state updates, use functional updates or the useReducer hook, especially for complex state logic. This approach helps maintain predictable behavior and application efficiency.
  • Effect Dependencies: When using useEffect, make sure all variables that the effect depends on are included in the dependency array. Failing to do so can cause your app to reference stale state or props. Also remeber that the useEffect function get’s called after the UI is rendered in the DOM.

Code Examples:

Keys for lists -

When rendering lists of elements, you should always use keys. Keys help React identify which items have changed, added, or removed, which is important for performance and accurate UI updates. In this example try changing the key type and then modify the list of components. When the key is set as an index or none, you will see that the list will not update correctly. This is because React uses the key to determine which items have changed, added, or removed. If the key is not unique, React will not be able to determine which item has changed. The rule of thumb is if the dataset is static, you can use the index as the key. If the dataset is dynamic, you should use a unique identifier as the key.

useEffect() dependencies -

In this example, we explore the nuances of the useEffect hook. The demo features two addends and their sums calculated in different ways:

  1. Bad Practice with Missing Dependency: The first equation updates the sum using useEffect with only one dependency. It demonstrates how omitting a relevant variable from the dependency array leads to incorrect behavior, as the sum doesn't update correctly when the second addend changes.
  2. Good Practice with Complete Dependency Array: The second equation correctly includes both dependencies. It shows the sum updating accurately, reflecting the importance of a complete dependency array.
  3. Effect Without Dependency Array: This example illustrates the problem of omitting the dependency array altogether, causing the effect to run after every render. It highlights the potential performance issues of overusing the useEffect hook.

Performance Optimization

Performance optimization is a crucial part of building a React application. In this section, we'll explore some common performance pitfalls and how to avoid them. I know I have built application and wondered why they are sometimes slow or why some of my components are re-renderng more than I expected. Hopefully this section will help you understand why and how to fix it.

Understanding React's Reconciliation

Before diving into specific optimization techniques, it's important to revisit the concept of reconciliation in React. This process is at the heart of React's efficiency. Reconciliation is how React updates the DOM, comparing the previous and next states of the UI and updating only what’s necessary. However, this process can become a bottleneck if not managed correctly. Efficient rendering is key; every unnecessary render cycle you prevent can significantly improve the overall performance of your application.

Rendering Optimization

React's memo and useCallback

React.memo() is a higher order component that will memoize the output of a component. This means that React will skip rendering the component if the props have not changed. This is useful for optimizing components that are expensive to render. React.memo() only checks for prop changes, so if you have a component that uses useState or useContext, you will need to use the useCallback hook to memoize the function. This is because the function will be recreated on every render, which will cause React.memo() to always re-render the component.

Example:

This example showcases how React.memo and useCallback can optimize your React components.

  • Memoized vs. Non-Memoized Components: Memoized vs. Non-Memoized Components: Observe the difference in re-render counts between components wrapped in React.memo and those that are not. React.memo helps prevent unnecessary re-renders by memorizing the component output.
  • Inline vs. Memoized Click Handlers: Compare how components behave with inline click handlers versus memoized handlers created with useCallback. Notice that memoized handlers prevent unnecessary re-renders by maintaining a stable reference across renders, unlike inline functions that change on every render.
  • Interactive Learning: Use the buttons to trigger re-renders and update the useCallback function. See how these actions impact the different components in terms of re-render counts and function stability.

State Management Optimization

Efficient State Updates

React’s setState function is asynchronous and batches updates for performance gains. However, managing complex state updates can be challenging. Using functional updates or the useReducer hook can help manage state more predictably and reduce unnecessary renders.

Functional updates ensure that state transitions are based on the previous state, avoiding issues with asynchronous updates. The useReducer hook is particularly useful for complex state logic, offering a more structured and scalable approach to state management.

In this interactive example, we explore common pitfalls and best practices of state management. The code demonstrates these scenarios:

  1. Incrementing state directly and logging it, showing how state updates are asynchronous.
  2. Attempting multiple state updates in one event handler, revealing how React batches these updates.
  3. Updating state in a loop, which illustrates potential issues with state updates relying on the current render's state value.
  4. The correct way to increment state multiple times using a function for the state setter.

useReducer() Example

From my experience, I have found useReducer to be particularly helpful when tackling complex state logic that involves multiple sub-values. One nice feature about using useReducer is its dispatch function. This function is memoized, ensuring stability and preventing extra re-renders when passed down to child components - a real advantage for avoiding prop drilling in complex component trees. This example features a dynamic CSS property editor that uses the useReducer hook to manage a potentially complex state. Since we used useReducer, adding new properties that we want to manage becomes straight-forward and intuitive.

Imutable Data Structures

Using immutable data structures is a key practice in React’s state management. React relies on immutability to determine if re-renders are necessary. When you treat state as immutable, you create a new object for every update, making it easier for React to compare previous and next states during the reconciliation process.

Conclusion

Thanks for reading and I hope you've gained valuable insights into not just the 'how', but more importantly, the 'why' behind various best practices and common pitfalls. I know it has been helpful for me to go through all this write it down. As a senior software engineer, I've always believed that understanding the underlying mechanics of the tools we use is crucial for crafting efficient, robust, and maintainable applications. I encourage you to experiment with the practices, challenge them, write your own, and see how they apply to your own projects. Happy coding!

"A great developer, you seek to become. Share knowledge and grow, you will." - Yoda