DEV Community

Cover image for React Higher-Order Components (HOCs) in a Hooks-First World
Ward Khaddour
Ward Khaddour

Posted on

React Higher-Order Components (HOCs) in a Hooks-First World

Sharing logic between React components is no longer about how to reuse logic, but where it belongs — hooks, utilities, render props, and higher-order components all exist — but a question of where each abstraction actually fits.

In modern React, hooks dominate the reuse of logic. Yet some problems remain awkward to solve with hooks alone — especially when logic needs to wrap, adapt, or standardize behavior across heterogeneous components, including third-party ones.

In this article, we will examine the use cases, advantages, and disadvantages of HOCs.

What a Higher-Order Component Really Is

Higher-order components provide a clean way to extend a component with additional behaviour, without changing its implementation or ownership.

The extra logic could be:

  • Analytics: track how many times a button has been clicked.

  • Authentication, or authorization gates: prevent rendering a component based on authentication status or the current user's permissions.

  • Third-party library integration: adapt a third-party component to your application interface.

At its core, an HOC is a pure function that takes a component and returns a new component with additional behavior:

Rather than modifying the original component, the HOC wraps it, injecting props, behavior, or side effects at the boundary.

HOCs do not change the component itself — they wrap and enhance it.

const EnhancedComponent = withExtraLogic(BaseComponent);
Enter fullscreen mode Exit fullscreen mode

This distinction is important because it defines where responsibility lives in your system.

By convention, HOC names start with with.
While hooks have become popular for logic reuse, HOCs still shine when you want to enhance multiple components without altering their internals.

HOCs keep logic around a component, while Hooks pull logic into a component.

HOCs as Design Patterns: Decorator vs Adapter

Higher-Order Components are not a design pattern themselves.
They are a mechanism that can implement different patterns depending on intent. Recognizing which pattern you are applying helps prevent accidental coupling and misuse of abstraction.

HOCs as a Decorator

An HOC behaves like a Decorator when it:

  • Preserves the component’s public interface

  • Adds behavior without changing how the component is consumed

  • Enhances responsibility rather than translating it

Examples:

  • Injecting analytics

  • Adding authorization checks

  • Enhancing styling or theming

  • Adding error boundaries

In these cases, the wrapped component remains conceptually the same — it is augmented.

withAuth(Component)
withAnalytics(Component)
withTheme(Component)
Enter fullscreen mode Exit fullscreen mode

This aligns closely with the classic Decorator pattern:
Same interface, more behavior.

HOCs as an Adapter

An HOC behaves like an Adapter when it:

  • Translates between incompatible APIs

  • Bridges a component to an external system

  • Changes how the component communicates with its environment

Exploring an example

Forms sit at the center of most front-end applications and are deceptively complex:

  • Labels and accessibility wiring

  • Validation and error presentation

  • Controlled vs uncontrolled inputs

  • Integration with non-native inputs

Libraries like React Hook Form, Formik, or TanStack Form solve state management — but they also introduce API coupling.

Consider a signup form built with React Hook Form:

'use client'

import { Select } from '@radix-ui/react-select'
import { Controller, useForm } from 'react-hook-form'
import PhoneInput from 'react-phone-number-input'
import 'react-phone-number-input/style.css'

