Component, colocation, composition: A note on the state of React
#Reactjs #webNote: Just before I published this post, Tanner Linsley of Tanstack posted an excellent long form tweet on the same topic: component, colocation, and composition. Tanner’s tweet is more focused on how React’s new direction is seemingly in conflict with colocation of data requirements on client, but I believe the general idea is the same one I attempted to elaborate here—only he said it better!
Recently, the React community went through a bit of drama. I’m not here to recite the whole saga in this blog as this excellent chronicle by Dominik Dorfmeister (aka TkDodo) has already appeared. But here’s a brief summary:
It started with a change included in the React 19 RC release that mostly flew under the community’s radar at first. Previously, React on client would concurrently render all siblings under a suspense boundary and display the provided fallback element until all promises are resolved. Kind of like Promise.all
. The changed behavior would instead sequentially render siblings under the same suspense boundary on the client, which would allow React to display the fallback element faster while introducing a waterfall of async tasks. A bit like for await...of
loop.
It might have been a trivial change from the core team’s perspective since the suspense behavior on client was still marked as experimental. But a lot of big libraries in the React ecosystem, including React Query, tRPC, and React-three-fiber, were already depending on the original behavior as well as many individual early adopters. So a big pushback was inevitable.
This all shortly concluded with the React team reverting the change, acknowledging the concerns and promising a better alternative in future. What most stood out to me from the discussion, however, was the core team’s early response to the community feedback: initiating data fetching in individual components under a suspense boundary, a pattern known as fetch-on-render, is a bad practice. The recommended alternative was to prefetch data outside the components that read the data. But wait, isn’t React all about composing components? Components that contain everything they need—state, logic, and view—all nicely colocated? Are components not sufficient?
Component, a success story
One fundamental bet of React is that components are primary building blocks for UI apps. Each component contains a full recipe for React to produce a piece of UI—state, logic, and view. A component may include other components as its children, thus forming a tree. So a React app is the root component of a component tree that is passed to React to render. On state change, React re-executes the whole or parts of the component tree to produce the latest view. Of course, the actual process involves a few more steps, but that’s the gist of it.
This has turned out to be a wildly successful bet. For years, React has reigned supreme in the web UI development space, and has gained significant traction in the mobile development space as well. There have been many alternatives, some of which have found great success as well, but React still largely dictates key concepts and conventions in the space. Indeed, most React alternatives are component-based, even if they may differ in the details on how components work.
In fact, the idea of component has been so successful that React is bringing components to the other side of the network. We now have React on server (or as I’d like to call it, React server) that understands components, produces an intermediary React tree representation, and sends it over the wire. React on client then weaves the server output and its own component tree into a single React tree to finally update the UI on screen.
The trouble with state
Unfortunately, React components fall short as a sole model for the real world apps. One main challenge has to do with state—or more precisely, the high cost of shared state across the component tree. The React model executes components to produce the latest view on state change, and this puts a lot of strain on performance for a large tree that includes computationally expensive components. This is exacerbated by state that sits close to the root of the component tree and used broadly across the tree, because any update to it can lead to a huge amount of work regardless of how small the actual diff might be.
Another important and related challenge is data fetching. A component tree in which individual components fetch data to satisfy their own data requirements can lead to a waterfall of network requests and poor user experience. This problem is made worse when data requirements for one component depend on another’s, further degrading user experience.
The community has recognized these challenges and made numerous attempts to address them, some more successful than others. Facebook engineers, the largest React users since its introduction, proposed Flux as an architectural solution in which React was playing only a part.1 For more concrete solutions, we have seen Redux and many “state management” solutions, React Query and other “data fetching” solutions, Relay’s compilation of multiple GraphQL fragments combined into a single query, React Router/Remix’s nested routes and parallel data loading patterns… The list goes on.
In all of these, we repeatedly find a common pattern where we need more than a single component tree to build an app with React: we need managed state placed outside the component tree.2 Of course, the managed state still needs to be accessible from the component tree, and the main mechanism for this is using a context and hooks for individual components to access managed state and data. And there we have the common structure of a React app over the last several years, always buttressed by the ecosystem.
Welcome to the jungle
You wanted a banana but what you got was a gorilla holding the banana and the entire jungle.
This is a famous quote of Joe Armstrong, a co-creator of the Erlang programming language. In the original context, the quote was an analogy to describe the problem of implicit environment in object-oriented languages. It may be a surprise to some, but the same applies to React as well.
A common practice—a temptation, even—is to put everything a component needs to produce UI inside its body, making it appear as an autonomous, self-contained Lego block to compose with. There’s even a name for this, state colocation. The general idea is to keep state as close as to where it’s used as possible. This mostly works when all necessary state is strictly local to the component or provided as a prop. But as we’ve discussed earlier, passing external state to components as props can get very expensive in React—an entire ecosystem is built around addressing this!
A natural workaround to this conundrum is to place state in a context and have components to access it via hooks. This allows React to re-render only the relevant components on state change instead of a larger component subtree. Indeed, this is what many established solutions handle state: place our context provider at the top of the component tree and access managed state directly from your components via hooks like useSelector
, useQuery
, useLoaderData
, etc. The not-so-hidden cost of this performance optimization is that your components are now tightly coupled to these gorilla contexts.
So we now have arrived at the jungle. We wanted simple components to compose with, but what we got is components tightly coupled to global state and contexts as well as other dependencies. The irony is that the more we try to make our components autonomous and self-sufficient by colocating all their state and data requirements next to the UI output, the less composable they become. Funny how components come so close to objects, state colocation to encapsulation, and context and props to dependency injection… they may as well be the same thing!
”Components are pure overhead”
Many of us have learned to live in the component jungle. Some may even enjoy it for fun and games. But others are desperate to find a way out. What are the alternatives?
Solid, the poster child for Signals, mostly bypasses the problem of state-view duality by treating view as a mere by-product. The truth lives in the Signals graph holding state, which happens to have a side effect that keeps UI view in sync. All “components” in Solid melt into air after completing their job of setting up the Signals graph. In fact, Solid’s creator Ryan Carniato already declared that “the future is component-less” as early as in 2021. And we’re seeing his prophecy (maybe) coming true as more UI frameworks moving away from components in runtime: Svelte 5, Vue Vapor, etc.
htmx’s locality of behavior (LoB) principle offers an alternative take on what made components successful: the intuition that what changes together should be placed together. But of course, there is no real “component” to speak of in htmx. All there is are just a handful of hx-
attributes to encode behaviors into HTML elements and some tricks like “boosting.” And what about state? It mostly just lives on the server side, unbeknownst to the client side that receives the resulting view over the wire. This is a limitation that is also a blessing to many who got burned by juggling duplicated state on both ends of the network.
Worth noting that state takes priority over view in either approach.
YARVTW (Yet another React vs the world)
Back to the recent drama. What it highlights is the gap between React’s component-oritented vision for building apps and the reality of its current limitations—as well as the established solutions from the community to address those limitations. Until recently, React and its community partners were largely aligned. But the relationship is starting to evolve as React’s own vision and scope expand.
By bringing components to server, React takes another shot at putting both state and view under the same component model. Yes, there are still two separate structures, but they’re both component trees that React can manage and, here it goes, compose. Along with that, React aims to establish new patterns for architecting apps where the component tree on server takes on the responsibility of keeping the current view in sync with server state and data. In turn, the component tree on client gets new features—such as useOptimistic
, useActionState
, useFormStatus
, and more—to streamline passing messages to and from its server counterpart. In this new React architecture, perhaps React alone is sufficient to implement the Flux pattern across the network gap. (And is it just a coincidence that React now has “action” in its core terminology? I’m just asking questions here!) The client tree getting leaner and lighter is a bonus, and React Compiler can make it run even faster!
As it pursues this “RSC strategy” in full force, React now makes a new demand from the ecosystem where frameworks take a more central role. And with that shift of focus, frictions are inevitable—all established solutions from the community have invested a lot of time and energy but now face an uncertain future. So the latest drama is only one manifestation of the said frictions—I expect more to come. React rarely backs down from pursing its vision even if that means going against the world. Only this time, the world is its own ecosystem. Will friends turn enemies? Or will their bond grow stronger after tests and trials?
All eyes are on React.
Footnotes
-
This “in-depth overview” article and this recorded talk introducing Flux are worth re-reading and re-watching. ↩
-
Ryan Carniato, the creator of Solid, made an insightful comment on this very point in his livestream a while ago: “React’s biggest simplification, it’s biggest flaw—they are basically equating your data tree with your view tree… It is a fundamental misalignment. They are related but they are not the same thing.” In fact, the quote was from his discussion on Tanner Linsley’s tweet on concurrent rendering. Tanner really knows what’s up! ↩