Introduction
React changed how we think about building UIs. As it continues evolving, it’s changing how we think about building applications.
The space between how we think something works, or should work, and how it actually works is where bugs and performance issues creep in. Having a clear and accurate mental model of a technology is key to mastering it.
Software development is also a team sport, even if it’s our future selves (or an AI). A collective understanding of how things work, and how to build and share code helps create a coherent vision and consistent structure in a codebase, so we can avoid reinventing the wheel.
In this post, we’ll explore the evolution of React and the various code reuse patterns that have emerged. We’ll dig into the underlying mental models that shaped them, and the tradeoffs that came with them.
By the end, we’ll have a clearer picture of React’s past, present, and future. Be equipped to dive into legacy codebases and assess how other technologies take different approaches and make different tradeoffs.
A brief history of React APIs
Let’s start when object-orientated design patterns were more widespread in the JS ecosystem. We can see this influence in the early React APIs.
Mixins
The React.createClass
API was the original way to create components. React had its own representation of a class before Javascript supported the native class
syntax. Mixins are a general OOP pattern for code reuse, here’s a simplified example:
function ShoppingCart() {
this.items = []
}
var orderMixin = {
calculateTotal() {
// calculate from this.items
},
// .. other methods
}
// mix that bad boy in like it's 2014
Object.assign(ShoppingCart.prototype, orderMixin)
var cart = new ShoppingCart()
cart.calculateTotal()
Javascript doesn’t support multiple inheritance, so mixins were a way to reuse shared behavior and augment classes.
Getting back to React - the question was how can we share logic between components made with createClass
?
Mixins were a commonly used pattern, so it seemed as good an idea as any. Mixins had access to a component’s life cycle methods allowing us to compose logic, state, and effects:
var SubscriptionMixin = {
// multiple mixins could contribute to
// the end getInitialState result
getInitialState: function () {
return {
comments: DataSource.getComments(),
}
},
// when a component used multiple mixins
// React would try to be smart and merge the lifecycle
// methods of multiple mixins, so each would be called
componentDidMount: function () {
console.log('do something on mount')
},
componentWillUnmount: function () {
console.log('do something on unmount')
},
}
// pass our object to createClass
var CommentList = React.createClass({
// define them under the mixins property
mixins: [SubscriptionMixin, AnotherMixin, SomeOtherMixin],
render: function () {
// comments in this.state coming from the first mixin
// (!) hard to tell where all the other state
// comes from with multiple mixins
var { comments, ...otherStuff } = this.state
return (
<div>
{comments.map(function (comment) {
return <Comment key={comment.id} comment={comment} />
})}
</div>
)
},
})
This worked well for small enough examples. But mixins had a few drawbacks when pushed to scale:
Name collisions: Mixins have a shared namespace. Collisions occur when multiple mixins use the same name for a method or piece of state.
Implicit dependencies: It took some work to determine which mixin provided what functionality or state. They reference shared property keys to interact with each other, creating an implicit coupling.
Reduced the ability to reason locally: Mixins often made components harder to reason about and debug. For example, several mixins could contribute to the
getInitialState
result, making things hard to track down.
Having felt the pain of these issues, the React team published “Mixins Considered Harmful” discouraging the use of this pattern going forward.
Higher-order components
Eventually we got native class
syntax in Javascript. The React team deprecated the createClass
API in v15.5, favoring native classes.
We were still thinking in terms of classes and life cycles, so there was no major mental model shift during this transition. We could now extend Reacts Component
class which included the lifecycle methods:
class MyComponent extends React.Component {
constructor(props) {
// runs before the component mounts to the DOM
// super refers to the parent Component constructor
super(props)
// calling it allows us to access
// this.state and this.props in here
}
// life cycle methods related to mounting and unmounting
componentWillMount() {}
componentDidMount() {}
componentWillUnmount() {}
// component update life cycle methods
// some now prefixed with UNSAFE_
componentWillUpdate() {}
shouldComponentUpdate() {}
componentWillReceiveProps() {}
getSnapshotBeforeUpdate() {}
componentDidUpdate() {}
// .. and more methods
render() {}
}
With the pitfalls of mixins in mind, the question was how do we share logic and effects in this new way of writing React components?
Higher Order Components (HOCs) entered the scene via an early gist. It got its name from the functional programming concept of higher order functions.
They became a popular way to replace mixins, making their way into APIs in libraries like Redux, with its connect
function, which connected a component to the Redux store, and React Router’s withRouter
.
// a function that creates enhanced components
// that have some extra state, behavior, or props
const EnhancedComponent = myHoc(MyComponent)
// simplified example of a HOC
function myHoc(Component) {
return class extends React.Component {
componentDidMount() {
console.log('do stuff')
}
render() {
// render the original component with some injected props
return <Component {...this.props} extraProps={42} />
}
}
}
HOCs were useful for sharing common behaviors across multiple components. They allowed the wrapped components to stay decoupled and generic enough to be reusable.
Abstractions are powerful because we tend to run with them once we have them, and use them for everything. It turned out that HOCs ran into similar problems as mixins:
Name collisions: Because HOCs would need to forward and spread
...this.props
to wrapped components, collisions could occur with nested HOCs overriding each other.Hard to statically type: This was roughly around the time static type checkers had really caught on. When multiple nested HOCs would inject new props into wrapped components, it was a pain to type correctly.
Obscured data flow: With mixins, the question was, “Where’s this state coming from?“. With HOCs, it was “Where are these props coming from?“. Because they are composed statically at the module level, it can be hard to trace the data flow.
Alongside these pitfalls, overusing HOCs led to deeply nested and complex component hierarchies and performance issues that were hard to debug.
Render props
The render prop pattern started to emerge as an alternative to HOCs, popularized by open source APIs like React-Motion and downshift, and from the folks building React Router.
<Motion style={{ x: 10 }}>{(interpolatingStyle) => <div style={interpolatingStyle} />}</Motion>
The idea is to pass a function as a prop to a component. Which would then call that function internally, passing along any data and methods, inverting control back to the function to continue rendering whatever they want.
Compared to HOCs, the composition happens at runtime inside JSX, rather than statically in module scope. They didn’t suffer from name collisions because it was explicit where things came from. They were also much easier to statically type.
One clunky aspect was when used as a data provider, they could quickly lead to deeply nested pyramids, creating a visual false hierarchy of components:
<UserProvider>
{user => (
<UserPreferences user={user}>
{userPreferences => (
<Project user={user}>
{project => (
<IssueTracker project={project}>
{issues => (
<Notification user={user}>
{notifications => (
<TimeTracker user={user}>
{timeData => (
<TeamMembers project={project}>
{teamMembers => (
<RenderThangs renderItem={item => (
// do stuff
// what was i doing again?
)}/>
)}
</TeamMembers>
)}
</TimeTracker>
)}
</Notification>
)}
</IssueTracker>
)}
</Project>
)}
</UserPreferences>
)}
</UserProvider>
Around this time, it was common to separate concerns between components that manage state and those that render UI.
The “container” and “presentational” component patterns fell out of favor with the advent of hooks. But it’s worth mentioning the pattern here, to see how they are somewhat reborn with server components.
In any case, render props are still an effective pattern for creating composable component APIs.
Enter hooks
Hooks became the official way to reuse logic and effects in the React 16.8 release. This solidified function components as the recommended way to write components.
Hooks made reusing effects and composing logic colocated in components much more straightforward. Compared to classes where encapsulating and sharing logic and effects was trickier, with bits and pieces spread across various lifecycle methods.
Deeply nested structures could be simplified and flattened. Alongside the surging popularity of TypeScript they were also easy to type.
// flattened our contrived example above
function Example() {
const user = useUser()
const userPreferences = useUserPreferences(user)
const project = useProject(user)
const issues = useIssueTracker(project)
const notifications = useNotification(user)
const timeData = useTimeTracker(user)
const teamMembers = useTeamMembers(project)
return <div>{/* render stuff */}</div>
}
Understanding the tradeoffs
There were numerous benefits, and they resolved subtle issues with classes. But they weren’t without some tradeoffs, let’s dig into them now.
Splitbrain between classes and functions
From a consumer’s perspective of components, nothing changed with this transition; we continued to render JSX the same way. But there was now a splitbrain between the paradigm of classes and functions, particularly for those learning both simultaneously.
Classes come with associations of OOP with stateful classes. And functions with functional programming and concepts like pure functions. Each model has useful analogies but only partially captures the full picture.
Class components read state and props from a mutable this
and think about responding to lifecycle events. Function components leverage closures and think in terms of declarative synchronization and effects.
Common analogies like components are functions where the arguments are “props”, and functions should be pure, don’t match up with a class-based mental model.
On the flip side, keeping functions “pure” in a functional programming model doesn’t fully account for the local state and effects that are key elements of a React component. Where it’s not intuitive to think that hooks are also a part of what’s returned from a component, forming a declarative description of state, effects, and JSX.
The idea of a component in React, its implementation using Javascript, and our attempts to explain it using existing terminology all contribute to the difficulty of building an accurate mental model for those learning React.
Gaps in our understanding lead to buggy code. Some common culprits in this transition were infinite loops when setting state or fetching data. Or reading stale props and state. Thinking imperatively and responding to events and lifecyles often introduces unnecessary state and effects, where you might not need them.
Developer experience
Classes had a different set of terminology in terms of componenDid
, componentWill
, shouldComponent?
, and binding methods to instances.
Functions and hooks simplified this by removing the outer class shell, allowing us to focus solely on the render function. Where everything gets recreated each render, and so we rediscovered that we need to be able to preserve things between render cycles.
For those familiar with classes, this revealed a new perspective on React that was there from the beginning. APIs like useCallback
and useMemo
were introduced so we could define what should be preserved between re-renders.
Having to explicitly manage dependency arrays, and think about object identities all the time, on top of the syntax noise of the hooks API, felt like a worse developer experience to some. For others, hooks greatly simplified both their mental model of React and their code.
The experimental React forget aims to improve the developer experience by pre-compiling React components, removing the need to manually memoize and manage dependency arrays. Highlighting the tradeoff between leaving things explicit or trying to handle things under the hood.
Coupling state and logic to React
Many React apps using state management libraries like Redux or MobX kept state and view separately. This is in line with the original tagline of React as the “the view” in MVC.
Over time there was a shift from global monolithic stores, towards more colocation, with the idea that “everything is a component” particularly with render props. Which was solidified with the move to hooks.
There are tradeoffs to both “app-centric” and “component-centric” models. Managing state decoupled from React gives you more control when things should re-render, allows independent development of stores and components, allows you to run and test all logic separately from the UI.
On the other hand, the colocation and composability of hooks that can be used by multiple components, improve local reasoning, portability and other benefits we’ll touch on next.
Principles behind React’s evolution
So what can we learn from the evolution of these patterns? And what are some heuristics that can guide us to worthwhile tradeoffs?
User experience over APIs
Frameworks and libraries must consider both the developer experience and the end-user experience. Trading off user experience for developer experience is a false dichotomy, but sometimes one gets prioritized over the other.
For example, runtime CSS in JS libraries, like
styled-components
are amazing to use when you have a lot of dynamic styles, but they can come at the price of end-user experience. There’s a spectrum to be balanced. React as a library falls on this spectrum compared to other faster frameworks.We can see the concurrent features in React 18, and RSCs as innovations in pursuit of better end-user experiences.
Pursuing these meant updating the APIs and patterns we use to implement components. The “snapshotting” property (closures) of functions makes it easier to write code that works correctly in concurrent mode, and where
async
functions on the server are a good way to express Server Components.APIs over implementations
The APIs and patterns we have discussed so far are from the perspective of implementing the internals of components.
While the implementation details have evolved from
createClass
, to ES6 classes, to stateful functions - the higher-level API concept of a “component” which can be stateful with effects has remained stable throughout this evolution:return ( <ImplementedWithMixins> <ComponentUsingHOCs> <ThisUsesHooks> <ServerComponentWoah /> </ThisUsesHooks> </ComponentUsingHOCs> </ImplementedWithMixins> )
Focus on the right primitives
In other words, build on solid foundations. In React this is the component model which allows us to think declaratively and to reason locally.
This makes them portable, where we can more easily delete, move, and copy and paste code without accidentally tearing out any hidden wiring holding things together.
Architectures and patterns that go with the grain of this model afford better composability which often requires keeping things localized where components capture a colocation of concerns and accepting the tradeoffs that come with that.
Abstractions that go against the grain in this model obscure the data-flow and make tracing and debugging hard to follow, adding implicit couplings.
One example is the transition from classes to hooks, where logic split across multiple lifecycle events is now packaged up in a composable function that can be colocated directly in components.
A good way to think about React is as a library that provides a set of low-level primitives to build on top of. It’s flexible to architect things the way you want, which can be both a blessing and a curse.
This ties into the popularity of higher-level application-level frameworks like Remix and Next that layer on stronger opinions and abstractions.
React’s expanding mental model
As React extends its reach beyond the client, it’s providing primitives that allow developers to build full-stack applications. Writing backend code in our frontends opens up a new range of patterns and tradeoffs.
Compared to previous transitions, this transition is more of an expansion of our existing mental models, rather than a paradigm shift that requires us to unlearn previous ones.
For a deep dive into this evolution, you can check out Rethinking React best practices, where I talked about the new wave of patterns around data loading and data mutations and how we think about the client-side cache.
How is this different from PHP?
In a fully server-driven state model like PHP the client’s role is more or less a recipient of HTML. Compute centralizes on the server, templates are rendered, and any client state between route changes gets blown away with full-page refreshes.
In a hybrid model, both client and server components contribute to the overall compute architecture. Where the scale can slide back and forth depending on the type of experience you are delivering.
For a lot of experiences on the web, doing more on the server makes sense, it allows us to offload compute-intensive tasks and avoid sending bloated bundles down the wire. But a client-driven approach is better if we need fast interactions with much less latency than a full server round trip.
React evolved from the client-only part of this model, but we can imagine React first starting on the server and adding the client parts later.
Understanding a fullstack React
Blending the client and server requires us to know where the boundaries are within our module graph. This is necessary to reason locally on where, when and how the code will run.
For this, we’re starting to see a new pattern in React in the form of directives (or pragma, similar to "use strict"
, and "use asm"
, or "worklet"
in React Native) that change the meaning of the code following it.
Understanding
"use client"
This is placed at the top of a file above the imports to indicate that the following code is “client code” marking a boundary from server-only code.
Where the other modules imported into it (and their dependencies) are considered part of the client bundle sent down the wire.
Terms like client and server are again only rough approximations of the mental model, as they don’t determine the environment where the code runs.
Components with
"use client"
can also run on the server. For example, as part of generating the initial HTML or as part of a static site generation process. In other words, these are the React components we know and love today.The
"use server"
directiveAction functions are a way for the client to call functions that live on the server - the remote procedure call pattern.
The
"use server"
can be placed at the top of an action function in a server component, to tell a compiler that it should stay on the server.// inside a server component // allows the client to reference and call this function // without sending it down to the client // server (RSC) -> client (RPC) -> server (Action) async function update(formData: FormData) { 'use server' await db.post.update({ content: formData.get('content'), }) }
In Next, when a
"use server"
is at the top of the file tells the bundler that all exports are server action functions. This ensures that the function is not included in the client bundle.
When our backends and frontend share the same module graph, there’s the potential for accidentally sending down a bunch of client code you didn’t mean to, or worse, accidentally importing sensitive data into your client bundles.
To ensure this doesn’t happen, there is also the "server-only"
package as a way to mark a boundary to ensure that the code following it is only used on the server components.
These experimental directives and patterns are also being explored in other frameworks beyond React that mark this distinction with syntax like server$
.
Fullstack composition
In this transition, the abstraction of a component gets raised to an even higher level to include both server and client elements. This affords the possibility of reusing, and composing entire full-stack verticle slices of functionality.
// we can imagine sharable fullstack components
// that encapsulate both server and client details
<Suspense fallback={<LoadingSkelly />}>
<AIPoweredRecommendationThing apiKey={proccess.env.AI_KEY} promptContext={getPromptContext(user)} />
</Suspense>
The price for this power comes from the underlying complexity of advanced bundlers, compilers, and routers found in the meta-frameworks that build on top of React. And as frontenders, an expansion of our mental model to understand the implications of writing backend code in the same module graph as our frontends.
Conclusion
We’ve covered a lot of ground, from mixins to server components, exploring the evolution of React and the landscape of tradeoffs with each paradigm.
Understanding these shifts and the principles that underpin them is a good way to construct a clear mental model of React. Having an accurate mental model enables us to build efficiently and quickly pinpoint bugs and performance bottlenecks.
On large projects, a lot of the complexity comes from the patchwork of half-finished migrations and ideas that are never fully baked. This often happens when there is no coherent vision or consistent structure to align to. A shared understanding helps us communicate and build together coherently, creating reusable abstractions that can adapt and evolve over time.
As we’ve seen, one of the tricky aspects of building an accurate mental model is the impedance mismatch between the pre-existing terminology, and language we use to articulate concepts, and how those concepts are implemented in practice.
A good way to build up a mental model is to appreciate the tradeoffs and benefits of each approach, which is necessary to be able to pick the right approach for a given task, without falling into a dogmatic adherence to a particular approach or pattern.