Blog

Part 41 · Higher-Order Components in React

A higher-order component is a function that takes a component and gives back a new one. Learn how to build and type one, its rules and problems, and why hooks replaced most of them.

In Part 40 we shared logic by passing a function as a prop. This part shows an older way to share logic: the higher-order component, or HOC for short.

A HOC is a function. You give it a component, and it gives you back a new component. The new one wraps the old one and adds some behaviour, and sometimes props.

HOCs come from the time before hooks. Then, a component with state had to be a class, and classes can’t use hooks. HOCs and render props were the main ways to share code that used state. Part 40 told that story.

Newer code uses hooks for most of this. But you will meet HOCs in older code and in some libraries. Knowing how they work, and where they break, makes that code easy to read. At the end we turn one HOC into a hook, so you can see why newer code uses hooks.

Try this first

Read this code. Don’t press Run yet.

import type { ComponentType } from 'react'

function withLogging<P extends object>(Component: ComponentType<P>) {
  return function WithLogging(props: P) {
    console.log('Props:', JSON.stringify(props))
    return <Component {...props} />
  }
}

function Button({ label }: { label: string }) {
  return <button>{label}</button>
}

const LoggedButton = withLogging(Button)

export default function App() {
  return (
    <div>
      <Button label="Plain" />
      <LoggedButton label="Logged" />
    </div>
  )
}
Props: {"label":"Logged"}
Props: {"label":"Logged"}

Don’t worry about <P extends object> and ComponentType<P> yet. They are TypeScript, and we explain them later. For now, read P as “whatever props the component takes”.

Make a guess. Will the two buttons look different? And which button will write to the Console?

Press Run.

The two buttons look the same. Only the second one logs its props. The line appears twice because of Strict Mode. In development, React calls each component twice to find bugs. Part 2 explained why.

We never changed Button. withLogging made a new component around it. The new component logs, then shows the old Button with the same props.

First, higher-order functions

The name comes from plain JavaScript. A higher-order function is a function that takes a function, or gives one back. MDN says it exactly: “A function that returns a function or takes other functions as arguments is called a higher-order function.”

You have used one already. An array’s map takes a function and calls it for each item. Here are both kinds:

function double(n: number) {
  return n * 2
}

function makeAdder(amount: number) {
  return (n: number) => n + amount
}

const addTen = makeAdder(10)

export default function App() {
  return (
    <ul>
      <li>map: {[1, 2, 3].map(double).join(', ')}</li>
      <li>addTen(5): {addTen(5)}</li>
    </ul>
  )
}

map takes a function, double. makeAdder gives back a function. addTen is that new function, and it remembers the 10.

A higher-order component

A component is a function too. So a function can take a component and give back a new component. That is all a higher-order component is.

React’s old docs put it in one line. A higher-order component “is a function that takes a component and returns a new component”. They also say that HOCs “are not part of the React API”. There is no hoc import. A HOC is a pattern: a way of writing code that people repeat because it works.

Look at withLogging again:

  • It takes one component, Component.
  • Inside, it makes a new component, WithLogging.
  • WithLogging does its extra work, then shows <Component {...props} />.
  • withLogging returns WithLogging.

By habit, a HOC’s name starts with with. The result is a normal function component. Let’s check:

import type { ComponentType } from 'react'

function withLogging<P extends object>(Component: ComponentType<P>) {
  return function WithLogging(props: P) {
    console.log('Props:', JSON.stringify(props))
    return <Component {...props} />
  }
}

function Button({ label }: { label: string }) {
  return <button>{label}</button>
}

const LoggedButton = withLogging(Button)
const LoggedAgain = withLogging(Button)

export default function App() {
  return (
    <ul>
      <li>typeof LoggedButton: {typeof LoggedButton}</li>
      <li>Same as Button: {String(LoggedButton === Button)}</li>
      <li>Two calls, one result: {String(LoggedButton === LoggedAgain)}</li>
    </ul>
  )
}

LoggedButton is a function, and it is not Button. Each call to withLogging makes a brand new function. Remember that last line. It matters a lot in a rule below.

{...props} passes everything through

{...props} is spread, from Part 3. Each key of the props object becomes one prop. So whatever you give LoggedButton, Button gets too. The old docs call this a convention. A HOC should pass through the props it doesn’t need for its own job.

