TypeScript con React avanzado: Context tipado, generics en componentes y compound components

Una vez dominados los tipos básicos de React, el siguiente nivel es tipar patrones de composición complejos: contextos con valores nulos seguros, componentes que aceptan parámetros de tipo genéricos, compound components que comparten estado interno sin exponerlo al exterior y componentes polimórficos cuya etiqueta HTML se puede cambiar con una prop. Estos patrones aparecen en librerías de UI como Radix, HeadlessUI o Shadcn, y entenderlos permite tanto usarlos mejor como replicarlos en código propio.

Context tipado con type guards

El patrón más seguro para un contexto de React es crear un hook personalizado que lanza un error si se usa fuera del proveedor:

interface AuthContextTipo {
  usuario: { id: number; email: string; rol: string };
  cerrarSesion(): void;
  actualizarPerfil(datos: Partial<{ email: string }>): Promise<void>;
}

const AuthContext = React.createContext<AuthContextTipo | null>(null);

export function AuthProvider({ children }: React.PropsWithChildren) {
  const [usuario, setUsuario] = React.useState<AuthContextTipo["usuario"]>(
    { id: 1, email: "[email protected]", rol: "admin" }
  );

  const valor: AuthContextTipo = {
    usuario,
    cerrarSesion: () => { /* ... */ },
    actualizarPerfil: async (datos) => { setUsuario(u => ({ ...u, ...datos })); },
  };

  return <AuthContext.Provider value={valor}>{children}</AuthContext.Provider>;
}

// Hook con type guard integrado:
export function useAuth(): AuthContextTipo {
  const ctx = React.useContext(AuthContext);
  if (ctx === null) {
    throw new Error("useAuth debe usarse dentro de <AuthProvider>");
  }
  return ctx;
}

// Uso:
function Perfil() {
  const { usuario, cerrarSesion } = useAuth(); // siempre tipado, nunca null
  return <div>{usuario.email}</div>;
}

Componentes genéricos

Un componente genérico acepta parámetros de tipo que se determinan en el punto de uso. En JSX la sintaxis es <Componente<Tipo> /> o se infiere del valor de las props:

interface ListaProps<T> {
  items: T[];
  renderItem: (item: T, indice: number) => React.ReactNode;
  clave: keyof T;
}

function Lista<T extends { [K in keyof T]: unknown }>({
  items,
  renderItem,
  clave,
}: ListaProps<T>) {
  return (
    <ul>
      {items.map((item, i) => (
        <li key={String(item[clave])}>{renderItem(item, i)}</li>
      ))}
    </ul>
  );
}

// TypeScript infiere T = Producto:
<Lista
  items={productos}
  clave="id"
  renderItem={(p) => <span>{p.nombre}: {p.precio}€</span>}
/>

Compound components con contexto privado

Los compound components son componentes que funcionan juntos y comparten estado interno a través de un contexto, pero sin exponer ese contexto al exterior:

interface TabsContextTipo {
  activo: string;
  setActivo: (id: string) => void;
}

const TabsContext = React.createContext<TabsContextTipo | null>(null);

function useTabsContext() {
  const ctx = React.useContext(TabsContext);
  if (!ctx) throw new Error("Debe estar dentro de <Tabs>");
  return ctx;
}

function Tabs({ children, defaultTab }: React.PropsWithChildren<{ defaultTab: string }>) {
  const [activo, setActivo] = React.useState(defaultTab);
  return (
    <TabsContext.Provider value={{ activo, setActivo }}>
      <div className="tabs">{children}</div>
    </TabsContext.Provider>
  );
}

function Tab({ id, children }: React.PropsWithChildren<{ id: string }>) {
  const { activo, setActivo } = useTabsContext();
  return (
    <button
      className={activo === id ? "activo" : ""}
      onClick={() => setActivo(id)}
    >{children}</button>
  );
}

function TabPanel({ id, children }: React.PropsWithChildren<{ id: string }>) {
  const { activo } = useTabsContext();
  if (activo !== id) return null;
  return <div>{children}</div>;
}

// API de uso limpia:
<Tabs defaultTab="inicio">
  <Tab id="inicio">Inicio</Tab>
  <Tab id="perfil">Perfil</Tab>
  <TabPanel id="inicio">Contenido inicio</TabPanel>
  <TabPanel id="perfil">Contenido perfil</TabPanel>
</Tabs>

Componentes polimórficos con la prop as

// Tipo auxiliar para componentes polimórficos:
type PropsMas<E extends React.ElementType, P> =
  P & Omit<React.ComponentPropsWithRef<E>, keyof P> & { as?: E };

interface TextoBaseProps {
  size?: "sm" | "md" | "lg";
  color?: "gris" | "azul" | "rojo";
}

function Texto<E extends React.ElementType = "p">({
  as,
  size = "md",
  color = "gris",
  ...resto
}: PropsMas<E, TextoBaseProps>) {
  const Etiqueta = (as ?? "p") as React.ElementType;
  return <Etiqueta className={`texto-${size} color-${color}`} {...resto} />;
}

// Uso:
<Texto>Párrafo por defecto</Texto>
<Texto as="h1" size="lg">Título</Texto>
<Texto as="a" href="/perfil" color="azul">Enlace</Texto>
// TypeScript autocompletará las props del elemento que indiques en as

Estos patrones avanzados son lo que separa una biblioteca de componentes bien tipada de una colección de componentes con any dispersos. Cuando el sistema de tipos entiende la composición del componente, el desarrollador que lo usa recibe autocompletado correcto, errores en tiempo de desarrollo y ningún as unknown as T en el código.

Imagen: Pexels / Pixabay

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP