Back

RIP Higher Order Components, You Were a Headache I Won't Miss

19 min read

Requires understanding of Typescript generics

Need of Abstraction

If you have worked on larger codebases, you tend to find some common patterns in your code. These patterns can be anything from fetching data from an API, handling loading states, error states, injecting props, etc. That’s where you decide to abstract these patterns into reusable components or functions. Abstraction can be of any form such as React Hooks, Higher Order Components, Wrapper functions, Tailwind layers/components etc.

/**
 * Example of a Tailwind layer that can be used to abstract common styles
 */
@layer components {
  .btn-blue {
    @apply bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded;
  }
}

I will be talking about two different approaches to abstracting out a redundant code. One is the traditional Higher Order Components (HOC) and the other is the newer one leveraging react’s owner-child component relationship.

What are we going to abstract out?

At my job, I deal with complex Next.js applications, and I frequently encounter situations where I need to write the same code repeatedly. During multiple migrations from the pages directory to the app directory in Next.js, I had several chances to refactor and abstract out redundant code.

One such example, parsing the query and url params that are injected into the Page component at a route level to make UI decisions before rendering anything.

// pages/blog/[slug].tsx
const Home = ({params}: {params: string | string | string[]}) => {
  return (
    <div>
      <SomeComponent params={params} />
    </div>
  )
}

export default Home;

While Next.js offers some type helpers, they are often insufficient for more complex needs. For instance, in my work with building search engines related to intellectual properties within Next.js, I found that simply passing params to components wasn’t enough. I needed to develop a parser layer to handle and structure query parameters more effectively. This was crucial because decisions about the UI—such as displaying trademark or copyright information specific to a country—often hinge on these parameters. This pattern of redundancy appeared across multiple routes in the codebase, making it an ideal candidate for abstraction. By applying one of the approaches I mentioned earlier, I could eliminate the repeated code and improve the overall maintainability of the application.

This may seem like overkill to most of you, but believe me, it solved a lot of intricate issues and streamlined the functionality in ways we wouldn’t expect. It took us a considerable amount of time to reach the decision to refactor our codebase to adopt and embrace this solution. This particular example is not important, the approach to tackle this problem is what I have written this blog post about.

I will be using two libraries to parse the query params and url params. one is Zod and other is Tempeh.

  • Zod is a TypeScript-first schema declaration and validation library, this will be our schema provider.
  • Tempeh is a declarative route builder for Next.js that integrates with Zod. It provides schema-validated type safety for the Next.js router object, ensuring type safety both at runtime and compile time.
  • Tempeh provides helpers to get the query params and url params in a structured way in client components but we need a way to parse these params at the route level and pass as props to the children of the page that are server components - that is what our generic abstracted component is going to do.

Problem Statement

We are going to build a marketing page for an imaginary native application from where user can download the app. The page will take a url param - device type such as android or ios and a query params related to tracking and analytics. - utm_source, utm_campaign, utm_medium etc. We need a way to parse these params at the route level and pass as props to the children of the page by creating a layer of abstraction.

In a Next.js app, we will install our dependencies:

pnpm i zod@latest tempeh@4.0.3

This is how our Next.js app looks like

├── app
|  ├── layout.tsx
|  ├── page.tsx
|  ├── download
|  |  ├── [devices]
|  |  |  ├── page.tsx
|  |  |  ├── route.info.ts
|  |
├── package.json
├── ts.config.json
└── next.config.mjs
└── route.config.ts

This is a normal Next.js app structure, except for route.config.ts and route.info.ts files. These files are used by Tempeh to define the routes and their schema. I will come to this later.

Defining our Routes

To use tempeh, first we will instantiate a new instance of Tempeh and define our routes in route.config.ts file.

import { routeBuilder } from 'tempeh';
import { z } from 'zod';

// instantiate a new instance of routeBuilder
const { createRoute } = routeBuilder.getInstance({
  additionalBaseUrls: {
    EXAMPLE: 'https://example.com',
  },
  defaultBaseUrl: '/',
  formattedValidationErrors: true,
});

