Kodokon kodokon.com

Patrones expertos: compound components, inversión de control

Diseña APIs de componentes flexibles con los compound components, el state reducer y la elección correcta entre render props y hooks.

10 min · 3 preguntas

Abrir esta lección en Kodokon

Un componente <Tabs items={...} labels={...} icons={...} /> siempre acaba sepultado bajo props de configuración. El patrón de compound components invierte el enfoque: varios componentes colaboran y comparten estado implícito a través de un contexto, mientras el usuario mantiene el control de la composición JSX (orden, anidamiento, elementos intercalados). Es el modelo de <select> y <option> en HTML, y el que usan bibliotecas como Radix o Headless UI.

JSX
const TabsContext = createContext(null);

function Tabs({ defaultTab, children }) {
  const [active, setActive] = useState(defaultTab);
  const value = useMemo(
    () => ({ active, setActive }),
    [active]
  );
  return (
    <TabsContext.Provider value={value}>
      {children}
    </TabsContext.Provider>
  );
}
El padre expone el estado compartido a través de un contexto memoizado.

Cada subcomponente lee el contexto y no necesita ninguna prop de conexión: el usuario compone libremente, y el patrón garantiza la coherencia. Fíjate en el valor del contexto envuelto en useMemo: sin él, cada renderizado del padre crearía un objeto nuevo y forzaría un renderizado de todos los consumidores.

JSX
function Tab({ id, children }) {
  const ctx = useContext(TabsContext);
  return (
    <button
      aria-selected={ctx.active === id}
      onClick={() => ctx.setActive(id)}
    >
      {children}
    </button>
  );
}

// <Tabs defaultTab="a">
//   <Tab id="a">Profile</Tab>
//   <Tab id="b">Settings</Tab>
// </Tabs>
Sin props de conexión: el contexto hace el enlace.

Segundo patrón: la inversión de control, cuya forma más refinada es el state reducer popularizado por Kent C. Dodds en Downshift. En lugar de añadir una prop booleana por cada nueva necesidad, el hook expone sus transiciones de estado: quien llama proporciona un reducer que recibe cada acción y puede dejarla pasar, modificarla o cancelarla. La biblioteca conserva su comportamiento por defecto; el control fino pertenece al consumidor.

JSX
function defaultReducer(state, action) {
  if (action.type === 'toggle') {
    return { on: !state.on };
  }
  return state;
}

function useToggle({ reducer = defaultReducer } = {}) {
  const [state, dispatch] = useReducer(reducer, {
    on: false,
  });
  const toggle = () => dispatch({ type: 'toggle' });
  return { on: state.on, toggle };
}
Quien llama puede interceptar cada transición de estado.

Por último, el eterno debate de render props frente a hooks. Los hooks han ganado para compartir lógica: sin falsa jerarquía en el árbol, sin pirámide de wrappers, con composición natural de varias fuentes. Pero el render prop conserva un territorio legítimo: cuando la variación concierne al propio renderizado. Una lista virtualizada que te pregunta cómo dibujar cada fila, un componente headless que delega todo su JSX: ahí, una función de renderizado pasada como prop sigue siendo la herramienta más directa, porque un hook no puede decidir dónde insertar el JSX en el árbol de quien llama.

JSX
function MouseTracker({ render }) {
  const [pos, setPos] = useState({ x: 0, y: 0 });
  const onMove = (e) =>
    setPos({ x: e.clientX, y: e.clientY });
  return (
    <div onMouseMove={onMove}>{render(pos)}</div>
  );
}

// For the logic alone, a hook is enough:
// const pos = useMousePosition();
Un render prop para delegar el renderizado, un hook para la lógica.

Prueba de conocimientos

Comprueba que has retenido los puntos clave de esta lección.

  1. ¿Qué mecanismo permite a los compound components compartir su estado sin props explícitas?
    • Una variable de módulo compartida
    • Un contexto React provisto por el componente padre
    • Duplicar el estado en cada hijo
    • Refs pasadas hacia abajo mediante cloneElement
  2. ¿Qué invierte exactamente el patrón state reducer?
    • La dirección del flujo de datos entre padre e hijo
    • El control sobre las transiciones de estado, cedido a quien llama
    • El orden de ejecución de los hooks
    • La responsabilidad de renderizar el fallback
  3. ¿En qué caso un render prop sigue siendo preferible a un hook?
    • Cuando quieres compartir lógica entre componentes
    • Cuando quien llama debe proporcionar el JSX a insertar en el árbol del componente
    • Cuando necesitas memoizar un cálculo costoso
    • Cuando el componente no tiene estado