Open-source software is a great place to learn. In this series, I will dive into solutions for very specific problems that I've discovered while inspecting code and/or documentation from OSS.

Component Evolution and Challenges

Components evolve over time, and there are moments when the current implementation no longer meets the requirements. Some decisions made in the past may not have panned out as expected. However, you can’t simply refactor the component because other parts of the system rely on the existing implementation. Ideally, you want to develop a new version without forcing users into difficult and time-consuming refactors.

Introducing a New Component

One approach is introducing a new component.

Let's say we have a Label component which needs some big refactors. One option is to rename Label to DeprecatedLabel and make Label the new implementation; another option is to create a new component and name it ImprovedLabel / NewLabel (?). 

In the first scenario, you expect the consumer to change the imports when updating; in the second scenario, the name of the new component looks off and will make it potentially harder when you remove the old implementation.

We can do better.

Opting Out Gracefully with Legacy Flags

In Next.js 13, there was a breaking change in the Link component. You didn't need to add an anchor tag as a child anymore.

The team at Vercel introduced a legacyBehaviour flag to opt out of this new behaviour. Making it very easy to upgrade to Next.js 13. 

Legacy behaviour

<Link href={ `/post/${slug}` } legacyBehaviour>
  <a>
    blog post
  </a>
</Link>

New behaviour

<Link href={ `/post/${slug}` }>
    blog post
</Link>


This technique works best when there are big substantial changes: handlers, manipulating passed children, logic. It can also potentially be automated through a codemod, which would scan your codebase for instances of the old usage and insert the legacyBehaviour flag where necessary, making the upgrade process even more seamless.

A quick search for legacyBehaviour in your codebase will easily highlight the areas that will eventually require refactoring, making it simpler to plan and manage updates.

See the implementation here.

An example:

Let's say we have an Input component that returns a string in its onChange handler; we want to refactor it to return the full event so we can more easily integrate it with an external library.

import { ChangeEventHandler } from "react";

type Props =
  | {
      legacyBehaviour: true;
      onChange: (value: string) => void;
    }
  | {
      legacyBehaviour?: false;
      onChange: ChangeEventHandler<HTMLInputElement>;
    };

function Input({ legacyBehaviour, onChange }: Props) {
  return (
    <input
      onChange={(event) => {
        if (legacyBehaviour) {
          onChange(event.target.value);
          return;
        }
        onChange(event);
      }}
    />
  );
}

export { Input };

By using discriminating union types and "never", the combination of the new and old property is impossible.

If branching becomes too complex, you can create 2 components and return the correct one accordingly. It makes it easier to clean up when you remove the old behaviour.

Property Renaming: Using Fallbacks to Smoothen Transition

When you only need to rename properties, you can go for a different approach. Read the value of the new property first with a fallback to the old one. Marking them as deprecated strikes through the properties in your IDE and warns the user with a message.

type Props = (
  | {
      onClick: () => void;
      /**
       * @deprecated onPress will be removed in the next major version, use onClick instead
       */
      onPress?: never;
    }
  | {
      onClick?: never;
      /**
       * @deprecated onPress will be removed in the next major version, use onClick instead
       */
      onPress: () => void;
    }
) &
  (
    | {
        children: string;
        /**
         * @deprecated label will be removed in the next major version, pass the value as children instead
         */
        label?: never;
      }
    | {
        children?: never;
        /**
         * @deprecated label will be removed in the next major version, pass the value as children instead
         */
        label: string;
      }
  );

function Button(props: Props) {
  return (
    <button onClick={props.onClick ?? props.onPress}>
      {props.children ?? props.label}
    </button>
  );
}

export { Button };

While temporary solutions like the legacyBehaviour flag are valuable for easing transitions, it’s crucial to keep track of these workarounds. Relying on legacy behavior for too long can lead to technical debt and slow down future improvements. To avoid this, teams should establish a clear plan to phase out these temporary measures. Regularly reviewing the codebase, setting deadlines for removal, and gradually refactoring can help ensure a smooth transition to the new implementation without relying indefinitely on outdated patterns.