// Common Schema that will be used in the route
// Params Schema
export const paramsSchema = z.object({
  device: z.enum(['mac', 'windows', 'ios', 'android']),
});

// SearchParams Schema
export const searchParamsSchema = z.object({
  utm_source: z.string().default('direct'),
  utm_medium: z.string().default('organic'),
  utm_campaign: z.string().default('none'),
  utm_term: z.string().optional(),
  utm_content: z.string().optional(),
  campaign_id: z.string().uuid().optional(),
  click_timestamp: z.date().default(() => new Date()),
});
  • We have initialized a new instance of routeBuilder and defined our routes. This is a singleton instance and you can use it anywhere in your app. It takes certain options but all of them are optional and out of scope for this article.
  • We have also defined two schemas - paramsSchema and searchParamsSchema that will be used in our routes.

Now in the route.info.ts file, we will define our routes. We will use defined schemas for the validation layer for our routes.

import createRoute, { paramsSchema, searchParamsSchema } from '@/route.config';

const DownloadPageRoute = createRoute({
  name: 'download-page',
  fn: ({ device }) => `download/${device}`,
  searchParamsSchema: searchParamsSchema,
  paramsSchema: paramsSchema,
});

export default DownloadPageRoute;

First Approach - Higher Order Components (HOC)

It is a very legacy way of abstracting out the redundant code. It is a pattern that is used in React to reuse component logic. It is a function that takes a component and returns a new component with some additional props. This is a very rare pattern these days as React hooks introduced better ways to reuse component logic. Honestly, if you are someone who is still using HOCs, You love the pain, don’t you?

