createEntityAdapter: CRUD y selectores sin ceremonia
Toda colección normalizada necesita lo mismo: la forma ids más entities, un puñado de reducers CRUD para mantenerla y los selectores para leerla. Escribirlo a mano en cada slice es repetición pura y una fuente de bugs. createEntityAdapter, de Redux Toolkit, encapsula ese patrón: te da getInitialState, un juego completo de helpers CRUD que operan sobre el draft de Immer, y getSelectors con los selectores ya memoizados y re-basados. Esta lección diseca su anatomía, selectId y sortComparer, la integración con RTK Query y createSlice, y cuándo no usarlo.
La lección anterior estableció por qué el estado compartido debe vivir normalizado; esta responde a la objeción inmediata: normalizar a mano es tedioso y propenso a errores. Cada colección repite la misma coreografía —insertar preservando el orden de ids, actualizar sin romper la inmutabilidad, borrar limpiando ambas mitades de la estructura, exponer los mismos cinco selectores— y esa repetición es justo el tipo de código donde los bugs se esconden. createEntityAdapter, incluido en Redux Toolkit, es la abstracción que captura ese patrón entero: le dices qué es tu entidad y te devuelve el estado inicial, los reducers CRUD y los selectores memoizados, todo coherente entre sí. Es, en esencia, la definición de una tabla con su índice y sus consultas, en una sola llamada.
- Explicar qué patrón repetido captura
createEntityAdaptery por qué esa repetición es peligrosa. - Diseccionar su anatomía:
getInitialState, los reducers CRUD ygetSelectors. - Configurar la identidad y el orden con
selectIdysortComparer. - Integrarlo con
createSliceyRTK Query, y decidir cuándo no conviene usarlo.
El patrón repetido y su abstracción
Sin el adapter, cada slice de colección reescribe la misma lógica: comprobar si un id ya existe antes de añadirlo a ids, fusionar cambios parciales en una entidad existente, eliminar de entities y filtrar de ids al borrar. Es código correcto pero mecánico, y multiplicado por cada entidad de la app se convierte en superficie de bugs sin ningún valor de negocio. createEntityAdapter lo abstrae de una vez: recibe el tipo de entidad y opcionalmente cómo obtener su id y cómo ordenarla, y devuelve un objeto con métodos que producen exactamente esa lógica, ya probada.
Anatomía: initial state, CRUD y selectores
El adapter expone tres familias. getInitialState produce la forma vacía ids más entities, admitiendo campos extra para el estado que no sean entidades. Los reducers CRUD —que operan sobre el draft de Immer, así que puedes usarlos directamente dentro de createSlice— cubren todas las operaciones. Y getSelectors genera los selectores de lectura, re-basados sobre la rama del store donde vive la colección.
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'
interface Tarea { id: string; titulo: string; hecha: boolean; creada: number }
const adaptador = createEntityAdapter<Tarea>({
selectId: (t) => t.id, // clave primaria (por defecto entity.id)
sortComparer: (a, b) => b.creada - a.creada, // orden estable de ids
})
const slice = createSlice({
name: 'tareas',
initialState: adaptador.getInitialState({ cargando: false }), // campo extra
reducers: {
anadida: adaptador.addOne,
sincronizadas: adaptador.upsertMany,
editada: adaptador.updateOne, // payload: { id, changes }
borrada: adaptador.removeOne,
},
})
// Selectores memoizados y re-basados sobre la rama del store.
export const {
selectAll: selectTareas,
selectById: selectTareaPorId,
selectIds: selectIdsTareas,
selectTotal: selectNumTareas,
} = adaptador.getSelectors((s: RootState) => s.tareas)
Insertar
addOne y addMany añaden solo si el id no existe; setOne, setMany y setAll reemplazan.
Upsert
upsertOne y upsertMany insertan si es nuevo o fusionan cambios si ya existe. El caballo de batalla al sincronizar con el servidor.
Actualizar
updateOne y updateMany reciben { id, changes } y aplican un cambio parcial sin tocar el resto de la entidad.
Eliminar
removeOne, removeMany y removeAll limpian entities e ids a la vez, sin dejar mitades huérfanas.
Identidad, orden y tipos
Dos opciones gobiernan el comportamiento. selectId indica cuál es la clave primaria cuando no se llama id —por ejemplo entity.uuid—. sortComparer mantiene el array ids ordenado en todo momento, de modo que selectAll y selectIds devuelven las entidades en ese orden sin que ningún componente tenga que ordenar; si lo omites, el orden es de inserción. El estado que produce el adapter tiene el tipo EntityState<Tarea, string>, con el tipo de la entidad y el tipo del id como parámetros, lo que te da autocompletado y comprobación en toda la cadena.
getSelectors no devuelve funciones ingenuas: selectAll está construido con createSelector sobre ids y entities, así que solo reconstruye el array cuando la colección cambia de verdad, y devuelve la misma referencia mientras no. Es la lección 2 aplicada por ti sin que muevas un dedo. Además re-basa cada selector sobre la rama que le indicas en getSelectors(state => state.tareas), resolviendo de paso la cuestión de selectores locales frente a globales que dejamos abierta en la lección 1.
Integración y cuándo no usarlo
createEntityAdapter encaja con RTK Query: en el extraReducers de un slice puedes hacer adaptador.upsertMany cuando una query se cumple, normalizando en tu store la respuesta que RTK Query cachea por endpoint. Es el patrón estándar de 2026 para tener a la vez la caché de servidor de RTK Query y una colección normalizada de entidades consultable con selectores propios. Pero el adapter no es para todo: usarlo exige que tus datos sean entidades con id estable y que la colección justifique la maquinaria. Una lista fija de tres opciones de un menú, un estado que no es una colección, o datos sin identidad natural no ganan nada normalizándose; ahí el adapter añade ceremonia sin beneficio, y la lección siguiente te dará el criterio para no caer en ese exceso.
Vale la pena ver createEntityAdapter como lo que verdaderamente es: la materialización, en una sola primitiva, de la analogía con la base de datos que la lección anterior planteó. getInitialState declara la tabla vacía; selectId nombra su clave primaria; sortComparer define su índice ordenado; los reducers CRUD son las sentencias de inserción, actualización y borrado que mantienen la integridad de esa tabla sin que tú vigiles la coherencia entre ids y entities; y getSelectors son las consultas preparadas, ya optimizadas con memoización. Toda la sabiduría relacional que la normalización trajo al cliente queda empaquetada aquí en una abstracción que hace lo correcto por defecto y cuesta menos escribir que la versión incorrecta a mano. Ese es el rasgo de una gran abstracción: no solo ahorra código, sino que hace que el camino fácil y el camino correcto sean el mismo, de modo que un desarrollador que jamás oyó hablar de las formas normales de Codd acabe produciendo estado normalizado simplemente porque era la ruta de menor resistencia. Por eso, cuando tus datos son de verdad entidades, no hay excusa para no usarlo: convierte la normalización de una disciplina que hay que recordar en una consecuencia gratuita de usar la herramienta idiomática. Y por eso mismo su límite es tan importante como su poder: aplicarlo a lo que no es una colección de entidades es forzar un esquema de tabla sobre datos que no lo son, y ese desajuste entre la herramienta y la forma real del problema es precisamente lo que la próxima lección te enseñará a reconocer y evitar.
- Elige una entidad de tu app y crea su
createEntityAdapter, decidiendo suselectIdy si necesitasortComparer. - Móntalo dentro de un
createSliceusando los helpers CRUD directamente como reducers, con un campo extra no-entidad engetInitialState. - Exporta los selectores con
getSelectorsre-basados sobre la rama del store y úsalos en un componente. - Sincroniza con el servidor haciendo
upsertManyenextraReducersal cumplirse una query, y observa cómo las entidades intactas conservan su referencia. - Toma un dato de tu app que NO sea una colección de entidades e intenta modelarlo con el adapter: articula por qué el resultado es peor que un slice normal.