The old docs give one more convention: “Don’t Mutate the Original Component.” Mutate means change. A HOC must not change the component it gets, for example by adding things to it. It should only wrap it. withLogging never touches Button. Button still works on its own, as the first button showed.

How props travel through a HOC

Our next HOC is withLoading. While something loads, it shows “Loading…” instead of the component. The new component takes one extra prop, isLoading. The component inside never sees it.

1. Button is a normal component. It takes a label. 2. withLoading(Button) runs once. It gives back a new component. 3. App gives the new component two props. 4. WithLoading takes isLoading out for itself. 5. The rest, the label, goes through to Button. 6. If isLoading is true, WithLoading shows Loading… instead. Button takes: label withLoading(Button)runs once WithLoading (the new component)Button <ButtonWithLoadingisLoading={false}label="Save" /> isLoading: false label: "Save" kept by WithLoading Button shows Save Loading…

Props going through withLoading. Press play, or step through it with the arrows.

Here are the same steps in words.

  1. Button is a normal component. It takes a label.
  2. withLoading(Button) runs once, at the top of the file. It gives back a new component, WithLoading.
  3. App shows <ButtonWithLoading isLoading={false} label="Save" />. React calls WithLoading with both props.
  4. WithLoading takes isLoading out for itself. The rest is { label: "Save" }.
  5. It passes the rest through to Button. Button gets only label, and never knows a HOC was there.
  6. If isLoading is true, WithLoading shows “Loading…” and doesn’t show Button at all.

withLoading: show something else while loading

import { useState, type ComponentType } from 'react'

function withLoading<P extends object>(Component: ComponentType<P>) {
  return function WithLoading({ isLoading, ...rest }: P & { isLoading: boolean }) {
    if (isLoading) {
      return <p>Loading...</p>
    }
    return <Component {...(rest as P)} />
  }
}

function Button({ label }: { label: string }) {
  return <button>{label}</button>
}

const ButtonWithLoading = withLoading(Button)

export default function App() {
  const [isLoading, setIsLoading] = useState(true)
  return (
    <div>
      <button onClick={() => setIsLoading(!isLoading)}>
        Loading: {isLoading ? 'on' : 'off'}
      </button>
      <ButtonWithLoading isLoading={isLoading} label="Save" />
    </div>
  )
}

Run it. You see “Loading…”. Click “Loading: on”, and the “Save” button appears.

{ isLoading, ...rest } takes props apart. isLoading gets its own variable. The three dots before rest collect all the other props into a new object. This is called rest. It looks like spread, but it does the opposite job: it gathers, instead of spreading out.

P & { isLoading: boolean } is the type of the new component’s props. The & means “both”: all of P, plus isLoading. We come back to as P in the TypeScript section.

withAuth: show a fallback

A fallback is what you show instead, when the real thing can’t be shown. Many pages need the same check: “Is someone signed in? If not, ask them to sign in.” Without a HOC, every page repeats that check at the top. With a HOC, the check lives in one place.

This one reads the user from context. Part 23 explained context. It also takes a second argument, the message to show.

import { createContext, useContext, useState, type ComponentType } from 'react'

const UserContext = createContext<string | null>(null)

function withAuth<P extends object>(Component: ComponentType<P>, message: string) {
  return function WithAuth(props: P) {
    const user = useContext(UserContext)
    if (user === null) {
      return <p>{message}</p>
    }
    return <Component {...props} />
  }
}

function Settings() {
  return <p>Your settings</p>
}

function Orders({ count }: { count: number }) {
  return <p>You have {count} orders.</p>
}

const SettingsWithAuth = withAuth(Settings, 'Sign in to see your settings.')
const OrdersWithAuth = withAuth(Orders, 'Sign in to see your orders.')

export default function App() {
  const [user, setUser] = useState<string | null>(null)
  return (
    <UserContext value={user}>
      <button onClick={() => setUser(user === null ? 'Ana' : null)}>
        {user === null ? 'Sign in' : 'Sign out'}
      </button>
      <SettingsWithAuth />
      <OrdersWithAuth count={3} />
    </UserContext>
  )
}

Run it, and press “Sign in”. Both components appear. Settings and Orders have no idea that a sign-in check exists. The count prop still reaches Orders, because WithAuth passes all its props through.

