Componentes Controlados y No Controlados en React: Hook useControllable

Componentes Controlados y No Controlados en React: Hook useControllable

En el desarrollo con React, los componentes pueden clasificarse como controlados o no controlados basándose en cómo gestionan su estado. Los componentes controlados dependen de las props pasadas desde un componente padre para gestionar su estado, mientras que los componentes no controlados mantienen su propio estado interno.

Este no es otro post sobre componentes controlados vs no controlados, ya existen muchos de ellos. Por ejemplo, puedes consultar la documentación oficial de React sobre componentes controlados y no controlados.

Este post presenta un hook personalizado llamado useControllable que simplifica la creación de componentes que pueden funcionar tanto en modo controlado como no controlado.

¿Por qué usar ambos modos, controlado y no controlado, en el mismo componente?

Imagina el típico componente select. En la mayoría de los casos, querrás usarlo de forma controlada, pasando el value seleccionado y un manejador onChange desde el componente padre.

Un componente simple como este no tiene mucho sentido como no controlado, pero imagina el mismo componente que incluye un searchbox para filtrar las opciones en el select. En este caso, puedes controlar el searchbox en el padre para estar al tanto de lo que el usuario está escribiendo, pero también podrías querer simplemente dejar que el select gestione su propio estado, ya que el filtrado se realiza dentro del componente select sin necesidad de controlar el estado en el padre.

En un caso como ese (queremos que el searchbox sea opcionalmente controlado, pero si no, que mantenga el estado interno), siempre podríamos tener un estado interno para el searchbox, pero reaccionando a los cambios en el padre cuando se proporciona la prop value y emitiendo los cambios al padre cuando el usuario escribe en el searchbox.

export const MySelect({search, onChangeSearch}: {search?: string; onChangeSearch?: (newSearch: string) => void}) {
  const [internalSearch, setInternalSearch] = React.useState(search ?? '');


  // Updates the internal state when the `search` prop changes
  useEffect(() => {
    if (search !== undefined) {
      setInternalSearch(search);
    }
  }, [search]);

  // Updates the parent when the internal state changes
  useEffect(() => {
    if (onChangeSearch) {
      onChangeSearch(internalSearch);
    }
  }, [internalSearch, onChangeSearch]);
  

  const handleSearchChange = (newSearch: string) => {
    setInternalSearch(newSearch);
  };

  return (
    <div>
      <input
        type="text"
        value={effectiveSearch}
        onChange={(e) => handleSearchChange(e.target.value)}
      />
      {/* Render select options based on effectiveSearch */}
    </div>
  );
}

Esta solución funciona, pero añade una complejidad extra al componente, con dos hooks useEffect para sincronizar el estado interno con las props, y si el valor no es un tipo primitivo (string, number, boolean) necesitarás añadir lógica extra para evitar bucles infinitos cuando el padre proporciona un nuevo objeto/array con el mismo contenido pero diferente referencia.

Un enfoque mejor es usar solo el estado interno cuando las props value y onChange no se proporcionan, y usar las props directamente cuando sí se proporcionan. De esta manera evitamos la necesidad de sincronizar los estados.

export const MySelect({search, onChangeSearch, defaultSearch}: {search?: string; onChangeSearch?: (newSearch: string) => void, defaultSearch?: string}) {
  const [internalSearch, setInternalSearch] = React.useState(defaultSearch ?? '');

  const effectiveSearch = (search !== undefined && onChangeSearch !== undefined) ? search : internalSearch;

  const handleSearchChange = (newSearch: string) => {
    if (onChangeSearch) {
      onChangeSearch(newSearch);
    } else {
      setInternalSearch(newSearch);
    }
  };

  return (
    <div>
      <input
        type="text"
        value={effectiveSearch}
        onChange={(e) => handleSearchChange(e.target.value)}
      />
      {/* Render select options based on effectiveSearch */}
    </div>
  );
}

En esta versión, determinamos el valor de effectiveSearch basándonos en si se proporcionan las props search y onChangeSearch. Si es así, usamos los valores de las props; de lo contrario, recurrimos al estado interno. La función handleSearchChange también comprueba si se proporciona la prop onChangeSearch antes de decidir si llamarla o actualizar el estado interno.

