Reading Legacy React: Class Components and Pre-Hooks Patterns

Recognize the class-component patterns — lifecycle methods, higher-order components, render props, legacy context, ReactDOM.render, and older ref patterns — that appear throughout real-world existing React codebases, and know the modern hook-based equivalent for each.

Hooks became the default way to write React in 2019, but an enormous amount of production React code predates them and is still running today. You will, sooner or later, open a file with class Widget extends React.Component in it — inside a legacy internal tool, an older library, a codebase mid-migration, or a tutorial from a few years ago. This lesson is not about writing new code this way; it's about fluently reading it, so you can work in and modernize real codebases instead of being stopped cold by unfamiliar syntax.

Class components

Before hooks, every component that needed state or lifecycle behavior had to be written as an ES6 class extending React.Component (or React.PureComponent). State lived in a single this.state object, updated exclusively through this.setState() — never by mutating this.state directly. Rendering happened in a required render() method that returned JSX, and event handlers were ordinary class methods that usually needed to be bound to this (via .bind(this) in the constructor, or written as class field arrow functions) because plain method references lose their this binding when passed as callbacks.

Modern replacement: a function component with the useState hook. State becomes one or more independent useState calls instead of one combined object, there is no this to bind because functions don't have it, and the component body itself plays the role render() used to play.

• class Widget extends React.Component { ... } -> function Widget() { ... } • this.state = { count: 0 }; this.setState({ count: 1 }) -> const [count, setCount] = useState(0); setCount(1) • render() { return <div>...</div>; } -> function body directly returns JSX • Constructor .bind(this) or class field arrows for handlers -> no binding needed; plain functions/closures

Lifecycle methods: componentDidMount, componentDidUpdate, componentWillUnmount

Class components expose 'lifecycle methods' — methods React calls automatically at specific points in a component's existence. componentDidMount() runs once, right after the component's first render, and is where class components traditionally fetched data, set up subscriptions, or read from the DOM. componentDidUpdate(prevProps, prevState) runs after every re-render caused by a props or state change (but not the first render), and is where code reacted to changes — typically by comparing the passed-in prevProps/prevState against the current ones to decide whether to act. componentWillUnmount() runs once, right before the component is removed from the DOM, and is where cleanup (clearing timers, unsubscribing, cancelling requests) belonged.

Modern replacement: the useEffect hook unifies all three into one mental model. An effect with an empty dependency array ([]) runs once after mount, matching componentDidMount. An effect with dependencies runs after mount and again whenever any listed dependency changes, matching the combined behavior of componentDidMount + componentDidUpdate for those specific values — no manual prevProps comparison needed, since the dependency array does that job. A function returned from the effect is React's designated cleanup slot, running before the next effect run and on unmount, replacing componentWillUnmount.

• componentDidMount() -> useEffect(() => { ... }, []) • componentDidUpdate(prevProps, prevState) with manual diffing -> useEffect(() => { ... }, [dep1, dep2]) — reruns automatically when deps change • componentWillUnmount() -> the cleanup function returned from useEffect: useEffect(() => { ...; return () => { /* cleanup */ }; }, []) • One effect per concern is idiomatic with hooks, vs. one componentDidUpdate handling every concern together in classes

Higher-order components (HOCs)

A higher-order component is a function that takes a component and returns a new, enhanced component — a pattern for reusing logic across components before hooks existed. withAuth(MyComponent) might return a new component that checks authentication and either renders <MyComponent /> with extra props injected, or redirects. Libraries like older React Router (withRouter) and older Redux (connect) are built entirely on this pattern.

HOCs work but have real downsides: wrapping a component in several HOCs creates deeply nested component trees ('wrapper hell') that are hard to read in dev tools, prop names can silently collide between HOCs, and it's not always obvious, just by reading a component's own code, where its props are actually coming from.

Modern replacement: a custom hook. Instead of withAuth(MyComponent) injecting an isAuthenticated prop from the outside, a useAuth() hook is called directly inside the component, returning exactly the values it needs, with no extra wrapper component and no prop-origin mystery — you can see precisely which hook produced which value by reading top to bottom.

Render props

A render prop is a prop whose value is a function that a component calls during its own render, typically named render or passed as children, to let the consumer decide exactly what to render with some data or behavior the provider manages internally. For example, a <MouseTracker render={({x, y}) => <p>{x}, {y}</p>} /> component tracks mouse position internally and calls this.props.render(position) inside its own render(), letting the consumer control the actual output.