withTheme: add a prop from context

The first two HOCs took a prop away or showed something else. This one injects a prop. To inject means to put something in from outside. withTheme reads the theme from context and hands it to the component as a prop.

import { createContext, useContext, type ComponentType } from 'react'

const ThemeContext = createContext('light')

function withTheme<P extends { theme: string }>(Component: ComponentType<P>) {
  return function WithTheme(props: Omit<P, 'theme'>) {
    const theme = useContext(ThemeContext)
    return <Component {...(props as P)} theme={theme} />
  }
}

function Button({ label, theme }: { label: string; theme: string }) {
  return <button>{label} ({theme})</button>
}

const ThemedButton = withTheme(Button)

export default function App() {
  return (
    <div>
      <ThemedButton label="Outside" />
      <ThemeContext value="dark">
        <ThemedButton label="Inside" />
      </ThemeContext>
    </div>
  )
}

App passes only label. Button gets label and theme. The HOC added the second one.

Typing a HOC

The HOCs above use three TypeScript tools. Here is what each one does.

<P>, a type parameter. Part 25 used <T> to mean “some type, decided later”. <P> is the same idea for props. When you write withLoading(Button), TypeScript looks at Button and works out that P is { label: string }. P extends object means P must be an object type.

ComponentType<P>. This is React’s type for “a component that takes props P“. Both function components and class components fit it.

Omit<P, 'theme'>. Omit makes a new type from P with some keys removed. TypeScript’s docs say it picks all the keys of a type, then removes the ones you name. withTheme adds theme itself, so the outside must not have to pass it.

What if you forget Omit? Here WithTheme takes the full P:

import { createContext, useContext, type ComponentType } from 'react'

const ThemeContext = createContext('light')

function withTheme<P extends { theme: string }>(Component: ComponentType<P>) {
  return function WithTheme(props: P) {
    const theme = useContext(ThemeContext)
    return <Component {...props} theme={theme} />
  }
}

function Button({ label, theme }: { label: string; theme: string }) {
  return <button>{label} ({theme})</button>
}

const ThemedButton = withTheme(Button)

export default function App() {
  return <ThemedButton label="Save" />
}

TypeScript stops at <ThemedButton label="Save" />. It says: Property 'theme' is missing in type '{ label: string; }' but required in type '{ label: string; theme: string; }'. The type still asks for theme, though the HOC adds it. The playground doesn’t check types, so it shows “Save (light)”.

as P. In withLoading, rest is “P without isLoading“. In withTheme, props is “P without theme“. TypeScript can’t prove that this is still a P. Without as P, the spread line in withLoading gets the error No overload matches this call. The detail under it says Type 'Omit<P & { isLoading: boolean; }, "isLoading">' is not assignable to type 'IntrinsicAttributes & P'.

as P tells TypeScript to treat the value as a P and stop checking. That hides a real problem. Say the component inside also takes a prop called isLoading, like a Spinner. Then withLoading takes isLoading away, and Spinner never gets it. We tried withLoading(Spinner): TypeScript found 0 errors, and Spinner got undefined. Both withLoading and withTheme use as P, so both can hide this. It is a prop name collision, which comes later.

Names: displayName

Every component that withLogging makes has the same name, WithLogging. Wrap three components, and you get three components named WithLogging. In an error message, you can’t tell them apart.

The old docs suggest a fix. Set displayName on the new component. React’s warnings then use displayName instead of the function’s own name. The old docs show the style WithSubscription(CommentList). It is the HOC’s name, then the wrapped component’s name inside ( ).

import type { ComponentType } from 'react'

function withLogging<P extends object>(Component: ComponentType<P>) {
  function WithLogging(props: P) {
    console.log('Props:', JSON.stringify(props))
    return <Component {...props} />
  }
  const name = Component.displayName || Component.name || 'Component'
  WithLogging.displayName = `WithLogging(${name})`
  return WithLogging
}

function Button({ label }: { label: string }) {
  return <button>{label}</button>
}

const LoggedButton = withLogging(Button)

export default function App() {
  return (
    <ul>
      <li>name: {LoggedButton.name}</li>
      <li>displayName: {LoggedButton.displayName}</li>
    </ul>
  )
}