Ten en cuenta que también añadimos una prop defaultSearch para inicializar el estado interno cuando el componente se usa en modo no controlado.

El hook useControllable

Para simplificar la implementación de componentes que pueden funcionar tanto en modo controlado como no controlado, creé la librería useControllable. Esta librería expone un hook que encapsula la lógica para gestionar el valor efectivo y el manejador de cambios, y también proporciona tipos de TypeScript para garantizar la seguridad de tipos al obligar al desarrollador a proporcionar tanto value como onChange o ninguno de ellos, sino defaultValue en su lugar.

Aquí tienes cómo puedes usar el hook useControllable en el ejemplo anterior:

import { useControllable, type UseControllableProps } from 'use-controllable';


type MySelectProps = UseControllableProps<string, 'search'> & {
  // other props of the select component
}

export const MySelect({search, onChangeSearch, defaultSearch}: MySelectProps) {
  const [value, setValue] = useControllable({
    value: search,
    defaultValue: defaultSearch,
    onChange: onChangeSearch,
  })

  const handleSearchChange = (newSearch: string) => {
    setValue(newSearch);
  };

  return (
    <div>
      <input
        type="text"
        value={value}
        onChange={(e) => handleSearchChange(e.target.value)}
      />
      {/* Render select options based on effectiveSearch */}
    </div>
  );
}

Al revisar este código, podemos ver que la lógica para determinar el valor efectivo y el manejador de cambios ahora está encapsulada en el hook useControllable, el resto del código del componente es más limpio y fácil de leer; es como un componente controlado estándar, pero con el beneficio añadido de poder funcionar también en modo no controlado.

Puedes tener múltiples props controlables en el mismo componente llamando al hook useControllable varias veces con diferentes nombres de prop.



type MySelectProps = UseControllableProps<string> & UseControllableProps<string, 'search'> & {
  // other props of the select component
}

export const MySelect(props: MySelectProps) {

  const [value, setValue] = useControllable({
    value: props.value,
    defaultValue: props.defaultValue,
    onChange: props.onChange,
  })


  const [search, setSearch] = useControllable({
    value: props.search,
    defaultValue: props.defaultSearch,
    onChange: props.onChangeSearch,
  })

  const handleSearchChange = (newSearch: string) => {
    setSearch(newSearch);
  };

  return (
    <div>
      <input
        type="text"
        value={search}
        onChange={(e) => handleSearchChange(e.target.value)}
      />
      <select value={value} onChange={setValue}>
        {/* ... */}
      </select>
    </div>
  );
}

Instalación

Puedes instalar el paquete use-controllable a través de npm o yarn:

npm install use-controllable
# or
pnpm install use-controllable
# or 
yarn add use-controllable

puedes consultar la documentación y el código fuente en GitHub

Rendimiento

Realicé algunos benchmarks para comparar el rendimiento de los componentes que usan el hook useControllable frente a la implementación manual de la lógica controlado/no controlado. Los resultados muestran que el uso del hook proporciona mejoras significativas de rendimiento, especialmente en las renderizaciones del modo controlado.

EscenarioSin HookCon useControllableGanancia de Rendimiento
Modo controlado - Renderizado inicial1,167 ops/sec1,631 ops/sec1.40x más rápido
Modo controlado - Re-renderizados (100)40 ops/sec87 ops/sec2.14x más rápido ⚡⚡
Modo no controlado - Renderizado inicial2,007 ops/sec1,981 ops/sec~1.01x (equivalente)
Múltiples instancias (100 componentes)62 ops/sec94 ops/sec1.51x más rápido

Conclusión

El hook useControllable proporciona una forma limpia y eficiente de crear componentes de React que pueden funcionar tanto en modo controlado como no controlado. Al encapsular la lógica para gestionar los valores efectivos y los manejadores de cambios, simplifica la implementación de los componentes y mejora la legibilidad del código. Si a menudo te encuentras necesitando componentes que puedan alternar entre el comportamiento controlado y el no controlado, ¡prueba el hook useControllable y cuéntame qué te parece o cualquier comentario que tengas!