Optimizing a React SPA Part 3: Clean Out Your Functional Components

Curtis CarterCurtis Carter
4 min read

I’ve had a run through a decent number of interesting React issues since I’ve started this and the biggest performance loss I’ve seen is from doing stuff more often than necessary. While this could extend to render loops which are their own special torture for your application, I’m targeting low hanging fruit today.

Nested Component Functions

To understand why it’s bad to nest component functions inside each other you need to think about what a functional component really is. It’s the render() function that runs to generate the component code inside it. Any code defined inside it is only defined when the function runs. So if you define a function, a constant, or a component it’s discarded after the render completes. React internally tracks which components have rendered to be able to tell when it needs to render them again.

Let’s look at this minimal example based on the vite react template:

import {useState} from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'

function App() {
    const [count, setCount] = useState(0)

    function Innercomponent({setCount, count}): JSX.Element {
        return <button onClick={() => setCount((count: number) => count + 1)}>
            count is {count}
        </button>
    }

    return (
        <>
            <div>
                <a href="https://vite.dev" target="_blank">
                    <img src={viteLogo} className="logo" alt="Vite logo"/>
                </a>
                <a href="https://react.dev" target="_blank">
                    <img src={reactLogo} className="logo react" alt="React logo"/>
                </a>
            </div>
            <h1>Vite + React</h1>
            <div className="card">
                <Innercomponent count={count} setCount={setCount}/>
                <p>
                    Edit <code>src/App.tsx</code> and save to test HMR
                </p>
            </div>
            <p className="read-the-docs">
                Click on the Vite and React logos to learn more
            </p>
        </>
    )
}

export default App

As seen here, when clicking on the button, the InnerComponent is rendered as a fresh function on each render despite being effectively the same component. Now let’s move it and the counter function out of the component:

// imports...
function Innercomponent({setCount,count}): JSX.Element{
    return    <button onClick={() => setCount((count: number) => count + 1)}>
        count is {count}
    </button>
}

function App() {
  const [count, setCount] = useState(0)
  return (
    <>
      <div>
        <a href="https://vite.dev" target="_blank">
          <img src={viteLogo} className="logo" alt="Vite logo" />
        </a>
        <a href="https://react.dev" target="_blank">
          <img src={reactLogo} className="logo react" alt="React logo" />
        </a>
      </div>
      <h1>Vite + React</h1>
      <div className="card">
          <Innercomponent count={count} setCount={setCount} />
        <p>
          Edit <code>src/App.tsx</code> and save to test HMR
        </p>
      </div>
      <p className="read-the-docs">
        Click on the Vite and React logos to learn more
      </p>
    </>
  )
}
///...

Now we can see in the React Profiler that it is indeed tracking it as the same component. Don’t be mislead by the execution times though. In this case it’s a relatively simple component and function call, but more complex sets can have devestating performance implications when the inner component’s state resets.

function App() {
    function Innercomponent({onClick}): JSX.Element {
        const [count, setCount] = useState(0)

        function counter(setCount) {
            setCount((count: number) => count + 1);
            onClick((clickCount:number) => clickCount + 1);
        }

        return <button onClick={() => {
            counter(setCount);}}>
            inner component is at {count}
        </button>
    }

    const [state, setState] = useState(0)

    return (
        <>
            <div>
                <a href="https://vite.dev" target="_blank">
                    <img src={viteLogo} className="logo" alt="Vite logo"/>
                </a>
                <a href="https://react.dev" target="_blank">
                    <img src={reactLogo} className="logo react" alt="React logo"/>
                </a>
            </div>
            <h1>Vite + React</h1>
            <div className="card">
                <Innercomponent onClick={() => setState((state) => state + 1)}/>
                <p>
                    But the outer component has is at {state}
                </p>
            </div>
            <p className="read-the-docs">
                Click on the Vite and React logos to learn more
            </p>
        </>
    )
}

Notice in the code above that if we run it, the outer component will update it’s state and increase the counter, but since the inner component is freshly rendered on each render of the outer component it will never increase the count. In practice, I’ve seen deeply nested components that use redux or contexts to consistently recalculate complex state and cause render loops or delays of hundreds of milliseconds for a single render.

On the function side of things, the function counter() is treated as a new function and can cause unwanted rerenders even when no other state changes for memoized components. A simple lift and shift of these functions to somewhere outside of the component function causes React to treat them appropriately. If your function has dependencies from the inner function, they need to be passed to the component in some other fashion such as via props or contexts.

How Much Does This Help?

The answer is very dependent on your app. With a set of deeply nested components that make use of useEffect(), React.memo, useState() and useMemo() it can be very impactful preventing extra data processing and even api calls. For simpler components with only a few layers deep, it may be a millisecond or less of savings. For functions defined in another function, the improvement is even lower, but across a large SPA it becomes mildly significant.

But, it’s effectively a free performance improvement with little to no risk. Where the waters muddy is when you are using state from the parent component in the child component without passing it in as props. Ideally you keep this in mind and fix the issues as you work near them instead of overhauling an app just to make this change. Lift and Shift development should be treated as low hanging fruit, but not a cure-all.

0
Subscribe to my newsletter

Read articles from Curtis Carter directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Curtis Carter
Curtis Carter

I'm a cross-platform (Windows/Linux) dev with deep experience in C# and the .Net stack (from legacy 1.1 through core and .Net 5). I like creating developer tools and teaching others about code and architecture.