The function’s name is still WithLogging. Its displayName is now WithLogging(Button).

Here is one warning we checked. Say a wrapper shows a list with no keys. With displayName set, the warning had this line:

Check the render method of `WithList(Button)`. See https://react.dev/link/warning-keys for more information.

Without it, the warning named only WithList. This was the same when the wrapper called a hook.

Always give the inner function a name too. Then, even with no displayName, React has a name to show.

The old docs also say the wrappers “show up in the React Developer Tools like any other component”. So displayName helps there too. We didn’t open the browser add-on for this part.

Refs pass through in React 19

A ref lets a parent reach a real tag on the page, like an input. Part 14 showed that in React 19, ref is a normal prop for a function component. React’s blog says: “Starting in React 19, you can now access ref as a prop for function components”.

So {...props} passes ref through a HOC too, with nothing extra. This HOC puts a border around any component:

import { useRef, useState, type ComponentProps, type ComponentType } from 'react'

function withBorder<P extends object>(Component: ComponentType<P>) {
  return function WithBorder(props: P) {
    return (
      <div style={{ border: '2px solid teal', padding: 8 }}>
        <Component {...props} />
      </div>
    )
  }
}

function NameBox(props: ComponentProps<'input'>) {
  return <input {...props} />
}

const BorderedNameBox = withBorder(NameBox)

export default function App() {
  const ref = useRef<HTMLInputElement>(null)
  const [found, setFound] = useState('nothing yet')
  return (
    <div>
      <BorderedNameBox ref={ref} aria-label="Name" />
      <button onClick={() => setFound(ref.current?.tagName ?? 'null')}>Check the ref</button>
      <p>The ref holds: {found}</p>
    </div>
  )
}

Run it and click “Check the ref”. It says INPUT. The ref went into WithBorder as a prop, through {...props} to NameBox, and through NameBox‘s own {...props} to the <input>. React printed no warning.

ComponentProps<'input'> is the type for “every prop an <input> can take”, and that includes ref.

Older code: forwardRef

Before React 19, this didn’t work. ref was not a prop, so {...props} never carried it. The old HOC page says passing all props through “does not work for refs”. We tried the withBorder above in React 18.3.1. The ref stayed null, and React printed a warning:

Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?

The fix then was forwardRef. It gives the wrapper the ref as a second argument, next to the props. The HOC then hands it on by name:

import { forwardRef, type ComponentProps, type ComponentType } from 'react'

type InputProps = ComponentProps<'input'>

function withBorder(Component: ComponentType<InputProps>) {
  return forwardRef<HTMLInputElement, InputProps>(function WithBorder(props, ref) {
    return (
      <div style={{ border: '2px solid teal', padding: 8 }}>
        <Component {...props} ref={ref} />
      </div>
    )
  })
}

To keep it short, this one only wraps components that take input props. In React 18 the inner NameBox had to use forwardRef too, as Part 14 showed. We checked: with both, the ref held the INPUT in React 18.3.1.

You’ll see this shape in older HOCs. React’s docs now say that in React 19, forwardRef “is no longer necessary”.

The two classic rules

Rule 1: never make a HOC inside a component

import { useState, type ComponentType } from 'react'

function withBorder<P extends object>(Component: ComponentType<P>) {
  return function WithBorder(props: P) {
    return (
      <div style={{ border: '2px solid teal', padding: 8 }}>
        <Component {...props} />
      </div>
    )
  }
}

function Counter() {
  const [count, setCount] = useState(0)
  return <button onClick={() => setCount(count + 1)}>Counter: {count}</button>
}

export default function App() {
  const [clicks, setClicks] = useState(0)
  const BorderedCounter = withBorder(Counter)
  return (
    <div>
      <button onClick={() => setClicks(clicks + 1)}>App: {clicks}</button>
      <BorderedCounter />
    </div>
  )
}

Run it. Click “Counter” twice, then “App”. The counter goes back to 0.

You saw above that each call to a HOC makes a new function. Here withBorder(Counter) runs on every render of App. So <BorderedCounter /> gets a new type each time. Part 2 showed what React does then. It removes the old component, with its state, and adds a new one. We checked: after the click on “App”, the counter’s <button> was a new tag on the page.

