DiegoVallejo

Getting Up to Date with React

React has shipped a paradigm-shifting feature almost every year since its open-source debut in 2013. If you stepped away for a while, the codebase you return to will look nothing like the one you left. This post walks through every era, explains why each change mattered, and — where possible — lets you interact with a live demo right here in the page.


2013–2015 · The Birth — JSX, Virtual DOM, Components

React introduced three ideas that were radical at the time:

  1. JSX — write HTML-like syntax directly in JavaScript.
  2. Virtual DOM — diff a lightweight tree instead of touching the real DOM.
  3. One-way data flow — props flow down, events flow up.
// The original "Hello World" — still valid today
const element = <h1>Hello, {name}!</h1>;
ReactDOM.render(element, document.getElementById('root'));

The component model made UI a function of state: given the same props, a component always renders the same output. This was the foundation everything else was built on.

📖 References: Writing Markup with JSX · Virtual DOM Internals · MDN: DOM

2016–2017 · The Class Component Era

Before hooks, class components were the only way to hold state or respond to lifecycle events.

class Timer extends React.Component {
  state = { seconds: 0 };

  componentDidMount() {
    this.interval = setInterval(() => {
      this.setState(s => ({ seconds: s.seconds + 1 }));
    }, 1000);
  }

  componentWillUnmount() {
    clearInterval(this.interval);
  }

  render() {
    return <p>Elapsed: {this.state.seconds}s</p>;
  }
}

This era also popularised two powerful composition patterns:

  • Higher-Order Components (HOCs) — functions that wrap a component to inject behaviour (withRouter, connect).
  • Render Props — components that expose their internal logic through a function child.
// HOC pattern (e.g. react-router v4)
export default withRouter(function Nav({ history }) {
  return <button onClick={() => history.push('/home')}>Go Home</button>;
});

The main criticism? "Wrapper hell" — deeply nested HOCs made the component tree unreadable in DevTools, and sharing stateful logic between components was verbose.

📖 References: React.Component API · Higher-Order Components · Render Props

2018 · Context API (Modern) & Portals

React 16.3 shipped the new Context API, replacing the old, unstable one. It provided a first-class way to share values across the tree without prop drilling.

const ThemeCtx = React.createContext('light');

function App() {
  return (
    <ThemeCtx.Provider value="dark">
      <Toolbar />
    </ThemeCtx.Provider>
  );
}

// Deep child — no prop drilling needed
function ThemedButton() {
  return (
    <ThemeCtx.Consumer>{theme => <button className={theme}>Click me</button>}</ThemeCtx.Consumer>
  );
}

Portals (ReactDOM.createPortal) also landed here, allowing components to render children into a different DOM node — essential for modals, tooltips, and toast notifications.

📖 References: createContext · createPortal · Passing Data with Context

2019 · Hooks — The Paradigm Shift

React 16.8 introduced Hooks, the single biggest API change in React's history. They let functional components hold state, run side effects, and compose logic — all without classes.

Hook Purpose
useState Local state
useEffect Side effects (fetch, subscriptions, timers)
useContext Read context without a Consumer wrapper
useRef Mutable ref that persists across renders
useMemo / useCallback Memoisation
useReducer Complex state machines

Why it mattered

  • Custom hooks let you extract and reuse stateful logic as plain functions (useFetch, useLocalStorage, useDebounce).
  • No more this binding issues.
  • The codebase moves from OOP to functional composition.
function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handler = () => setWidth(window.innerWidth);
    window.addEventListener('resize', handler);
    return () => window.removeEventListener('resize', handler);
  }, []);

  return width;
}

Live Demo — useState + useEffect

Open in new tab ↗

📖 References: useState · useEffect · Custom Hooks · Hooks API Reference

2020–2021 · Concurrent Features & Suspense

React 17 was a "stepping-stone" release with no new developer-facing features (it focused on the new JSX transform and gradual upgrades). But behind the scenes, the Concurrent Mode architecture was being built.

The first user-visible feature was React.lazy + <Suspense>:

const LazyDashboard = React.lazy(() => import('./Dashboard'));

function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <LazyDashboard />
    </Suspense>
  );
}

This let you code-split at the component level with zero configuration — Webpack (or any bundler) automatically creates a new chunk for the dynamic import.

Live Demo — React.lazy + Suspense

Open in new tab ↗

📖 References: React.lazy · Suspense · MDN: Dynamic import()

2022 · React 18 — Concurrent Rendering for Real

React 18 made concurrent rendering opt-in and production-ready. Key additions:

createRoot (New Root API)

// Before (legacy)
ReactDOM.render(<App />, document.getElementById('root'));

// After (React 18+)
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

useTransition — Mark Non-Urgent Updates

