From State to Performance: Optimizing React Apps with Composition Patterns

From State to Performance: Optimizing React Apps with Composition Patterns

In my previous article, we explored the state changes that trigger downstream re-renders in our app. However, the example was relatively simple, and the state was isolated. So moving it into a component was easy, but what if that wasn't the case? What if we had a more complicated situation? Let's explore this by using another example.

const App = () => {

  return (
    <div className="scrollableDiv">

      <SlowComponent />
      <TheLegacyComponent />
      <VerySlowRenderComponent />
      <TheSlowBunch />
      <OtherComponent />

    </div>
  );
};        

We have this huge application used by a good number of customers, and you're tasked with creating a link block at the bottom that moves in the x direction and changes the link according to the scroll position of the app.

To solve this brute force, we can create a state that saves the captured and calculated value and pass it to the component that needs it.

const App = () => {
  const [pos, setPos] = useState(0);

  const handleScroll = (e) => {
    const calculated = functionToCalculatePos(e.target.scrollTop);
    setPos(calculated);
  };
  return (
    <div className="scrollableDiv" onScroll={handleScroll}>
      <SlowComponent />

      <ScrollableDiv pos={pos} />

      <TheLegacyComponent />
      <VerySlowRenderComponent />
      <TheSlowBunch />
      <OtherComponent />
    </div>
  );
};        

But this is far from optimal. If we look at it, every time a user scrolls, it will trigger a state update and a re-render of the App. As you can see, we can't extract the state into the component anymore. So how can we solve this? Memoization? Or maybe some magical refs?

Not at all. We can still find a workaround to isolate the component by passing the slow components as a single prop.

const App = () => {
  const slowComponents = () => (

    <>
      <SlowComponent />
      <TheLegacyComponent />
      <VerySlowRenderComponent />
      <TheSlowBunch />
      <OtherComponent />
    </>
  );
  return (
    <div className="scrollableDiv" onScroll={handleScroll}>

      <ScrollableDiv slowContent={slowComponents} />
    </div>
  );
};

const ScrollableDiv = ({ slowContent }) => {
  const [pos, setPos] = useState(0);

  const handleScroll = (e) => {
    const calculated = functionToCalculatePos(e.target.scrollTop);
    setPos(calculated);
  };
  return (

    <div className="scrollableDiv" onScroll={handleScroll}>
      <MovingLinks position={pos} />

      {slowContent}

    </div>
  );
};        

And now, when the state is changed and the re-rendering is triggered, the only component that re-renders itself is MovingLinks.

But how is this possible, you may ask? This is where we need to understand what a component is. So let's start with a simple one.

const Parent = () => {
  return <Child />;

};        

As you can see, it's just a function. Components are just functions, but what makes them different is that they return elements, which React then converts into DOM elements and sends to the browser. So what's an element? Every time we use those brackets on a component, we create an element. An element is simply an object that defines a component that needs to be rendered on the screen. The nice HTML-like syntax is nothing more than syntax sugar for the React.createElement function.

We can even replace that element with this:

React.createElement(Child, null, null);        

and everything will work exactly the same. The object definition for our Parent component would look something like this:

{
  type: Child,
  props: {}, // if Child had props

  ... // lots of other internal React properties

}        

This tells us that the Parent component, which returns that definition, wants us to render the Child component with no props. The return of the Child component will have its definitions, and so on, until we reach the end of that chain of components. Now that we are clear with elements and components, let's talk about re-renders. What we usually refer to as re-render is React calling those functions and executing everything that needs to be executed in the process (like hooks). From the return of those functions, React builds a tree of those objects. We know it as the Fiber Tree or, sometimes, the Virtual DOM. It's even two trees: before and after render. By comparing or "diffing" those, React will then extract the info that goes to the browser on which DOM elements need to be updated, removed, or added. This is known as the "reconciliation" algorithm. What matters for now is, if the object (element) before and after re-render is the same, then React will skip the re-render of those components. And by "the same," I mean whether Object.is(ElementBeforeRerender, ElementAfterRerender) returns true.

Now let's take a look at another small example:

const Parent = (props) => {
  const [state, setState] = useState();

  return <Child />;

};        

When the state is changed in the Parent function, it will call the Parent function and compare whatever it returns before and after state changes. And it returns an object that is defined locally to the Parent function. So on every function call, the object is again created, and the result of the Object.is on "before" and "after" <Child /> objects will be false. As a result, every time the Parent here re-renders, the <Child /> will also re-render. Which we all know, but hear me out.

Now imagine instead of rendering the Child component directly, we pass it as a prop.

const Parent = ({ child }) => {
  const [state, setState] = useState();

  return child;

};        

Somewhere, where the Parent component is rendered, the Child definition object is created and passed to it as the child prop. When the state changes, Parent is triggered. React will compare what the Parent function returns before and after, and in this case, it will be a reference to the child: an object created outside the Parent function scope and therefore doesn't change when it is called. As a result, the comparison of child before and after will return true, and React will skip the re-render.

And this is exactly what we did to our solution! While this pattern is cool and valid, there's still a small issue: it looks weird. Passing entire page content into random props just feels... wrong. So before I leave, let's make it prettier.

Let's first rename the prop to children, because for that we have the special syntax in JSX. The nice nesting composition we use all the time with HTML tags works exactly the same as if we were passing the children prop explicitly:

```javascript

const ScrollableDiv = ({ children }) => {
  const [pos, setPos] = useState(0);

  const handleScroll = (e) => {
    const calculated = functionToCalculatePos(e.target.scrollTop);

    setPos(calculated);
  };

  return (

    <div className="scrollableDiv" onScroll={handleScroll}>
      <MovingLinks position={pos} />

      {children}

    </div>
  );
};        
const App = () => {

  return (
    <ScrollableDiv>

      <SlowComponent />
      <TheLegacyComponent />
      <VerySlowRenderComponent />
      <TheSlowBunch />
      <OtherComponent />

    </ScrollableDiv>
  );
};        

And here we go: implementing a very performant block in a slow app using just a composition pattern.

Mahatva garg

PSE@Razorpay | Enhancing Code | problem solver

7 个月

Thanks for sharing

Muhammed Shahil

Youth Ambassador @ ViralFission | Cybersecurity, Event Management

7 个月

Insightful!

要查看或添加评论,请登录

Sanskar Tyagi的更多文章

社区洞察

其他会员也浏览了