We are probably not going to use this approach in newer codebase but most of us have to work on legacy codebases where HOCs are still being used. So this guide will help you understand how to use HOCs to abstract out the redundant code, especially in Typescript.

  • HOCs are named with a prefix with followed by the noun for the logic you are encapsulating. for example, to add logging to a component, you might create a withLogging HOC.
  • HOCs are of two concerns - enhancements and injections, Enhancements are the HOCs that add no new props to the component, they just enhance the existing component such as logging or loading. Injections are the HOCs that add new props to the component (our use case.
  • HOCs follow currying. If you call your piece of function HOC without following the currying pattern, those functional bros are gonna haunt you in your dreams. For those unfamiliar with currying, it’s a technique that transforms a function with multiple arguments into a sequence of functions, each taking a single argument. Essentially, you pass one argument at a time to the function, and it returns a new function that expects the next argument.

Let’s create a HOC that will parse the query params and url params and pass them as props to the children of the page.

// lib/hoc.tsx
import { type ComponentType } from 'react';
import { RouteConfig } from 'tempeh';
import { ZodSchema } from 'zod';

// type of the props that will be injected by the HOC
// received from the page.
export type RouteProps = {
  params: unknown;
  searchParams: unknown;
};

export const withTypedParams =
  <TParams extends ZodSchema, TSearchParams extends ZodSchema>(
    routeInfo: RouteConfig<TParams, TSearchParams>
  ) =>
  ({ params, searchParams }: RouteProps) =>
  <TProps extends object>(
    MyComponent: React.ComponentType<
      TProps & {
        parsedParams: typeof routeInfo.params;
        parsedSearchParams: typeof routeInfo.searchParams;
      }
    >
  ) => {
    const parsed = routeInfo.parseParams(params);
    const parsedSearchParams = routeInfo.parseSearchParams(searchParams);

    const ComponentWithParsedInfo: ComponentType<TProps> = (rest: TProps) => {
      return (
        <MyComponent
          {...rest}
          parsedSearchParams={parsedSearchParams}
          parsedParams={parsed}
        />
      );
    };

    ComponentWithParsedInfo.displayName = `withTypedRoutes(${
      MyComponent.displayName || MyComponent.name || 'Component'
    })`;

    return ComponentWithParsedInfo;
  };

Let’s break down the code:

  • At first, we take routeInfo as an argument which is the route configuration object that we defined in route.info.ts file. It takes two generic arguments - TParams and TSearchParams which are the types of the params and searchParams respectively.
  • We return a function that takes an object with params and searchParams as arguments.These are the values that we will parse with routeInfo and then inject into to the Component.
  • Next argument to the function is the component that we want to inject the parsed values into. It takes a generic argument TProps which is the type of the props that the component is expecting. We extend the props of the component with two new props - parsedParams and parsedSearchParams which are the parsed values of the params and searchParams.
  • Finally we return a new component that takes the rest of the props and passes them to the original component along with the parsed values of the params and searchParams.

We have a created a chain of functions that takes the route configuration object, parses the params and searchParams and injects them into the component. This is a very powerful pattern and can be used to abstract out the redundant code in your application.

While working with HOCs, do not forget to use displayName property of the component.Forgetting this can turn debugging your component tree in React DevTools into a nightmarish ordeal.

Using our HOC

import { RouteProps, withTypedParams } from '@/lib/hoc';
import DownloadPageRouteInfo from './route.info';

export default function Page({ params, searchParams }: RouteProps) {
  return withTypedParams(DownloadPageRouteInfo)({ params, searchParams })(
    ({ parsedParams, parsedSearchParams }) => {
      return (
        <div>
          <main className="flex min-h-screen flex-col items-center justify-between p-24">
            <h1 className="text-4xl">Download Page HOC</h1>
            <div className="py-12">
              <h2 className="text-2xl">Params</h2>
              <pre>{JSON.stringify(parsedParams, null, 2)}</pre>
            </div>
            <div className="py-12">
              <h2 className="text-2xl">Search Params</h2>
              <pre>{JSON.stringify(parsedSearchParams, null, 2)}</pre>
            </div>
          </main>
        </div>
      );
    }
  )({});
}

We have used our HOC to parse the params and searchParams and inject them into the component. And one by one -

  • we have passed the route configuration object to the HOC.
  • we have passed the params and searchParams to the HOC.
  • we have passed the component to the HOC.
  • we have passed the rest of the props to the component. (empty object in this case)

Now the component is free from the parsing logic and we can reuse the HOC in other page routes as well. All we need is to change the route configuration object that we pass to the HOC and pass the params and searchParams to the HOC. We also get full type safety with Typescript -

type safe params with tempeh

Problems with HOCs

  • We need to add the displayName property to the component to make it easier to debug in the React DevTools.
  • HOCs can be hard to debug and understand. They can make the codebase harder to understand.
  • Typescript interfaces can be hard to manage with HOCs. Someone with less experience with Typescript can find it hard to understand the types of the props that are being passed to the component.
  • Someone who does not like functional programming can not like HOCs. They can be hard to understand for someone who is not familiar with functional programming concepts such as currying and composition.

Luckily, they are outdated. They were like the coolest thing in the React world a few years ago but now we have better alternatives like hooks and owner components.

The Owner component pattern

You may wonder, scratch your head and ask yourself - “What is the owner component pattern?“. And it’s funny. See, The react is all about that parent-child relationship. except for sometimes, it’s not. There is one more type of component family that is called owner components.

Owner Components are wrappers that are responsible for managing the state and behavior of the children components. They are the ones that are responsible for the data fetching, state management, and other side effects. In simpler terms, if your parent component can pass the props to the child component, then that parent component owns the child component. This is very crucial in understanding the modern RSC (React State Component) world of React where ContextProviders that accepts children can take server compoents as children.

  • Owner components are responsible for managing the state and behavior of the children components.
  • When owner component rerenders, all the children components rerender as well.
  • Context Providers in RSC are parent components that accept children components as props but do not enject any props into the children components. They are owner components. That’s why Server Components can be children of Context Providers that are client components.

Example of a owner component:

import React, { useState, useEffect } from 'react';
import SomeComponent from './SomeComponent';

const Counter = () => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const intervalId = setInterval(() => {
      setCount(prevCount => prevCount + 1);
    }, 1000);

    return () => clearInterval(intervalId);
  }, []);

  return (
    <div>
      <SomeComponent count={count} />
      <h1>Counter: {count}</h1>
    </div>
  );
};