The old docs say why this hurts: “remounting a component causes the state of that component and all of its children to be lost”. Remounting means removing a component from the page and adding it again.

The fix: call withBorder(Counter) once, at the top level of the file. Then the counter keeps its 2.

Rule 2: copy static properties

A static property is a value stored on the component function itself, like Card.sizes. The new component from a HOC is a different function, so it doesn’t have them:

import type { ComponentType } from 'react'

function withBorder<P extends object>(Component: ComponentType<P>) {
  return function WithBorder(props: P) {
    return (
      <div style={{ border: '2px solid teal', padding: 8 }}>
        <Component {...props} />
      </div>
    )
  }
}

function Card({ size }: { size: string }) {
  return <p>Card: {size}</p>
}
Card.sizes = ['small', 'large']

const BorderedCard = withBorder(Card)

export default function App() {
  return (
    <ul>
      <li>Card.sizes: {String(Card.sizes)}</li>
      <li>BorderedCard.sizes: {String(BorderedCard.sizes)}</li>
    </ul>
  )
}

TypeScript catches it: Property 'sizes' does not exist on type. In the playground, press Run. The page shows Card.sizes: small,large and BorderedCard.sizes: undefined.

You can copy each one by hand, WithBorder.sizes = Component.sizes, but then the HOC must know every name. The old docs name a small package, hoist-non-react-statics, that copies them all. Its own page says it “Copies non-react specific statics from a child component to a parent component”. The old docs also suggest another way. Export the value on its own, next to the component. Don’t store it on the function.

memo looks like a HOC

You met memo in Part 20. const MemoButton = memo(Button) takes a component and gives back something you use as a component. React’s docs say memo “returns a new React component”. So it is used like a HOC.

But be careful with the word. Part 20 showed that what memo gives back is not a function:

import { memo } from 'react'

function Button({ label }: { label: string }) {
  return <button>{label}</button>
}

const MemoButton = memo(Button)

export default function App() {
  return (
    <ul>
      <li>typeof MemoButton: {typeof MemoButton}</li>
      <li>MemoButton.type is Button: {String(MemoButton.type === Button)}</li>
      <li>
        <MemoButton label="Still works as a tag" />
      </li>
    </ul>
  )
}

It is a small object, and its type points back at Button. Our HOCs return a new function that wraps the old one. memo returns a marker object that tells React “check the props before you call Button“. Both can go in a tag, and both follow Rule 1. Part 20 measured that: memo inside a component reset the counter too.

An everyday example

A HOC is like a phone case. You put the phone in the case. The phone doesn’t change. The case adds something, like a ring to hold it by. When you press a button on the case, the case presses the phone’s button for you.

The exact version

The case is a whole new component. React sees two components, the wrapper and the one inside, each with its own place in React’s tree. The wrapper may add no tag to the page at all. It can have its own state and hooks. It can also decide not to show the inner component, like withAuth did. And unlike a real case, a HOC must spread the props by hand. It doesn’t carry the component’s static properties either.

The problems with HOCs

HOCs work, but they have real costs. These costs are why newer code moved to hooks.

Wrapper hell

HOCs stack. withAuth(withTheme(withLoading(Button)), 'Sign in first.') gives one component that is really four. We made the inner Button throw an error and read the component stack. Its first four lines were, without the file places after each name:

at Button
at WithLoading
at WithTheme
at WithAuth

Every HOC adds one more layer around your component. React’s old page that introduced hooks called this “wrapper hell”. Look at a typical app in the React DevTools, it said. You find “components surrounded by layers of providers, consumers, higher-order components, render props”.

Prop names can collide

Two pieces of code that want the same prop name collide, which means they run into each other. withTheme injects theme. What if the outside also passes theme?

import { createContext, useContext, type ComponentType } from 'react'

const ThemeContext = createContext('light')

function withTheme<P extends { theme: string }>(Component: ComponentType<P>) {
  return function WithTheme(props: Omit<P, 'theme'>) {
    const theme = useContext(ThemeContext)
    return <Component {...(props as P)} theme={theme} />
  }
}

function Button({ label, theme }: { label: string; theme: string }) {
  return <button>{label} ({theme})</button>
}

const ThemedButton = withTheme(Button)

export default function App() {
  return <ThemedButton label="Save" theme="dark" />
}