function SearchResults({ query }) {
  const [isPending, startTransition] = useTransition();
  const [results, setResults] = useState([]);

  function handleChange(e) {
    // Typing is urgent — update the input immediately
    setInputValue(e.target.value);

    // Filtering thousands of items is NOT urgent
    startTransition(() => {
      setResults(filterItems(e.target.value));
    });
  }

  return (
    <>
      <input onChange={handleChange} />
      {isPending ? <Spinner /> : <List items={results} />}
    </>
  );
}

Automatic Batching

In React 17, state updates inside setTimeout or fetch callbacks were not batched. In React 18, all updates are batched by default, reducing unnecessary re-renders.

// React 18: these two setState calls produce ONE re-render
setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // React 18 batches → 1 render
  // React 17 → 2 renders
}, 1000);
📖 References: React 18 Release · useTransition · useDeferredValue · createRoot

2023 · React Server Components (RSC)

Server Components are arguably the most architectural shift since hooks. With the React + Next.js 13+ App Router, components are server-rendered by default — they never ship JavaScript to the client.

Open in new tab ↗

The Mental Model

Server Component  →  Can read DB, access filesystem, fetch APIs at build/request time
                     Ships 0 KB of JS to the browser
                     Cannot use hooks or browser APIs

Client Component  →  "use client" directive at the top of the file
                     Runs in the browser, can use useState, onClick, etc.

Server Actions

// app/actions.ts
'use server';

export async function createPost(formData: FormData) {
  const title = formData.get('title');
  await db.posts.create({ title });
  revalidatePath('/posts');
}
// app/page.tsx  (Server Component)
import { createPost } from './actions';

export default function Page() {
  return (
    <form action={createPost}>
      <input name="title" />
      <button type="submit">Create</button>
    </form>
  );
}

No API route, no fetch, no loading state boilerplate. The form calls a server function directly.

📖 References: Server Components · Server Actions · Next.js: Server Components

2024+ · React 19 — The Latest Era

React 19 ships new primitives that dramatically reduce boilerplate:

use() — Read Promises & Context in Render

// Before: useEffect + useState + loading state
// After:
function Comments({ commentsPromise }) {
  const comments = use(commentsPromise);
  return comments.map(c => <p key={c.id}>{c.body}</p>);
}

Unlike await, use() can be called conditionally and works with Suspense boundaries.

useActionState — Forms Without Boilerplate

async function updateName(prevState, formData) {
  const name = formData.get('name');
  const error = await saveName(name);
  if (error) return { error };
  return { error: null };
}

function NameForm() {
  const [state, formAction, isPending] = useActionState(updateName, null);

  return (
    <form action={formAction}>
      <input name="name" />
      <button disabled={isPending}>Save</button>
      {state?.error && <p>{state.error}</p>}
    </form>
  );
}

useOptimistic — Instant UI Feedback

function MessageList({ messages, sendMessage }) {
  const [optimistic, addOptimistic] = useOptimistic(messages, (state, newMsg) => [
    ...state,
    { text: newMsg, pending: true },
  ]);

  async function handleSubmit(formData) {
    const text = formData.get('message');
    addOptimistic(text); // Instant UI update
    await sendMessage(text); // Actual server call
  }

  return (
    <>
      {optimistic.map((m, i) => (
        <p key={i} style={{ opacity: m.pending ? 0.5 : 1 }}>
          {m.text}
        </p>
      ))}
      <form action={handleSubmit}>
        <input name="message" />
      </form>
    </>
  );
}

React Compiler (React Forget)

The React Compiler automatically memoises components and hooks. You no longer need to manually wrap things in useMemo, useCallback, or React.memo — the compiler figures out the minimal set of values that need to be tracked.

// Before React Compiler — manual memoisation everywhere:
const filtered = useMemo(() => items.filter(filterFn), [items, filterFn]);
const handleClick = useCallback(() => {
  /* ... */
}, [dep]);

// After React Compiler — just write plain code:
const filtered = items.filter(filterFn); // compiler handles it
const handleClick = () => {
  /* ... */
};

Live Demo — use() + useActionState

Open in new tab ↗

📖 References: React 19 Release · use() · useActionState · useOptimistic · React Compiler

The Trajectory

Looking at the timeline, a clear pattern emerges:

Era Theme
2013–2017 Declarative UI — Components, Virtual DOM, one-way data flow
2018–2019 Composition — Context, Hooks, custom hooks
2020–2022 Performance — Concurrent rendering, Suspense, automatic batching
2023–2024+ Server-first — RSC, Server Actions, zero-JS by default

React's trajectory is moving the "work" from the client to the server, while making the client-side code that remains simpler to write and impossible to misuse. The React Compiler is the logical endpoint of this philosophy: the developer writes straightforward code, and the toolchain handles the rest.


"The best code is the code you don't have to write — and the best optimisation is the one the compiler does for you."

Further Reading