בס״ד

React is a Fractal of Caching with Metastatic Mutability
2024-10-29

React, the popular framework for generating web UIs, has two serious failings that make it challenging to use well, even at modest scale.

The first is excessive caching. The second is excessive mutability. These are, of course, intimately related.

Who am I to level such criticisms? Nobody famous, but I have worked with React building large, complex, commercially successful web applications for almost eight years. I do not make these claims flippantly.

Why am I publicizing such criticisms? To answer the questions many developers have about why React feels so complicated and prone to subtle bugs. And because I felt like the recent article from mbrizic, React Still Feels Insane And No One Is Talking About It, missed the mark.

Note, some criticism here is directed at JavaScript itself, which a good framework would aim to compensate for.

React is a Fractal of Caching

In any software system it is wise to avoid caching as much as possible. As we know, cache invalidation is the first of the often quoted two hard programming things.

There are only two hard things in Computer Science: cache invalidation and naming things.

If caching is necessary, it should be hidden from the user, meaning the programmer, as much as possible. React does not do this; rather it makes cache handling a core and vital activity developers must engage with constantly.

Dependency Arrays

useCallback is caching. useMemo is caching. useEffect is caching. Generally, a dependency array is indicative of caching.

Why so? The function inside is cached unless the variables in the dependency array no longer refer to the same value. We manage cache invalidation by means of the dependency array.

This dependency array check itself uses another form of caching built into JavaScript called referential equality, in which objects and functions are compared by their memory address, without regard for their content. This caches the object reference across internal changes.

Together, these create a situation in which the behavior of a component array becomes unpredictable and thus error-prone.

For example, if in a dependency array one of the values is an object, two unexpected circumstances can occur. Either the object content may change while the refering variable remains the same, resulting in a failure to invalidate the cache, or the object content is unchanged, but by some means the variable referencing it changes, resulting in a spurious invalidation. Both behaviors can result in serious, hard to find bugs.

Refs

useRef is caching. The React documentation explicitly states:

Do not write or read ref.current during rendering, except for initialization. This makes your component's behavior unpredictable.

This is because a ref refers to something outside the React lifecycle, usually a DOM node. When React renders, the cached value in ref.current may be invalidated.


Why is React a fractal of caching? What could possibly justify this design? The answer is mutability.

React has Metastatic Mutability

At the core of React is a component tree. Each component is a function holding some state, and every time its state changes it runs, and, barring some prophylactic, so do all its descendant components. As the React documentation explains:

There are two reasons for a component to render:

  1. It’s the component’s initial render.
  2. The component’s (or one of its ancestors’) state has been updated.

So one state change, in one component, could cause a thousand functions to run. Some of the descendant components may be computationally expensive or initiate network requests.

React is the Helen of Troy of front-end frameworks. The beautiful simplicity masks a cascade of potentially dire consequences.

Lifts

Maybe the solution is to keep state mostly in leaf components, or near enough, where descendants are few. However, in practice this does not work, as the React documentation acknowledges:

Sometimes, you want the state of two components to always change together. To do it, remove state from both of them, move it to their closest common parent, and then pass it down to them via props. This is known as lifting state up, and it’s one of the most common things you will do writing React code.

source

Now we understand, "one of the most common things you will do writing React code" causes our mutations to metastasize, impacting more of our component tree.

Callbacks

How are these mutations triggered? Usually by user interactions with the DOM of descendant components, instead of in the shared ancestor where the state is held. For this to work we must pass callbacks down as well as state.

These callbacks are functions, which means unless they are memoized (cached) they are re-created every run of the component where they are defined, therefore we have useCallback.

Caching Required

With state heavy components high up in the tree, side effects, like server calls, strewn about, and callbacks sprinkled across leaf components, not to mention direct DOM access, we have a mess. All this mutation requires limits.

Caching escapes this mutation. The rules of renders are simple, but the consequences are far-reaching, burdensome, and often quite surprising.

Final Thoughts

Is there another way to approach web development, such that we avoid all this mutation and caching, while retaining the interactive dynamism of React?

Yes.

Masking Flaws

From the early days of React at Facebook these flaws were apparent. The Flux design pattern was established to mitigate the inherently undesirable characteristics of React.

Since then, many approaches have been used, starting with the venerable and boilerplate heavy Redux, and proceeding through to MobX and Zustand and more.

React themselves introduced the Context API, five years later, as a built-in approach, but just like all the third party solutions this only masked the problem of mutation and caching.

Alternatives

It may not be feasible to eschew React at your organization, but if it is, there are many alternatives which offer similar advantages without the flaws.

Consider Solid, the highest ranked front-end framework for satisfaction every year since it was added to the State of JS survey. With a focus on fine-grained reactivity, where mutations are constrained and caching is less necessary, it tops every performance benchmark and has slowly become a complete solution to rival React. The approach they use has also inspired other frameworks and led to Svelte runes, and Vue vapor.

Perhaps for a less overall dynamic use case, htmx can offer what is needed without JavaScript generating the UI, but rather the server generating the UI with interactivity tacked on and handled through htmx. Maybe the even less well known Datastar, operating via server sent events, is ideal for certain use cases. To quote the htmx creator, Carson Gross, "idk!"


Maybe you are stuck with React. At least you now know why it feels so uncomfortable and understand it isn't you. You're not the imposter; React is.

share this post