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);
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)
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>
)
}
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>
)
}
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'
)}
/>
);
}
);
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;
}
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
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);
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);
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>
);
}
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)