Thanks to Omit, TypeScript stops you: Property 'theme' does not exist on type. But in the playground, press Run. The button says “Save (light)”. The "dark" you passed was thrown away, with no warning.

In JSX, when the same prop appears twice, the last one wins. Here theme={theme} comes after {...props}, so the HOC wins. Change the order to theme={theme} first, then {...props}, and the outside wins. We checked: the button then says “Save (dark)”, and the context is ignored. Either way, one value is lost, and React prints no warning.

Two HOCs can collide too. We wrapped withTheme(Button) in a second HOC that also injects theme. With the Omit types above, TypeScript refuses that stack: the error is TS2345. In plain JavaScript, or in the playground, which doesn’t check types, it runs. Then the outer HOC’s value never arrives, and the button says “Save (light)”.

You can’t see where props come from

Look at <ThemedButton label="Save" />. Nothing there says that Button also gets a theme. To find out, you must read withTheme. With three HOCs, you must read three files. And you must know which one adds which prop.

Hooks replaced most HOCs

A custom hook, from Part 16, is a function whose name starts with use, and that calls other hooks. It shares the same logic with none of these problems. Here is withTheme as a hook:

import { createContext, useContext } from 'react'

const ThemeContext = createContext('light')

function useTheme() {
  return useContext(ThemeContext)
}

function Button({ label }: { label: string }) {
  const theme = useTheme()
  return <button>{label} ({theme})</button>
}

export default function App() {
  return (
    <div>
      <Button label="Outside" />
      <ThemeContext value="dark">
        <Button label="Inside" />
      </ThemeContext>
    </div>
  )
}

The page is the same as with withTheme. Compare the two:

  • No wrapper. There is one component, Button. Nothing is added around it.
  • No hidden props. const theme = useTheme() is right there in Button. You can see where theme comes from.
  • No collisions. You name the variable yourself. Two hooks can’t both set one prop.
  • No Omit, no as P. The types are plain.

The React team gave this as a reason for hooks. Their page that introduced hooks said: “React needs a better primitive for sharing stateful logic.” A primitive here means a basic building block.

Where you still meet HOCs

  • Older code. Apps written before hooks often use HOCs. Now you can read them.
  • Libraries. React Redux’s connect makes HOCs. You call connect(mapState), and that gives you a HOC. Then connect(mapState)(TodoList) gives the wrapped component. The docs say the HOC “returns a wrapper component with the additional props it injects”. The same docs now mark connect as deprecated in React-Redux 9.3.0. They say “It still works, and we do not intend to remove it”. But they “recommend using the hooks API instead”.
  • Things a hook can’t do. A hook can return values, but it can’t wrap your JSX in another component. So a HOC still fits when the wrapper must be a component. One example is an error boundary, which Part 32 showed must be a class. The react-error-boundary package (6.1.6) has withErrorBoundary(Component, props). It wraps your component in its ErrorBoundary, and names the result withErrorBoundary(Name).
  • memo. As above, it works like a HOC, though it returns an object.

For new code, start with a hook.

Common mistakes

Making a HOC inside a component

const BorderedCounter = withBorder(Counter) inside App makes a new component on every render. State inside it is lost each time. Call the HOC once, at the top level of the file. See Rule 1.

Not passing the props through

import type { ComponentType } from 'react'

function withBorder<P extends object>(Component: ComponentType<P>) {
  return function WithBorder(props: P) {
    console.log('WithBorder got', JSON.stringify(props))
    return (
      <div style={{ border: '2px solid teal', padding: 8 }}>
        <Component />
      </div>
    )
  }
}

function Button({ label }: { label: string }) {
  return <button>Label: {label}</button>
}

const BorderedButton = withBorder(Button)

export default function App() {
  return <BorderedButton label="Save" />
}

TypeScript catches this. The error is No overload matches this call, and the detail under it says Type '{}' is not assignable to type 'P'. <Component /> passes no props, but Component needs a P. The playground doesn’t check types, so press Run. The button says “Label: ” with nothing after it. The Console shows WithBorder got {"label":"Save"}, twice because of Strict Mode. The wrapper got the label and kept it.

The fix: <Component {...props} />.

Passing the HOC’s own prop down to a tag

import type { ComponentProps, ComponentType } from 'react'

