December 20, 2020

Un acercamiento sencillo a feature flags en una aplicación React con TypeScript

Aunque ya he escrito en otras ocasiones sobre feature flags y como su uso nos puede ayudar a alcanzar un flujo de entrega continua, en este artículo me gustaría compartir una implementación sencilla en React utilizando TypeScript.

En esencia, una feature flag es un interruptor que nos permite activar o desactivar una funcionalidad concreta bajo una serie de condiciones. Los tipos de condiciones pueden ser muy variados: entorno de ejecución de la aplicación, tipo de usuaria, porcentaje de tráfico, etc. Además, el cálculo de esa condición puede realizarse dentro de la propia aplicación, o bien delegarse en otro sistema.

Este concepto posibilita, entre otras cosas, desacoplar nuestro repositorio del proceso de entrega de software. Siempre y cuando nuestras funcionalidades incompletas estén protegidas bajo una feature flag, podremos integrar continuamente nuestros cambios en la rama principal (aunque eso implique su despliegue a producción o cualquier otro entorno).

Publicando feature flags

El primer paso para comenzar a utilizar feature flags es definirlas en algún sitio. Aunque existan servicios específicamente creados para trabajar con ellas (que, además, ofrecen flujos y procesos mucho más refinados), yo siempre recomiendo comenzar con un enfoque humilde, gestionando las feature flags directamente en la aplicación. En este caso, vamos a crear un módulo que exporte las diferentes feature flags y una función para obtener su estado:

export const Features = {
  newSettingsPage: () => process.env.NODE_ENV === "development",
};

La única razón de asociar una función a la feature flag en lugar de directamente un booleano es dotarle de cierta flexibilidad (realmente, sería incluso más interesante hacer que la función devolviese una promesa -de esa manera, estaríamos cubriendo el caso en el que, para calcular el valor de una feature flag, tuviésemos que realizar una petición a un servicio externo).

Integrando la aplicación React con las feature flags

Ahora que ya tenemos un módulo encargado de publicar las feature flags de la aplicación, es hora de hacerlo accesible desde los componentes React.

El enfoque más directo, sería importar el módulo desde las diferentes páginas o componentes que lo utilicen y realizar después las operaciones para mostrar/ocultar alguna parte de la interfaz. Aunque este enfoque funcionaría estaríamos creando una dependencia no explícita entre nuestros diferentes componentes y un elemento global (el módulo de Features) lo que hará que probar nuestros componentes en cada uno de los estados sea más complejo.

Vamos a verlo con un ejemplo:

import { Features } from "../lib/Features";

export function Menu() {
  return (
    <nav>
      <ul>
        <li>
          <a href="/dashboard">Dashboard</a>
        </li>

        <li>
          <a href="/profile">Profile</a>
        </li>

        {Features.newSettingsPage() && (
          <li>
            <a href="/settings">Settings</a>
          </li>
        )}
      </ul>
    </nav>
  );
}

En esta implementación, Menu utiliza directamente el módulo de Features para saber si tiene que mostrar o no la opción de ajustes. A la hora de probar este componente, tenemos que tener en cuenta esta dependencia y utilizar un stub para poder ajustarlo a ambos escenarios:

/**
 * @jest-environment jsdom
 */

import { render, screen } from "@testing-library/react";
import { Features } from "../lib/Features";
import { Menu } from "./Menu";

describe("Menu", () => {
  it("doesn't show Settings if the feature is disabled", async () => {
    render(<Menu />);

    expect(screen.queryByText("Settings")).toBeNull();
  });

  it("shows Settings if the feature is enabled", async () => {
    jest.spyOn(Features, "newSettingsPage").mockImplementationOnce(() => true);

    render(<Menu />);

    expect(screen.queryByText("Settings")).not.toBeNull();
  });
});

Los stubs (o test doubles en general) son recursos muy útiles pero me gusta reservar su uso para situaciones donde no tengo ninguna otra opción más idiomática para probar el sujeto bajo test. En este caso, lo ideal sería convertir esa dependencia no explícita en algo que pudiésemos controlar desde el exterior, utilizando inyección de dependencias. En React, tendríamos varias opciones para conseguirlo. Veamos un par de ellas.

Obtener las feature flags a través de una prop

Por ejemplo, utilizando una prop features para poder recibir el objeto completo de feature flags. El problema con este enfoque es que podría haber muchos componentes intermedios que necesiten propagar esta prop sin necesidad de utilizarla. Este fenómeno se denomina Prop Drilling y, aunque no es malo per se, puede convertirse en algo difícil de gestionar cuando nuestra aplicación crezca en número de componentes.

import { Features } from "../lib/Features";

export function Menu({ features }: { features: typeof Features }) {
  return (
    <nav>
      <ul>
        <li>
          <a href="/dashboard">Dashboard</a>
        </li>

        <li>
          <a href="/profile">Profile</a>
        </li>

        {features.newSettingsPage() && (
          <li>
            <a href="/settings">Settings</a>
          </li>
        )}
      </ul>
    </nav>
  );
}