This solved the same code-reuse problem as HOCs, without the wrapper-hell issue, but introduced its own downside: deeply nested render-prop components produce a 'callback pyramid' of nested function props that is visually harder to read than a flat list of hook calls.

Modern replacement: again, a custom hook. const { x, y } = useMousePosition(); gives a component exactly the data it needs directly, with no wrapping component and no nested callback — the same capability, flattened.

Legacy context

Before the modern Context API (React.createContext, still the current standard and NOT itself legacy), an earlier, undocumented-by-default 'legacy context' API existed, using special static properties (contextTypes, childContextTypes) and a getChildContext() method to pass data implicitly down a tree without prop drilling. It was fragile, poorly typed, officially discouraged even while it existed, and is now removed entirely from current React — code still using it will not run on modern React versions.

Modern replacement: React.createContext() combined with a <Context.Provider> and, in function components, the useContext() hook (or, in class components that must remain classes, static contextType) — a fully supported, type-safe, current API, not a legacy one.

ReactDOM.render and older ref patterns

Older React apps mounted their root with ReactDOM.render(<App />, document.getElementById('root')). React 18 replaced this with createRoot(document.getElementById('root')).render(<App />) from react-dom/client, which opts the app into React 18's concurrent rendering features; ReactDOM.render still ran in React 18 with a deprecation warning and was removed entirely in React 19.

Refs also changed shape over time. The oldest pattern, string refs (<input ref="myInput" />, accessed via this.refs.myInput), was deprecated for years and is gone in current React — it had subtle performance and correctness issues. It was replaced by callback refs (<input ref={el => { this.inputEl = el; }} />), which still work today and remain useful for advanced cases, and then by React.createRef() for class components (this.inputRef = React.createRef(); <input ref={this.inputRef} />, read via this.inputRef.current). In modern function components, the direct equivalent of createRef usage is the useRef() hook.

• ReactDOM.render(<App/>, root) -> createRoot(root).render(<App/>) (react-dom/client), required as of React 19 • String refs (ref="name", this.refs.name) -> removed from current React entirely; never write these • this.inputRef = React.createRef() (class) -> const inputRef = useRef(null) (function component) • Callback refs (ref={el => ...}) -> still valid today; used directly, or now composed inside custom hooks

Legacy lifecycle class component vs. modern hook equivalent

componentDidMount + componentDidUpdate's manual prevProps.userId check collapse into one useEffect with [userId] in its dependency array. componentWillUnmount's this.cancelled flag becomes a local cancelled variable set by the cleanup function returned from useEffect. The class needed a constructor purely to initialize this.state; the function component just calls useState twice, no constructor required.

// LEGACY — class component with lifecycle methods
class UserProfile extends React.Component {
  constructor(props) {
    super(props);
    this.state = { user: null, loading: true };
  }

  componentDidMount() {
    this.fetchUser(this.props.userId);
  }

  componentDidUpdate(prevProps) {
    if (prevProps.userId !== this.props.userId) {
      this.fetchUser(this.props.userId);
    }
  }

  componentWillUnmount() {
    this.cancelled = true;
  }

  fetchUser(id) {
    this.setState({ loading: true });
    fetch(`/api/users/${id}`)
      .then(res => res.json())
      .then(user => {
        if (!this.cancelled) this.setState({ user, loading: false });
      });
  }

  render() {
    if (this.state.loading) return <p>Loading...</p>;
    return <p>{this.state.user.name}</p>;
  }
}