Everytime, the count changes, it will rerender the Counter component, and because of that, all its children will also get rerendered. The Counter owns a logic and they can pass this state directly to the SomeComponent, thus making this relationship a Owner-Child Relationship

We will use the owner component pattern to refactor our HOC to create abstraction. Unlike a parent component, we want to inject parsed params and searchParams directly to the children of the component.

Our Beautiful and Generic Owner Component

// @lib/safe-params-layout.tsx
import { RouteConfig } from 'tempeh';
import { ZodSchema } from 'zod';

export const SafeParamsLayout = <T extends ZodSchema, U extends ZodSchema>({
  routeInfo,
  children,
  searchParams,
  params,
}: {
  routeInfo: RouteConfig<T, U>;
  children: (props: {
    parsedParams: RouteConfig<T, U>['params'];
    parsedSearchParams: RouteConfig<T, U>['searchParams'];
  }) => JSX.Element | Promise<JSX.Element>;
  searchParams?: unknown;
  params: unknown;
}) => {
  const parsedParams = routeInfo.parseParams(params);
  const parsedSearchParams = routeInfo.parseSearchParams(searchParams);

  return children({
    parsedParams,
    parsedSearchParams,
  });
};

I have named this file safe-params-layout.tsx because honestly, I could not come up with a better name. But since it is kind of a wrapper, I have named it layout. You can name it whatever you want. There is no official name for this pattern unlike the HOCs.

  • We have created a new component called SafeParamsLayout that takes the route configuration object, children, searchParams, and params as props.
  • Here, we have defined the type of params and searchParams as unknown. This is because we do not know the type of the params and searchParams that the component is expecting. We will pass the parsed values of the params and searchParams to the children component.
  • the parsed values of the params and searchParams are derived using the parseParams and parseSearchParams functions of the route configuration object. These functions will validate the values against the schema and if the values are valid, they will return the parsed values. Otherwise, they will throw an error. (You can make it safer by passing the second argument to parseParams and parseSearchParams as true. It will give a discriminated union of the error and the parsed value. You can then check if the value is an error or not and then throw an error or return the parsed value. Again, out of the scope of this article.)
  • Notice, instead of using the JSX syntax, we are using the function syntax to render the children component. This is because we want to pass the parsed values of the params and searchParams to the children component. This way we are no longer concering with what the children component is doing with the parsed values. We are just passing the parsed values to the children component.

Using the SafeParamsLayout component

import { RouteProps } from '@/lib/hoc';
import DownloadPageRouteInfo from './route.info';
import { SafeParamsLayout } from '@/lib/safe-params-layout';

export default function Page({ params, searchParams }: RouteProps) {
  return (
    <SafeParamsLayout
      routeInfo={DownloadPageRouteInfo}
      searchParams={searchParams}
      params={params}
    >
      {({ parsedSearchParams, parsedParams }) => {
        return (
          <div>
            <main className="flex min-h-screen flex-col items-center justify-between p-24">
              <h1 className="text-4xl">Download Page HOC</h1>
              <div className="py-12">
                <h2 className="text-2xl">Params</h2>
                <pre>{JSON.stringify(parsedParams, null, 2)}</pre>
              </div>
              <div className="py-12">
                <h2 className="text-2xl">Search Params</h2>
                <pre>{JSON.stringify(parsedSearchParams, null, 2)}</pre>
              </div>
            </main>
          </div>
        );
      }}
    </SafeParamsLayout>
  );
}

This syntax is much cleaner and easier to understand. We are no longer concerned with the implementation details of the children component. We are just passing the parsed values of the params and searchParams to the children component. This makes the code much easier to understand and maintain.

  • We are using the SafeParamsLayout component to wrap the children component. We are passing the route configuration object, searchParams, and params as props to the SafeParamsLayout component.
  • We are using the function syntax to render the children component. We are passing the parsed values of the params and searchParams to the children component. This way we are no longer concerned with what the children component is doing with the parsed values. We are just passing the parsed values to the children component.

