Hooks tipados: envolver useSelector y useDispatch una vez
Los hooks de react-redux son agnosticos del store por diseno, asi que en cada uso hay que decirles de que estado hablan y que dispatch devuelven. Repetir esa anotacion en cada componente es duplicar una decision cientos de veces. Esta leccion construye el modulo de hooks del proyecto con withTypes de react-redux 9, repasa la version historica con TypedUseSelectorHook, explica por que la ampliacion global de modulo fue abandonada, y muestra como los hooks tipados hacen que los selectores infieran su retorno y que los thunks se despachen sin aserciones. Cierra convirtiendo la convencion en regla verificable con eslint.
Los hooks de react-redux nacen sin saber nada de tu store, y esa ignorancia es deliberada: la librería no puede asumir que hay un único estado global en el proceso ni conocer la forma del tuyo. La consecuencia es que cada llamada a useSelector necesita que le digas de qué estado habla, y cada llamada a useDispatch devuelve un dispatch empobrecido que ignora los middlewares de tu store. Multiplica eso por los cientos de componentes de una app real y tendrás la misma anotación repetida cientos de veces, con el riesgo habitual de que alguna quede desfasada. La solución es tan pequeña que casi da vergüenza escribirla, y sin embargo es el patrón más rentable de todo el nivel: un módulo con dos hooks preconfigurados que el resto de la app importa en lugar de los originales.
- Explicar por qué
useSelectoryuseDispatchno pueden conocer tu store por sí solos. - Construir el módulo de hooks del proyecto con
withTypesy los alias del store. - Reconocer la forma histórica con
TypedUseSelectorHooky por qué se abandonó la ampliación global de módulo. - Convertir la convención en una regla de lint que impida importar los hooks crudos.
Una anotación repetida es una decisión repetida
La firma de useSelector tiene dos parámetros de tipo: el del estado del que parte y el del valor que devuelve. El segundo se infiere siempre del selector, pero el primero no puede inferirse de nada, porque el hook lee el store del contexto de React y el contexto no lleva tipos. En las versiones modernas de react-redux el estado por defecto es unknown, así que la única salida es anotar el parámetro en cada uso, escribiendo el selector con el tipo del estado a mano en el sitio. Con useDispatch el problema es peor porque no se manifiesta como una anotación que falta sino como una capacidad que se pierde: el hook devuelve el dispatch base, que solo acepta acciones planas, de modo que despachar un thunk deja de compilar aunque el store lo soporte perfectamente.
Ninguno de los dos problemas es un defecto de la librería; son el precio de que la librería sea genérica. El error está en pagarlo en cada punto de uso en lugar de pagarlo una vez. Anotar en el sitio esparce por toda la base de código una decisión que en realidad es única y central: qué estado tiene esta aplicación y qué sabe hacer su dispatch. La forma correcta de gestionar una decisión única es alojarla en un solo módulo y hacer que todos los demás la consuman, que es exactamente lo que hace el fichero de hooks del proyecto.
import { useDispatch, useSelector, useStore } from "react-redux";
import type { RootState, AppDispatch, AppStore } from "./store";
// react-redux 9: preconfiguran el hook con los tipos de tu store
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();
export const useAppStore = useStore.withTypes<AppStore>();
flowchart LR A[store con configureStore] --> B[RootState y AppDispatch] B --> C[modulo de hooks del proyecto] C --> D[useAppSelector] C --> E[useAppDispatch] D --> F[componentes sin genericos ni anotaciones] E --> F G[regla de eslint que prohibe los hooks crudos] --> F style C fill:#fab387,color:#11111b style F fill:#a6e3a1,color:#11111b
Lo que los hooks preconfigurados regalan
El beneficio inmediato es cosmético: los componentes dejan de repetir el tipo del estado. El beneficio real es que la inferencia vuelve a funcionar en las dos direcciones. Con useAppSelector, el parámetro del selector llega ya tipado, así que el valor devuelto se deduce solo y cualquier acceso a un campo inexistente falla en el editor. Con useAppDispatch, el dispatch conserva las sobrecargas que le añadieron los middlewares, de modo que despachar un thunk compila y devuelve el objeto con unwrap cuyo tipo de retorno es el payload de fulfilled. Sin ese envoltorio, ese unwrap no existiría para el compilador y alguien acabaría escribiendo una aserción.
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { seleccionarPendientes, cargarTodos } from "./todosSlice";
export function ListaTodos() {
// el tipo del selector se infiere: Todo array, sin anotar nada
const pendientes = useAppSelector(seleccionarPendientes);
const dispatch = useAppDispatch();
const recargar = async () => {
// compila porque AppDispatch conserva la sobrecarga del thunk
const datos = await dispatch(cargarTodos()).unwrap();
return datos.length;
};
return null;
}
useAppSelector
El estado llega tipado y el retorno se infiere del selector. Acepta también un comparador de igualdad sin perder tipos.
useAppDispatch
Devuelve el dispatch real del store, con thunks y cualquier otra ampliación que aporten tus middlewares.
useAppStore
Da acceso al store completo tipado. Útil para leer el estado dentro de un callback sin suscribir el componente.
El tercer hook, el del store, se usa poco y por eso conviene recordar para qué existe. useAppSelector suscribe el componente al valor que devuelve, de modo que leer un dato solo para decidir algo dentro de un manejador de eventos provoca renders innecesarios cada vez que ese dato cambia. useAppStore devuelve el store estable y permite leer el estado bajo demanda, sin suscripción, dentro del callback. Tipado, esa lectura conserva la forma de RootState y no obliga a ninguna anotación en el sitio, que es justamente lo que se buscaba.
export function BotonEnviar() {
const store = useAppStore();
const dispatch = useAppDispatch();
const enviar = () => {
// lectura puntual sin suscribir el componente, y con RootState tipado
const borrador = store.getState().formulario.borrador;
if (borrador.texto.length > 0) dispatch(enviado(borrador));
};
return null;
}
El envoltorio rinde el doble si además extraes los selectores del cuerpo del componente. Un selector declarado como función con el parámetro anotado como RootState se puede pasar por nombre a useAppSelector, y entonces convive con createSelector para memoizar sin cambiar nada en el punto de uso. La ventaja de tipos es que el contrato de lectura queda en un solo lugar, junto al slice que lo posee, y no disperso en lambdas anónimas. La ventaja de rendimiento llega gratis después: una función definida fuera del render conserva su identidad entre renders, requisito para que la memoización de Reselect sirva de algo.
Historia del patrón y cómo hacerlo obligatorio
Antes de withTypes, el mismo resultado se conseguía con anotaciones explícitas sobre los hooks originales, y ese código sigue siendo válido y conviene reconocerlo al leer proyectos anteriores. La forma histórica usaba el alias TypedUseSelectorHook para el selector y una anotación de función para el dispatch. Hubo además una tercera vía, hoy desaconsejada: ampliar globalmente el módulo de react-redux para declarar el tipo del estado por defecto. Se abandonó por una razón estructural, no por moda. Una ampliación global impone que en todo el proceso exista un único estado posible, lo que rompe en cuanto una librería embebe su propio store, en cuanto un monorepo mezcla dos aplicaciones o en cuanto una prueba quiere un estado distinto. Un envoltorio explícito no tiene ese problema porque es local por construcción: cada store puede tener el suyo y convivir con los demás.
import { useDispatch, useSelector, type TypedUseSelectorHook } from "react-redux";
import type { RootState, AppDispatch } from "./store";
// forma historica, equivalente y todavia habitual en proyectos existentes
export const useAppDispatch: () => AppDispatch = useDispatch;
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
El patrón solo funciona si nadie lo saltea, y en un equipo grande la disciplina voluntaria dura hasta la primera prisa. Por eso el paso final no es escribir el módulo sino cerrar la puerta de atrás: una regla que prohíba importar useSelector y useDispatch directamente de react-redux y que sugiera el módulo del proyecto en el mensaje de error. Convertir una convención en una regla verificable es lo que la transforma de recomendación en propiedad del sistema.
// regla que cierra la puerta de atras y explica por donde se pasa
"no-restricted-imports": ["error", {
paths: [{
name: "react-redux",
importNames: ["useSelector", "useDispatch", "useStore"],
message: "Usa useAppSelector, useAppDispatch o useAppStore de app/hooks.",
}],
}]
Una regla así tiene una virtud pedagógica que su severidad esconde: el mensaje de error es documentación que aparece exactamente cuando hace falta, en el editor de quien está a punto de equivocarse, sin depender de que nadie haya leído una guía. Merece la pena escribirlo con cuidado, nombrando el módulo alternativo y no solo prohibiendo el original. Y merece la pena aplicarla con una excepción explícita para el propio fichero de hooks, que es el único lugar del proyecto donde importar los originales es correcto.
Tipar los hooks no cambia nada del comportamiento en ejecución, y conviene no confundir las dos capas. Un useAppSelector que devuelve un objeto o un array construido en el momento seguirá provocando renders en cada acción despachada, porque la comparación por referencia fallará siempre aunque el contenido sea idéntico. El tipado hace que ese selector compile perfectamente, que es justo lo que lo vuelve traicionero: el compilador no tiene nada que objetar a un problema de identidad referencial. Para eso están createSelector, useAppSelector con un comparador superficial o, mejor aún, seleccionar valores primitivos por separado.
Un módulo de cuatro líneas que reexporta dos hooks parece demasiado poco para llamarlo arquitectura, y sin embargo hace exactamente lo que hace la buena arquitectura: convierte una decisión que se tomaría muchas veces en una decisión que se toma una vez. Toda librería general tiene huecos donde debe recibir información de tu dominio, y esos huecos son puntos de acoplamiento; la pregunta de diseño no es cómo evitarlos, porque no se puede, sino cuántas veces vas a rellenarlos. Anotar el estado en cada componente es rellenar el mismo hueco en cientos de sitios, y multiplica el coste de cambiar de opinión por el número de puntos de uso: si mañana partes el store en dos, o migras a otra librería, o renombras un alias, tendrás que visitarlos todos. Un envoltorio reduce ese número a uno y, al hacerlo, cambia la naturaleza del acoplamiento: la app deja de depender de react-redux y pasa a depender de su propio módulo de hooks, que es lo único que conoce a react-redux. Esa es la idea del módulo profundo, interfaz mínima que oculta una decisión importante, y explica por qué el patrón sobrevive intacto a los cambios de la librería: cuando react-redux introdujo withTypes y jubiló TypedUseSelectorHook, los proyectos que envolvían cambiaron dos líneas y los que anotaban en el sitio recorrieron cientos de ficheros. La lección general es que la duplicación más cara no es la del código sino la de las decisiones, porque el código repetido se localiza con una búsqueda mientras que una decisión repetida hay que reconocerla como tal en cada uno de sus disfraces.
- Crea el módulo de hooks con
withTypesa partir de los alias derivados de la lección anterior y expórtalo desde la carpeta de la app. - Migra un componente que anote el estado en el sitio y compara las dos versiones línea a línea. Cuenta cuántas anotaciones desaparecen.
- Extrae uno de sus selectores en línea a una función declarada junto al slice y comprueba que el retorno se sigue infiriendo sin anotar nada.
- Despacha un thunk con el
useDispatchcrudo, lee el error, y repítelo conuseAppDispatchencadenandounwrappara ver el tipo del payload resuelto. - Añade una regla de lint que prohíba importar los hooks crudos de react-redux, con un mensaje que apunte a tu módulo, y ejecútala sobre todo el proyecto.
- Escribe una prueba que monte un store distinto con
setupStorey confirma que los mismos hooks tipados sirven sin ampliaciones globales.