// MODERN — function component with hooks
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let cancelled = false;
    setLoading(true);
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        if (!cancelled) {
          setUser(data);
          setLoading(false);
    
…

Code Examples

The HOC wraps Header in an anonymous class that injects a width prop invisibly — reading <ResponsiveHeader title="Home" /> alone doesn't reveal that width exists at all. The hook version calls useWindowWidth() directly inside Header, so the data source is visible right where it's used, with no wrapper component added to the tree. Expected result: Both render '<h1>Home (1440px)</h1>' and update live on window resize; only the hook version is transparent about where width comes from.

const width = useWindowWidth();

Common mistake: Stacking several HOCs (withAuth(withRouter(withTheme(MyComponent)))) creates wrapper hell — nested component names in dev tools become unreadable, and prop names silently collide if two HOCs happen to inject the same prop name.

String refs (this.refs.name) were removed from current React entirely — code using them will not run on a modern install. useRef gives a mutable .current container that survives re-renders without causing them, the direct modern equivalent of React.createRef() in class components. createRoot replaces ReactDOM.render as the required React 18/19 mounting API and enables concurrent features. Expected result: focusInput() (or the ref) focuses the <input> in both versions; only the modern version runs on React 19. If you see ReactDOM.render in a codebase, that alone is a strong signal you're looking at code written for React 17 or earlier and due for a React 18/19 upgrade pass.

const inputRef = useRef(null);

// LEGACY — higher-order component
function withWindowWidth(WrappedComponent) {
  return class extends React.Component {
    state = { width: window.innerWidth };

    handleResize = () => this.setState({ width: window.innerWidth });

    componentDidMount() {
      window.addEventListener('resize', this.handleResize);
    }

    componentWillUnmount() {
      window.removeEventListener('resize', this.handleResize);
    }

    render() {
      return <WrappedComponent {...this.props} width={this.state.width} />;
    }
  };
}

const ResponsiveHeader = withWindowWidth(Header);
// usage: <ResponsiveHeader title="Home" />  (width injected invisibly)

// MODERN — custom hook
function useWindowWidth() {
  const [width, setWidth] = useState(window.innerWidth);

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

  return width;
}

function Header({ title }) {
  const width = useWindowWidth();
  return <h1>{title} ({width}px)</h1>;
}
// LEGACY (removed in current React — shown for recognition only)
class SearchBox extends React.Component {
  focusInput() {
    this.refs.searchInput.focus(); // string ref — removed from modern React
  }
  render() {
    return <input ref="searchInput" />;
  }
}
ReactDOM.render(<SearchBox />, document.getElementById('root'));

// MODERN — matches React 18/19
function SearchBox() {
  const inputRef = useRef(null);
  const focusInput = () => inputRef.current.focus();
  return <input ref={inputRef} onFocus={() => console.log('focused')} />;
}

import { createRoot } from 'react-dom/client';
createRoot(document.getElementById('root')).render(<SearchBox />);

Legacy Lifecycle → Modern React Hooks / APIs

Every legacy pattern here maps onto one or two hooks or a current API call — hooks did not add new capability so much as unify and simplify capability classes already had access to through several different, more verbose mechanisms.

LEGACY (class components)              MODERN (function components)
------------------------------          ------------------------------
this.state / this.setState()      -->   useState()
render()                          -->   function body returns JSX
constructor + .bind(this)         -->   not needed (no `this`)
componentDidMount                 -->   useEffect(fn, [])
componentDidUpdate(prevProps)     -->   useEffect(fn, [deps])
componentWillUnmount               -->   cleanup fn returned from useEffect
Higher-order components (HOC)     -->   custom hooks (useSomething())
Render props                      -->   custom hooks (useSomething())
Legacy context (contextTypes)     -->   createContext() + useContext()
ReactDOM.render()                 -->   createRoot(...).render()  [required, React 19]
String refs (ref="name")          -->   useRef() (removed entirely, React 19)
React.createRef() (class)         -->   useRef() (function component)

Important Rules

Never write new code using string refs, legacy context (contextTypes/childContextTypes), or ReactDOM.render — all are deprecated or fully removed in current React.

componentDidMount + componentDidUpdate + componentWillUnmount collapse into a single useEffect whose dependency array and optional cleanup function cover all three cases.

When you see a component wrapped by a function like withX(Component), recognize it as a higher-order component — the injected props come from the wrapper, not from Component's own code.

When you see a prop that is itself a function called during render (often named render or children), recognize it as the render props pattern.

A codebase using ReactDOM.render instead of createRoot is running React 17 or earlier, or hasn't finished a React 18+ migration.

It is entirely valid — and common — for a mature codebase to mix class components and function components; you do not need to rewrite classes to work productively alongside them.

Why this matters

Very few engineers get to work exclusively in brand-new, hooks-only codebases. Enterprise dashboards, older SaaS products, and long-lived internal tools routinely have class components, HOCs, and render-prop libraries (older React Router, older Redux, some older UI kits) still running in production. Being able to open one of these files, immediately recognize the pattern, and reason about it in terms of the modern hook you'd reach for is exactly the skill that lets you contribute to a real team on day one instead of needing everything rewritten first.

Assuming a componentDidUpdate(prevProps) method with no condition inside it is equivalent to useEffect with no dependency array.

useEffect with NO dependency array runs after every render, matching componentDidMount + componentDidUpdate combined with no filtering at all — that's the direct match for an unconditional componentDidUpdate; a dependency array narrows which changes trigger it, which is the more common and more correct choice.

A dependency array is what makes useEffect selectively rerun, mirroring a componentDidUpdate that manually checks prevProps before acting. Omitting the array entirely is closer to an unconditional componentDidUpdate, and is usually not what you want.

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

import { createRoot } from 'react-dom/client'; createRoot(document.getElementById('root')).render(<App />);

ReactDOM.render was deprecated in React 18 and removed in React 19; createRoot is the required entry point on current React and is what enables concurrent rendering features.

<input ref="myInput" /> then this.refs.myInput.focus() const myInputRef = React.createRef(); <input ref={myInputRef} /> then myInputRef.current.focus() (or useRef in a function component)

String refs are removed entirely from current React — this pattern will not run at all on a modern React install, not merely produce a warning.

[conceptual] Which single hook, used correctly, replaces the combination of componentDidMount, componentDidUpdate, and componentWillUnmount? A. useState, because it can hold any value including functions B. useContext, because it replaces legacy context entirely C. useEffect, using its dependency array to control when it reruns and its returned function for cleanup D. useMemo, because it also runs code after render

C. useEffect, using its dependency array to control when it reruns and its returned function for cleanup — useEffect's dependency array determines whether it behaves like componentDidMount (empty array), like componentDidMount+componentDidUpdate for specific values (array with deps), and its optional returned cleanup function plays the role of componentWillUnmount.

[conceptual] A component named withPermissions wraps other components and injects a canEdit prop into them. What legacy pattern is this, and what is its modern replacement? A. A render prop; replaced by the children prop B. A higher-order component (HOC); replaced by a custom hook like usePermissions() C. Legacy context; replaced by useReducer D. A class component; replaced by a function component

B. A higher-order component (HOC); replaced by a custom hook like usePermissions() — A function that takes a component and returns a new component with extra props injected is the higher-order component pattern. The modern equivalent is a custom hook (e.g. usePermissions()) called directly inside the component that needs the value, avoiding the extra wrapper component.

[recognition] You see <input ref="emailInput" /> and this.refs.emailInput.focus() in a file. What should you conclude? A. This is valid, current React code using callback refs B. This is a string ref, a pattern removed entirely from current React — this exact code will not run on a modern React install C. This is the modern useRef pattern written slightly differently D. This is legacy context syntax

B. This is a string ref, a pattern removed entirely from current React — this exact code will not run on a modern React install — String refs (ref="name" plus this.refs.name) were deprecated for years and have since been removed from React entirely. Seeing this pattern signals genuinely old code, not just an older but still-supported style.

[recognition] Why does ReactDOM.render(<App />, document.getElementById('root')) signal an outdated codebase? A. It was replaced by createRoot(...).render(...) from react-dom/client, deprecated in React 18 and removed in React 19 B. ReactDOM.render never existed in any version of React C. It only works with TypeScript, not JavaScript D. It is still the current recommended API and signals nothing about the codebase's age

A. It was replaced by createRoot(...).render(...) from react-dom/client, deprecated in React 18 and removed in React 19 — createRoot is the React 18/19-era mounting API that also enables concurrent rendering features. A codebase still calling ReactDOM.render is either on React 17 or earlier, or mid-migration to React 18+.

You inherit a component: class Timer extends React.Component { componentDidMount() { this.id = setInterval(() => this.setState({count: this.state.count + 1}), 1000); } componentWillUnmount() { clearInterval(this.id); } render() { return <p>{this.state.count}</p>; } }. Rewrite it as a modern function component using hooks, preserving the cleanup behavior.

function Timer() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(c => c + 1); }, 1000); return () => clearInterval(id); }, []); return <p>{count}</p>; } // Note: setCount(c => c + 1) (functional update) avoids needing count in the // dependency array, matching the original's intent without the classic // stale-closure bug that setCount(count + 1) would introduce here.

componentDidMount + componentWillUnmount together, with nothing depending on props, map onto one useEffect with an empty dependency array and a returned cleanup function.