Avoid Premature Reusability

While these solutions are great, you are not going to know when to use them unless you understand the problem. People often try to create abstractions before knowing the exact requirements. This leads to a lot of unnecessary complexity, unmaintainable architecture. This is also known as premature abstraction and you may as well know - premature abstraction is the root of all evil.

Duplication stands for the idea of writing the same code in multiple places. In software engineering, we have this notion of DRY (Don’t Repeat Yourself) which is a principle of software development aimed at reducing repetition of software patterns, replacing it with abstractions or using data normalization to avoid redundancy. However, It is also misunderstood a lot.

For example, Look at this code below, we have created a complex function that gets a coupon code for a user based on user details -


// Step 1: Define user parameters
// - We assume the user has properties like id, purchaseHistory, and loyaltyPoints

// Step 2: Determine user eligibility
// - Check if the user has made at least 3 purchases in the last 30 days
// - Verify if the user has more than 100 loyalty points

// Step 3: Generate base coupon code
// - Create a random string of 8 characters (letters and numbers)

// Step 4: Calculate discount percentage
// - Start with a base discount of 5%
// - Add 1% for every 50 loyalty points (up to a maximum of 5% additional)
// - Add 2% if the user has made more than 5 purchases in the last 30 days

// Step 5: Determine coupon expiration
// - Set expiration date to 14 days from current date

// Step 6: Create final coupon object
// - Combine coupon code, discount percentage, and expiration date

// Step 7: Store coupon in database
// - Save the coupon object associated with the user's ID

// Step 8: Return coupon to user
// - Send back the coupon code and relevant information


 async function getCouponForUser(userId) {
   const user = getUserById(userId);

   if (isEligibleForCoupon(user)) {
     const baseCode = generateRandomCode();
     const discountPercentage = calculateDiscount(user);
     const expirationDate = calculateExpirationDate();

     const coupon = createCouponObject(baseCode, discountPercentage, expirationDate);

     storeCouponInDatabase(userId, coupon);

     return coupon.code;
   } else {
     return null;
   }
 }

This function might seem like a solid approach to handling coupon codes, but it doesn’t follow the DRY (Don’t Repeat Yourself) principle. The real issue isn’t with the code but with the comments. By having both code and comments as separate sources of information, we create redundancy. This is a classic case of “code smell,” a term from Clean Code practices.

In software engineering, we often aim for knowledge abstraction rather than just code abstraction. This means we should rely on one clear source of truth for the logic behind our code. When code includes comments, any change in the code should also be reflected in the comments. If you forget to update the comments, they can become outdated and misleading. This inconsistency breaks the DRY principle and makes maintaining the code more difficult.

Next time you encounter duplication in your code, avoid jumping straight into refactoring. First, take the time to fully understand the problem. You might not yet grasp all the requirements or the precise nature of the issue. It’s important to avoid creating abstractions before you have a clear understanding of the requirements. Once you’re confident about the problem and requirements, you can apply one of the suggested solutions to eliminate duplication. This will help make your React code more maintainable and easier to understand.

Conclusion

  • Avoid premature reusability. It’s fine to refactor duplicate code and implement knowledge abstraction, but make sure you thoroughly understand all requirements before doing so. Rushing to create abstractions without a complete grasp of the problem can lead to ineffective solutions.
  • Typescript is your ally. It offers powerful tools for creating reusable components and functions. Its type helpers and utilities can significantly enhance the reuse of types, components, and functions across your codebase.
  • Learn modern practices. Avoid introducing new HOCs into your codebase, instead use hooks or the owner component pattern. But if you’re working with an existing codebase that already uses HOCs, it’s best to continue using them to align with your team’s existing practices and mental model.

Always remember, duplications leads to less bugs than abstractions when business requirements get too complex. In almost every situation, your business requirements will always diverge and you will have to make a trade-off between focusing on convering the code or having happy mornings.