function withLoading<P extends object>(Component: ComponentType<P>) {
  return function WithLoading(props: P & { isLoading: boolean }) {
    if (props.isLoading) {
      return <p>Loading...</p>
    }
    return <Component {...props} />
  }
}

function Button(props: ComponentProps<'button'>) {
  return <button {...props} />
}

const ButtonWithLoading = withLoading(Button)

export default function App() {
  return <ButtonWithLoading isLoading={false}>Save</ButtonWithLoading>
}

Run it. The button shows, but the Console has a warning. It starts like this:

React does not recognize the `isLoading` prop on a DOM element.

isLoading belongs to the HOC. But {...props} passed it on to Button, and Button spread it onto a real <button>.

The fix is the one withLoading used above: take your own prop out first, with { isLoading, ...rest }, and pass only rest.

Two values for one prop name

The HOC and the outside both set theme. One value is lost, with no warning. Use Omit so TypeScript catches it. Better, use a hook. See Prop names can collide.

Expecting a ref to reach the inner component in old React

Before React 19, a ref on a HOC’s result stayed null, with the warning “Function components cannot be given refs”. In React 19 it reaches the inner component, as long as the HOC spreads its props. In an older project, use forwardRef inside the HOC. See Refs pass through in React 19.

Practice

Press Edit on any example above and try these.

  1. In “Try this first”, add a second <LoggedButton label="Two" /> under the first one. How many lines will the Console show, and in what order?
  2. Give withBorder a second argument, the border color. withBorder(NameBox, 'orange') should draw an orange border.
  3. Fix the example in Rule 1. Click “Counter” twice, then “App”. What does the counter show now?
  4. In the withAuth example, write a hook useUser(). Make Settings use it, instead of withAuth, and show its own message when nobody is signed in.
Answers
  1. 4 lines. Strict Mode calls each component twice in a row, so you see Props: {"label":"Logged"} twice, then Props: {"label":"Two"} twice.
  2. Add a parameter and use it in the style. The code is below. Then write const BorderedNameBox = withBorder(NameBox, 'orange'). The color is fixed when the HOC runs, once, at the top of the file.
  3. Move const BorderedCounter = withBorder(Counter) above App, out of the component. Now the counter still shows 2 after you click “App”.
  4. The code is below. Delete SettingsWithAuth, and in App show <Settings /> in its place. The page works the same. Now Settings itself says what happens when nobody is signed in.

Answer 2, withBorder with a color:

import type { ComponentType } from 'react'

function withBorder<P extends object>(Component: ComponentType<P>, color: string) {
  return function WithBorder(props: P) {
    return (
      <div style={{ border: `2px solid ${color}`, padding: 8 }}>
        <Component {...props} />
      </div>
    )
  }
}

Answer 4, a useUser hook:

import { createContext, useContext } from 'react'

const UserContext = createContext<string | null>(null)

function useUser() {
  return useContext(UserContext)
}

function Settings() {
  const user = useUser()
  if (user === null) {
    return <p>Sign in to see your settings.</p>
  }
  return <p>Your settings</p>
}

Interview questions

Try to answer each one out loud before you open the answer.

What is a higher-order component?

A function that takes a component and returns a new component. The new one wraps the old one and adds some behaviour, and sometimes props: a check, a log, a theme. withAuth(Settings) is an example. It is a pattern, not a React API. React’s old docs say HOCs “are not part of the React API”.

A strong answer links it to higher-order functions in JavaScript, like map. It also says a good HOC doesn’t change the component it gets. It wraps it and passes the props through.

Why must you not apply a HOC inside a component?

Each call to a HOC returns a new function. Inside a component, that happens on every render. So the element’s type is new each time. React then removes the old component and adds a new one, and all state inside it is lost. In our test, a counter at 2 went back to 0 after the parent rendered again.

A strong answer gives the fix: call the HOC once, at the top level of a file. It adds that the same is true of memo.

How do refs work with a HOC?

In React 19, ref is a normal prop for function components. So a HOC that spreads {...props} passes the ref through. We checked: a ref given to a HOC’s result reached the <input> inside. Before React 19, ref was not a prop. In our React 18.3.1 test, the ref stayed null, and React warned “Function components cannot be given refs”. HOCs had to use forwardRef to pass it on, and so did the component inside.