Ahora que el componente Menu obtiene la información de las feature flags a través de una prop, podemos ajustar los diferentes escenarios para las pruebas sin necesidad de utilizar test doubles:

/**
 * @jest-environment jsdom
 */

import { render, screen } from "@testing-library/react";
import { Menu } from "./Menu";

describe("Menu", () => {
  it("doesn't show Settings if the feature is disabled", async () => {
    render(<Menu features={{ newSettingsPage: () => false }} />);

    expect(screen.queryByText("Settings")).toBeNull();
  });

  it("shows Settings if the feature is enabled", async () => {
    render(<Menu features={{ newSettingsPage: () => true }} />);

    expect(screen.queryByText("Settings")).not.toBeNull();
  });
});

Obtener las feature flags a través de la API de contextos

Bien utilizada, la API de contextos de React puede servir como un contenedor de inyección de dependencias que permita aislar a los componentes padre de las dependencias concretas de sus componentes hijo. En este caso, vamos a crear un nuevo contexto que publique las feature flags disponibles dentro de la aplicación y que permita acceder a ellas a través de un hook concreto, useFeatures.

Lo primero, es definir el propio contexto encargado de devolver las feature flags existentes. Junto a él, también definiremos el hook que se utilizará después desde nuestros componentes. Este último es una simple capa de abstracción que centraliza la lógica común de acceso.

import { createContext, useContext } from "react";

export const Features = {
  newSettingsPage: () => process.env.NODE_ENV === "development",
};

export const FeaturesContext = createContext(Features);

export function useFeatures() {
  return useContext(FeaturesContext);
}

Los contextos en React se crean con un valor por defecto. En caso de que no exista ningún proveedor que sobrescriba el valor para el contexto, nuestros componentes recibirán ese valor al acceder a él. En este caso, hemos creado el contexto utilizando como valor por defecto el propio objeto exportado por Features.

import { useFeatures } from "../lib/Features";

export function Menu() {
  const features = useFeatures();

  return (
    <nav>
      <ul>
        <li>
          <a href="/dashboard">Dashboard</a>
        </li>

        <li>
          <a href="/profile">Profile</a>
        </li>

        {features.newSettingsPage() && (
          <li>
            <a href="/settings">Settings</a>
          </li>
        )}
      </ul>
    </nav>
  );
}

Ahora que nuestro componente Menu utiliza el nuevo contexto, sólo necesitamos envolver el componente en diferentes proveedores cuando queramos probar distintos escenarios:

/**
 * @jest-environment jsdom
 */

import { render, screen } from "@testing-library/react";
import { FeaturesContext } from "../lib/Features";
import { Menu } from "./Menu";

describe("Menu", () => {
  it("doesn't show Settings if the feature is disabled", async () => {
    render(
      <FeaturesContext.Provider value={{ newSettingsPage: () => false }}>
        <Menu />
      </FeaturesContext.Provider>,
    );

    expect(screen.queryByText("Settings")).toBeNull();
  });

  it("shows Settings if the feature is enabled", async () => {
    render(
      <FeaturesContext.Provider value={{ newSettingsPage: () => true }}>
        <Menu />
      </FeaturesContext.Provider>,
    );

    expect(screen.queryByText("Settings")).not.toBeNull();
  });
});

<FeatureFlag />, un componente para abstraernos por completo

Si utilizamos la API de contextos, podemos ir todavía un paso más allá y abstraernos por completo de la gestión de funcionalidades disponibles. En este caso, vamos a crear un componente FeatureFlag que recibirá la flag sobre la que queremos trabajar y los nodos que queremos ocultar en caso de no estar disponible:

import { ReactNode } from "react";
import { Features, useFeatures } from "../lib/Features";

type FeatureFlagProps = { children: ReactNode; flag: keyof typeof Features };

export function FeatureFlag({ children, flag }: FeatureFlagProps) {
  const features = useFeatures();

  if (features[flag]()) {
    return <>{children}</>;
  }

  return null;
}

Al definir la prop de flag como keyof typeof Features estamos tomando ventaja de la ayuda del compilador de TypeScript, que nos dirá automáticamente que funcionalidades existen y evitará que utilicemos flags que no se encuentran en el objeto global de Features.

import { FeatureFlag } from "./FeatureFlag";

export function Menu() {
  return (
    <nav>
      <ul>
        <li>
          <a href="/dashboard">Dashboard</a>
        </li>

        <li>
          <a href="/profile">Profile</a>
        </li>

        <FeatureFlag flag="newSettingsPage">
          <li>
            <a href="/settings">Settings</a>
          </li>
        </FeatureFlag>
      </ul>
    </nav>
  );
}

Con este último enfoque, nuestras pruebas anteriores utilizando el proveedor del contexto de feature flags seguirán funcionando (ya que es el mecanismo sobre el que se construye todo) a la vez que tomamos ventaja de un diseño mucho más idiomático, encapsulando la gestión de feature flags en su propio componente React.