DEV Community

Cover image for React History in Plain Words: What Each Era Took Off Your Plate, and What It Billed You
Olexandr Uvarov
Olexandr Uvarov

Posted on

React History in Plain Words: What Each Era Took Off Your Plate, and What It Billed You

Here is a component every app has. A user profile: load the user by userId, show their name, and show a green dot while they're online.

I'm going to write it again for most eras of React, from 2013 to today.

Most histories of React go version by version: 0.13, 16.8, 18, 19. You finish reading and you know the dates, but not why any of it happened. I wanted to try a different angle. Every era of React made a deal with you. It took something off your plate, and it sent you a new bill. I'll keep score as I go: what you stopped doing, and what you started paying.

One warning about order. This article follows problems, not dates. Next.js (2016) shows up after hooks (2019), because that's where its problem hurts most.


1. Before React: you are the sync engine

The jQuery version:

$.getJSON(`/api/users/${userId}`, (user) => {
  $('#name').text(user.name);
});

presence.on('change', (id, online) => {
  if (id === userId) $('#status').toggleClass('online', online);
});
Enter fullscreen mode Exit fullscreen mode

It works. Now add a second place that shows the same user, say a header avatar. Then a list of friends with their own dots. Every piece of data lives in two places: in your variables and in the DOM (the browser's live tree of page elements). Every change has to be copied by hand, to every place that shows it. Miss one, and the header says "online" while the profile says "offline".

Backbone, Knockout and AngularJS were already attacking this with data binding. React's answer, open-sourced in 2013, was different. Pete Hunt's Why React post from June 2013 makes the case: stop updating the DOM yourself. Describe what the UI should look like for the current data, and let the library work out what to change.

In plain words: before React you were a waiter who memorizes every table's order and runs to the kitchen with each correction. React gave you an order pad. You write down what the table wants now, and the kitchen works out the difference.

One detail almost nobody mentions: that same 2013 post already lists server rendering as a selling point. Keep it in mind. It comes back later with a different name.

Took off your plate: keeping the DOM in sync by hand.
Billed you: HTML inside JavaScript, and a build step to compile it. JSX looked like heresy in 2013, and a lot of the first year was spent arguing about it.


2. createClass and mixins: sharing logic, badly

Early React components were objects passed to React.createClass. A mixin was a plain object of methods that got copied into every component that listed it. That's how you shared logic:

var Profile = React.createClass({
  mixins: [FetchUserMixin, PresenceMixin], // each one adds state and lifecycle methods
  render: function () {
    if (!this.state.user) return null;
    return <div>{this.state.user.name} {this.state.online && 'online'}</div>;
  },
});
Enter fullscreen mode Exit fullscreen mode

React called every mixin's componentDidMount, so that part worked. The trouble was everything you couldn't see. Which mixin set online? What happens when two mixins both define handleChange? You found out when the page loaded and threw.

React added ES6 classes in 0.13, in March 2015, and classes had no mixins at all. In 2016 Dan Abramov wrote the post that explained why that was a feature, Mixins Considered Harmful. One line from it: "There is no guarantee that two particular mixins can be used together."

So mixins saved you from copy-pasting logic, and charged you in coupling you couldn't see. Rename a method, and the page load told you which mixin had depended on it.


3. Classes: one feature, three methods

Here's the profile as a class (written with class properties, the Babel syntax most teams used):

class Profile extends React.Component {
  state = { user: null, online: false };

  componentDidMount() {
    fetchUser(this.props.userId).then((user) => this.setState({ user }));
    this.unsubscribe = presence.subscribe(this.props.userId, (online) => this.setState({ online }));
  }

  componentDidUpdate(prev) {
    if (prev.userId !== this.props.userId) {
      fetchUser(this.props.userId).then((user) => this.setState({ user }));
      this.unsubscribe();
      this.unsubscribe = presence.subscribe(this.props.userId, (online) => this.setState({ online }));
    }
  }

  componentWillUnmount() {
    this.unsubscribe();
  }
}
Enter fullscreen mode Exit fullscreen mode

(presence.subscribe returns a function that unsubscribes. Both versions in this article rely on that.)

Look at where the presence logic lives. Subscribe in one method, re-subscribe in a second, unsubscribe in a third. One feature, split by when it runs instead of what it does. The fetch is copied twice. Forget the componentDidUpdate branch and the dot keeps showing the previous user.

And there's a race here that's easy to miss: whichever response arrives last wins. Click from user 1 to user 2 quickly. Two requests are in flight. If user 1's response arrives last, setState writes it, and the profile of user 2 shows user 1's name. Nothing in the class shape protects you from that.

Sharing logic got its own problem too. Mixins were out, so the community moved to patterns that wrap one component in another. A higher-order component is a function that takes a component and returns a wrapped one. A render prop is a function you pass as a prop so another component can decide what to render. Both work, but every shared behavior adds a layer. Open the component tree of a 2018 app and you'd scroll through withRouter(connect(withTheme(...))) before reaching your own code.

The React team listed all of this when they introduced hooks. The motivation section reads like a bug report for this exact component: "It's hard to reuse stateful logic between components", complex components where related logic is split across lifecycle methods, and "Classes confuse both people and machines".

Took off your plate: hidden mixin coupling, mostly by not supporting mixins at all.
Billed you: this, logic split across lifecycle methods, and deeply nested wrappers.


4. Hooks: logic grouped by what it does

Hooks were announced at React Conf in October 2018 and shipped in React 16.8, in February 2019. The profile:

function usePresence(userId) {
  const [online, setOnline] = useState(false);
  useEffect(() => presence.subscribe(userId, setOnline), [userId]);
  return online;
}

function Profile({ userId }) {
  const [user, setUser] = useState(null);
  const online = usePresence(userId);

  useEffect(() => {
    let ignore = false;
    fetchUser(userId).then((u) => { if (!ignore) setUser(u); });
    return () => { ignore = true; };
  }, [userId]);

  if (!user) return null;
  return <div>{user.name} {online && 'online'}</div>;
}
Enter fullscreen mode Exit fullscreen mode

The whole presence feature is now one small hook, in one place. Subscribe and unsubscribe sit together (the effect returns its own cleanup), and [userId] re-runs it when the user changes. usePresence is just a function. Any component can call it, and there's no wrapper in the tree.

The race from the class version is fixed with the ignore flag: when userId changes, the old effect's cleanup marks its request as stale. But notice who fixed it: you did. React gave you the place to put the fix and left the fix itself to you.

The new bill is the dependency array. Leave a value out of it, and the effect keeps using the value from the render where it was created. That's a stale closure, and it's the most common hooks bug there is. Lint rules catch most of these bugs, though keeping the linter quiet often means restructuring the code.

Hooks also made "fetch in an effect" the default way to load data. Classes had the same problem, but now it was everywhere. Say the profile shows the user's posts, in a child component that fetches them. The child can't start its request until the parent has fetched the user and rendered it. Then the posts component fetches comments. Each level waits for the one above. That's a waterfall. Libraries like TanStack Query, SWR and Relay helped a lot, but the requests still started in the browser, after the JavaScript arrived.

Took off your plate: this, lifecycle splits, nested wrappers.
Billed you: dependency arrays, stale closures, and cleanup you write yourself.


5. Next.js: render it on the server

Everything in sections 3 and 4 happens in the browser, after the JavaScript bundle (the files the browser downloads) arrives. Until then the user stares at an empty page. Then a spinner. Then another spinner, one per level of the waterfall.

Next.js launched in October 2016, from ZEIT (now Vercel). Its announcement describes where the industry had gone: the pendulum of "'fully server-rendered' swung abruptly to the extreme of 'single page app'". Next.js swung it back with server-side rendering: the page is rendered to HTML on the server with its data, and the browser gets real content right away.

This file-per-page setup is what's now called the Pages Router, to tell it apart from the newer App Router in section 7. At launch the data function was getInitialProps. Since Next.js 9.3 in March 2020, the usual way is getServerSideProps, which runs only on the server:

export async function getServerSideProps({ params }) {
  const user = await fetchUser(params.id);
  return { props: { user } };
}

export default function ProfilePage({ user }) {
  return <h1>{user.name}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

The fetch runs on the server, close to the data. No empty page, no first spinner.

Remember the detail from 2013? Server rendering was already on React's list of selling points. Next.js didn't invent it. It made it the default, so you stopped wiring up the server yourself.

The price is hydration. The server sends finished HTML, and it looks right, but it's dead: buttons do nothing yet. Then the browser downloads the same components as JavaScript and runs them again to wire up the clicks. That wiring step is hydration. You pay for the page twice. If the server and the browser render anything differently (Date.now(), a window check, a locale-formatted date), React throws a hydration mismatch. And data loading lives in one function at the top of the page, so a component deep in the tree still can't fetch its own data on the server.

Took off your plate: the blank page, and setting up server rendering by hand.
Billed you: hydration, hydration mismatches, and page-level data loading.


6. React 18: the era everyone skips

Timelines often jump from hooks straight to Server Components, and React 18 gets less attention than it deserves.

React 18, March 2022, brought three things. I'll take them one at a time.

Concurrent rendering. React can start an update, pause it to handle a click, and come back to it. You opt in through APIs like useTransition.

Suspense. You wrap a slow part of the page, say the user's posts, and React shows a fallback there, like a spinner, until it's ready.

Streaming. On the server, the parts of the page that are ready go out first. The slow part arrives later and fills in its spinner.

In plain words: before 18, the server was a restaurant that brought your whole order at once, so everyone waited for the slowest dish. After 18, starters come out when they're ready, and the steak follows.

There's no new profile code here, and that's the point. This era changed what React can do under the hood, and the next one is built on it.

The bill came from somewhere else. Strict Mode, React's opt-in wrapper for extra checks in development, started mounting every component twice to catch missing cleanup. If your profile fetched twice in dev after upgrading, this is why. Usually the fix was the cleanup function you'd skipped. So 18 took "the whole page waits for the slowest request" off your plate, once a framework wired up streaming for you, and billed you in effects that suddenly ran twice.


7. Server Components: components that never reach the browser

The idea was presented in December 2020, before React 18. It reached most developers through the App Router, Next.js's newer routing system: in beta in Next.js 13 in October 2022, stable in Next.js 13.4, in May 2023. React 19 is when the Server Components APIs were declared stable for frameworks to build on. The 13.4 announcement says the App Router is "built on the foundation of React Server Components and Suspense", the Suspense and streaming that React 18 shipped.

Server Components are not the same as server rendering. With SSR, a component renders on the server and then its code ships to the browser anyway, for hydration. A Server Component renders on the server and its code never ships at all.

The profile, today:

// app/users/[id]/page.tsx: a Server Component
import { PresenceDot } from './PresenceDot';

export default async function ProfilePage({ params }: { params: Promise<{ id: string }> }) {
  const { id } = await params; // Next 15+. In 13 and 14, params was a plain object.
  const user = await fetchUser(id);
  return (
    <>
      <h1>{user.name}</h1>
      <PresenceDot userId={user.id} />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode
// app/users/[id]/PresenceDot.tsx: ships to the browser, because state and a live subscription only exist there
'use client';
import { usePresence } from '@/hooks/usePresence';

export function PresenceDot({ userId }: { userId: string }) {
  const online = usePresence(userId);
  return online ? <span className="dot" /> : null;
}
Enter fullscreen mode Exit fullscreen mode

The component is async. It fetches its own data with a plain await, on the server, and no useEffect is involved. Any component in the tree can do this, not only the page. The heading and the rest of the page never ship to the browser as JavaScript. Only PresenceDot does, because it's the only part that needs a live subscription.

In plain words: with the Pages Router, the server sent a printed photo of the page, and then the camera too: all the code that built it, so the browser could retake the shot and make it interactive. Server Components send the photo and only the code for the parts that actually move.

The bill is bigger than one line per file. Three costs show up first:

  • 'use client' marks where the browser part starts, and everything that file imports ships with it. Put it on the page instead of on PresenceDot, and you're back in section 4:
  'use client'; // on page.tsx: the whole profile ships as JavaScript again,
  // the component can't be async anymore, and fetchUser moves back into a useEffect
Enter fullscreen mode Exit fullscreen mode
  • Server files can't use useState, useEffect or context, because there's no browser there to hold state. That's why context providers move down into small client files, and why many third-party UI libraries need a 'use client' wrapper before they work.
  • Props that cross the boundary must be serializable: plain data that can be sent as text. No functions, no class instances.

Then the quieter ones. Waterfalls can come back on the server, as sequential awaits in nested components, so you reach for Promise.all and Suspense boundaries. Next.js caches fetches, routes and navigations in separate layers whose defaults have changed between versions, so "why is this data stale" became a new kind of question. And Server Components need support from the bundler (the tool that packages your code), which in practice means a framework such as Next.js. Your app is tied closer to it than before.

Took off your plate: fetching in effects, the client waterfall, shipping JavaScript for parts that never change.
Billed you: the server/client boundary, caching, and framework lock-in.


8. Today: React 19 and the Compiler

React 19 went stable in December 2024. ref is now a regular prop, so the forwardRef wrapper is on its way out. Actions give you primitives (useActionState, useFormStatus, useOptimistic, <form action>) for the pending states, errors and optimistic updates of a form, the parts you used to build with three useState calls. (An optimistic update shows the expected result right away, before the server confirms it.)

Then in October 2025, React Compiler 1.0. Memoization means caching a result so it isn't recomputed on every render. For years, a fast React app meant doing it by hand:

const onSelect = useCallback((id) => router.push(`/users/${id}`), [router]);
const sorted = useMemo(() => friends.toSorted(byName), [friends]);
return <FriendList friends={sorted} onSelect={onSelect} />; // FriendList wrapped in memo()
Enter fullscreen mode Exit fullscreen mode

The compiler is an opt-in build step that adds that memoization for you. You write the plain version, and it works out what to cache. It can even memoize code after an early return, which manual useMemo can't, because hooks can't be called after a return.

One honest caveat. The docs keep useMemo and useCallback as escape hatches, options for the rare cases where the default isn't enough. The routine was handed off, not deleted.

The compiler also assumes you follow the Rules of React: render is a pure calculation, so props and state aren't mutated and nothing outside changes during render. When it detects a violation, it safely skips that component. A violation it can't detect can make a compiled component misbehave at runtime. Say the profile reads the current theme from a global variable during render: the compiler only sees props and state, so it caches the output, and the profile keeps the old theme after the global changes.

Took off your plate: manual memoization, forwardRef, the boilerplate of form state.
Billed you: the Rules of React, now enforced.


The ledger

Same order as the article. The dates are in each section.

Era Took off your plate Billed you Where you'll see the bill
React itself Keeping the DOM in sync by hand JSX, and a build step Your first webpack/Babel config
createClass + mixins Copy-pasting logic Hidden coupling between mixins A name clash error on page load
Classes Hidden mixin coupling this, lifecycle splits, nested wrappers A wrapper-deep component tree in DevTools
Hooks this, lifecycle splits, wrappers Dependency arrays, stale closures, your own cleanup react-hooks/exhaustive-deps warnings
Next.js The blank page, manual SSR Hydration, mismatches, page-level data loading A hydration error in the console
React 18 Waiting for the slowest request Double-run effects in Strict Mode Effects firing twice in dev
Server Components Effects for data, shipping static UI The client boundary, caching, lock-in A "needs 'use client'" error at build
React 19 + Compiler Memoization, forwardRef, form state The Rules of React, enforced Compiler lint errors, skipped components

Not every era took a chore. React 18 and Server Components mostly added things you couldn't do before. And the bills didn't get smaller. They moved: from your DOM code, to your effects, to the line between server and browser.


Closing

In 2013 the bill was a whole job: you were the sync engine. In 2026 it's knowing which side of one line your code runs on. That's real progress. It just isn't free, and every React release since has moved the cost rather than cancelled it.

Which bill are you paying right now: stale closures, hydration mismatches, or the 'use client' boundary?

Top comments (0)