A strong answer adds that React’s docs say forwardRef “is no longer necessary” in React 19.

Why give the component from a HOC a display name?

So you can tell wrappers apart. Without it, every component from withLogging is called WithLogging. With displayName = 'WithLogging(Button)', React’s warnings use that name. In our test, a missing-key warning named WithList(Button) instead of just WithList. The old docs say the wrappers show up in the React DevTools too.

A strong answer adds that the inner function needs a name in any case. Then React has something to show. It also follows the old docs’ style: the HOC’s name, then the wrapped component’s name inside ( ).

HOCs, render props and hooks: how do they compare?

All three share logic between components.

  • A HOC wraps a component and adds behaviour, and sometimes props. It adds a layer to the tree. Its props are hidden, and two HOCs can collide on a prop name.
  • A render prop is “a prop whose value is a function”, in the words of React’s old docs. The component calls it to get JSX. You can see the data where you use it. But you still add a component around your JSX.
  • A custom hook is a function whose name starts with use, and that calls other hooks. It adds no wrapper. You name the values yourself, so nothing collides.

A strong answer says hooks are the default for new code. HOCs and render props still appear in older code and some libraries. A HOC also still fits when the wrapper must be a component, like an error boundary.

Is React.memo a higher-order component?

It is used like one. It takes a component and gives back something you use in a tag. React’s docs say it “returns a new React component”. But it doesn’t return a function. It returns a small object whose type points at your component. That object tells React to compare props before it calls the component.

A strong answer adds that the HOC rules still apply. Call memo once, at the top level, or state is lost.

What problems do HOCs have, and why did hooks replace most of them?

Three problems. First, layers: each HOC adds a component around yours, and stacking them gives “wrapper hell”. Second, prop collisions: a HOC’s injected prop can silently replace one you passed, or yours can replace it. Third, hidden props: you can’t see where a prop comes from without reading the HOC.

Hooks fix all three. There is no wrapper. The value comes from a call you can see, like const theme = useTheme(). And you pick its name.

A strong answer names more costs. Static properties don’t come along. Before React 19, refs didn’t pass through. A HOC made inside a component loses state on every render. And the TypeScript types are heavy: <P>, Omit and as P.

It also names HOCs still in use. React Redux’s connect(mapState) returns a HOC, though its docs now recommend the hooks API. And withErrorBoundary from react-error-boundary wraps a component in an error boundary, which a hook can’t do.

Sources

  • Higher-Order Components, legacy React docs: the definition, “not part of the React API”, “Don’t Mutate the Original Component”, passing props through as a convention, displayName and the WithSubscription(CommentList) style, “show up in the React Developer Tools”, not using a HOC inside render and the state loss, copying static methods and hoist-non-react-statics, and “does not work for refs”. The page itself says HOCs “are not commonly used in modern React code”.
  • Introducing Hooks, legacy React docs: “wrapper hell” and “React needs a better primitive for sharing stateful logic”.
  • Render Props, legacy React docs: a render prop is “a prop whose value is a function”.
  • First-class Function, MDN: the definition of a higher-order function. Array.prototype.map(), MDN: map calls a function for each item.
  • memo, react.dev: memo “returns a new React component”.
  • forwardRef, react.dev: “In React 19, forwardRef is no longer necessary. Pass ref as a prop instead.”
  • React v19, react.dev blog: ref as a prop for function components.
  • Utility Types, TypeScript docs: Omit.
  • connect(), React Redux docs: connect() returns a function that wraps your component, and is deprecated as of React-Redux 9.3.0 but still works. Hooks, React Redux docs: the hooks API is the recommended default.
  • react-error-boundary 6.1.6 on npm: its code exports withErrorBoundary, which wraps a component in ErrorBoundary with forwardRef and sets displayName to withErrorBoundary(Name).
  • Component, react.dev: “currently no way to write an Error Boundary as a function component”.
  • hoist-non-react-statics README: what the package copies.
  • The logs, the component stacks, the warnings, the ref check, the lost state, the prop collisions and the TypeScript errors above come from running React 19.3.0 and TypeScript 7.0.2 for this post. The forwardRef and “cannot be given refs” results come from React 18.3.1.
  • This part follows the Higher-Order Components kata in react-katas.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.