export function SignupForm() {
  const form = useForm({
    defaultValues: {
      name: '',
      email: '',
      phoneNumber: '',
      gender: '',
    },
  })

  const errors = form.formState.errors

  const handleSubmit = form.handleSubmit(console.log)

  return (
    <form onSubmit={handleSubmit}>
      <div className='flex flex-col gap-1'>
        <label htmlFor='name'>Name</label>
        <input id='name' {...form.register('name')} />
        {errors.name ? (
          <small className='text-red-500'>{errors.name.message}</small>
        ) : null}
      </div>
      <div className='flex flex-col gap-1'>
        <label htmlFor='email'>Email</label>
        <input id='email' {...form.register('email')} />
        {errors.email ? (
          <small className='text-red-500'>{errors.email.message}</small>
        ) : null}
      </div>
      <div className='flex flex-col gap-1'>
        <label htmlFor='phoneNumber'>Phone Number</label>
        <Controller
          control={form.control}
          name='phoneNumber'
          render={({ field, fieldState: { error } }) => (
            <>
              <PhoneInput numberInputProps={{ id: 'phoneNumber' }} {...field} />
              {error ? (
                <small className='text-red-500'>{error.message}</small>
              ) : null}
            </>
          )}
        />
      </div>

      <div className='flex flex-col gap-1'>
        <label htmlFor='gender'>Gender</label>
        <Controller
          control={form.control}
          name='gender'
          render={({ field, fieldState: { error } }) => (
            <>
              <Select {...field} options={[...]} />{' '}
              {error ? (
                <small className='text-red-500'>{error.message}</small>
              ) : null}
            </>
          )}
        />
      </div>

      <button type='submit'>Create Account</button>
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Notice how every field repeats the same structure:

  • A label.

  • An input wired to React Hook Form.

  • The only thing that changes is which input component is used.

This is not just a UI problem — it’s a cross-cutting concern:

  • Field registration, using library-specific APIs (register, Controller)

  • Validation feedback

  • Accessibility wiring

This logic is needed by many different components, but it shouldn’t live inside any single one of them.

For a single form, it is okay, but chances are, the app will have a lot of forms, and each will have the same issues.

A common approach is to inject the form library directly into each field component:

import clsx from 'clsx';

type Props = {
  name: string
  label: string
  control: Control
}

function Input({ name, control, label }: Props) {
  const { field, fieldState } = useController({
    name,
    control,
  })

  const { error } = fieldState

  return (
    <div className='flex flex-col gap-1'>
      <label htmlFor={name} className={'...'}>{label}</label>
      <input
        {...field}
        className={clsx(
          'border p-2',
          error ? 'border-red-500' : 'border-gray-400'
        )}
    />
      {error ? (
        <small className='text-red-500'>{error.message}</small>
      ) : null}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

This works — but it comes with a cost.

Every field component becomes:

  • Tightly coupled to React Hook Form

  • Hard to reuse outside forms

  • Costly to migrate if the form library changes

  • The same implementation is needed for other types of inputs: Select , PhoneInput, DatePicker

    A UI component (Input) shouldn’t know about a business logic library (RHF)

Refactoring with Higher-Order Components

Let’s migrate the inputs to

  • Reusable input components.

  • Isolate library-specific logic.

  • A single adapter to integrate inputs with libraries.

    The goal is not to reduce code, but to separate responsibilities cleanly.

Creating a library-agnostic Input component

Defining a base input component that knows nothing about any form library

import clsx from 'clsx';
import { forwardRef } from 'react';

type InputProps = React.InputHTMLAttributes<HTMLInputElement> & {
  error?: { message?: string };
};

export const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ error, ...props }, ref) => {
    return (
      <input
        {...props}
        ref={ref}
        className={clsx(
          'border p-2',
          error ? 'border-red-500' : 'border-gray-400'
        )}
      />
    );
  }
);
Enter fullscreen mode Exit fullscreen mode
  • Simple Input component.

  • Accepts standard input props.

  • Usable inside or outside a form.

  • Has no dependency on any form library.

    In React 19, forwardRef is no longer necessary. Pass ref as a prop instead.
    forwardRef will be deprecated in a future release. See forwardRef

Adapting the Component with a Higher-Order Component

import {
  type Control,
  type ControllerRenderProps,
  type FieldError,
  type FieldPath,
  type FieldValues,
  useController,
} from 'react-hook-form';

type WithRhfProps<TFieldValues extends FieldValues> = {
  name: FieldPath<TFieldValues>;
  control: Control<TFieldValues>;
  rules?: object;
};

export function withReactHookForm<TComponentProps = object>(
  Component: React.ComponentType<
    TComponentProps &
      ControllerRenderProps & {
        error?: FieldError;
      }
  >
) {
  const RhfComponent = function <TFieldValues extends FieldValues>(
    props: TComponentProps & WithRhfProps<TFieldValues>
  ) {
    const { name, control, rules, ...rest } = props;

    const {
      field,
      fieldState: { error },
    } = useController({
      name,
      control,
      rules,
    });

    return (
      <div>
        <Component {...(rest as TComponentProps)} {...field} error={error} />

        {error && (
          <small className="text-red-500 text-sm">{error.message}</small>
        )}
      </div>
    );
  };

  const displayName = `Rhf(${
    Component.displayName ?? Component.name ?? 'Component'
  })`;

  // Better for Debugging and Readability
  RhfComponent.displayName = displayName;

  return RhfComponent;
}
Enter fullscreen mode Exit fullscreen mode

This HOC:

  • Encapsulates all React Hook Form logic

  • Preserves the original component API

  • Acts as an adapter, not a replacement. The wrapped component remains portable and unaware of the form system.

The base component remains isolated from React Hook Form.

While the TypeScript signatures are dense, they are essential for preserving Prop Transparency and IDE autocompletion.

The way this HOC works is simple — its type notation, after omitting some types for simplicity, is:

(Component: React.ComponentType) => (props) => JSX.Element
Enter fullscreen mode Exit fullscreen mode

The withReactHookForm function accepts a Component and returns a new component. The original component is saved to be used in the extended component, thanks to Closures.

Extending The Base Input Without Modifying

export const RhfInput = withReactHookForm(Input);
Enter fullscreen mode Exit fullscreen mode
  • No update is needed for the original component.

  • Create a new RhfInput with withReactHookForm

  • The Input component is now decoupled from React Hook Form.

  • You now have two components, each has its own use case.

  • Can wrap native inputs , custom inputs and third-party UI libraries

  • Migrating to another form's library is contained in a single adapter layer, create withTanstackForm and it’s done!

Integration with a third-party library is simple.

import { withReactHookForm } from './with-react-hook-form';
import TextField from '@mui/material/TextField';

export const RhfTextField = withReactHookForm(TextField);
Enter fullscreen mode Exit fullscreen mode

Notice how form fields now read declaratively, while the integration logic disappears entirely.

Usage in Create Account Form

'use client';

import 'react-phone-number-input/style.css';
import { useForm } from 'react-hook-form';
import { RhfInput } from './components/input';
import { RhfSelect } from './components/select';
import { RhfPhoneInput } from './components/phone-input';

export function SignupForm() {
  const form = useForm({
    defaultValues: {
      name: '',
      email: '',
      phoneNumber: '',
      gender: '',
    },
  });

  const handleSubmit = form.handleSubmit(console.log);

  return (
    <form onSubmit={handleSubmit}>
      <div className="flex flex-col gap-1">
        <label htmlFor="name">Name</label>
        <RhfInput control={form.control} name="name" />
      </div>
      <div className="flex flex-col gap-1">
        <label htmlFor="email">Email</label>
        <RhfInput control={form.control} name="email" />
      </div>
      <div className="flex flex-col gap-1">
        <label htmlFor="phoneNumber">Phone Number</label>
        <RhfPhoneInput control={form.control} name="phoneNumber" />
      </div>

      <div className="flex flex-col gap-1">
        <label htmlFor="gender">Gender</label>
        <RhfSelect
          control={form.control}
          name="gender"
          options={['male', 'female', 'other']}
        />
      </div>

      <button type="submit">Create Account</button>
    </form>
  );
}
Enter fullscreen mode Exit fullscreen mode

The form now reads declaratively, while infrastructure remains hidden.

While state management is now solved, layout concerns (like labels) remain. There are many possible approaches:

  • A separate withLabel HOC for uniform layouts

  • Render props when the layout varies per form

  • Compound components for different layouts and form variations.

    There is no universal solution — what matters is that form state integration is no longer entangled with layout decisions.

When This Pattern Makes Sense — and When It Doesn’t

HOCs might be used when:

  • You need to adapt many components to the same infrastructure

  • You integrate third-party libraries

  • You want to keep the base components portable

Avoid HOCs when:

  • A simple hook suffices

  • Component ownership is local

  • Abstraction adds more indirection than value

  • Debugging stack traces becomes harder

Conclusion

Higher-Order Components are no longer the default abstraction in React — but in scenarios involving cross-cutting concerns, third-party integration, and long-term maintainability, HOCs remain a clean, explicit, powerful tool and preserve long-term flexibility in complex applications.

If this article was useful, consider connecting with me on LinkedIn.

Top comments (0)