The evolution of React APIs and code reuse

Trace the evolution of React APIs and the mental models behind them. From mixins to hooks, to RSCs, understand the tradeoffs along the way.

Rem · 25 May 2023

Share:

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:

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:

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?

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.

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.

References and resources

Want to level up?

Get notified when new content comes out

Feel free to unsubscribe anytime. No spam.