DEV Community

loading...

avoid the "death by a 1000 cuts" performance problem with jotai

nibtime profile image nibtime Updated on ・3 min read

Problem

I first got aware of a really hard to grasp performance problem of React by this blog post from Rich Harris: "death by a 1000 cuts".

The danger of defaulting to doing unnecessary work, even if that work is trivial, is that your app will eventually succumb to 'death by a thousand cuts' with no clear bottleneck to aim at once it's time to optimize.

Let's explore what that means. Let's look at a most simple pattern involving derived React state: Adding two numbers.

const Component = (props) => {
  const [n, setN] = useState(1)
  const [m, setM] = useState(1)
  const sum = n + m
  ...
}
Enter fullscreen mode Exit fullscreen mode

When we just look at this piece of code, there seems to be no problem at all. This component will work smoothly and performance is absolutely no issue.

Let's observe what happens during React rendering and how this contributes to the "death by a 1000 cuts".

React components rerender on state or props changes. On every rerender, the function body of the React component will be executed. So on every rerender, the addition (n + m) gets executed, also when state or props changes happen that do not influence the result of the addition.

At first sight, this doesn't matter at all. Re-evaluating the addition every time, even if unnecessary, doesn't make any problem. To prevent unnecessary re-evaluation we can do the following:

const sum = useMemo(() => n + m, [n, m])
Enter fullscreen mode Exit fullscreen mode

But wait, we are only supposed to do that with expensive computations, right? And simple addition is pretty much the cheapest thing there is.

So we do not memoize such statements and accept a little unnecessary extra work. We accept a "tiny little cut". One or a few of them don't do much harm.

But as your codebase grows and such "cuts" keep adding up to 1000, at some point the UI could get sluggish and slow somehow and you might have absolutely no idea why that is and what you did wrong (because you actually did nothing wrong).

Then you are experiencing the "death by a 1000 cuts" performance problem.

Cure

Start memoizing derived state all over your codebase with useMemo. There is no clear indication about where to start and when it is enough. At some point after doing this, the performance will be OK again. After your application grows further, it might pop up again and you have to repeat the process.

Redemption

Design your state patterns bottom-up with jotai atoms. Then this problem has no opportunity to manifest itself by design!

Let's take a closer look at the core abstraction of jotai, the atom, and how we build patterns of state with it. Let's look how we would model the above addition with jotai:

const nAtom = atom(1)
const mAtom = atom(1)
const sumAtom = atom((get) => get(nAtom) + get(mAtom))

const Component = (props) => {
  const [n, setN] = useAtom(nAtom)
  const [m, setM] = useAtom(mAtom)
  const sum = useAtom(sumAtom)
  ...
}
Enter fullscreen mode Exit fullscreen mode

This Component behaves the same as the snippet with useState from above. With one difference: sum will only be re-evaluated when either n or m changes. So useMemo is sort of "built-in".

Let's explore those atom declarations and their meaning. nAtom and mAtom are so-called "primitive atoms". They are a readable and writable container for a single number. With useAtom we can interact with this container inside React components with the same interface that useState gives us.

sumAtom is a so-called "derived atom". It is a readable-only container that holds the sum of the current values of nAtom and mAtom. This container "knows", that it only needs to re-evaluate its value when one of its dependencies (nAtom, mAtom) change. Those dependencies are tracked with the get function. With useAtom we can interact with this container inside React components and get the derived value (the sum) directly.

By designing state with atoms in this bottom-up fashion, we always end up with a minimal "dependency/data flow graph" for our state, where the derived state gets only re-evaluated if one of its (transitive) dependencies changes.

If that sounds too fancy: it's basically the same thing that spreadsheets do, just replace "atom" with "cell" 😉

We always just do the minimum necessary work. No "cuts" are happening.

Discussion (0)

Forem Open with the Forem app