wandres.dev
SELECTORES · reselect y normalización

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.

⏱ 16 min

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.

🎯 Al terminar esta lección sabrás
  • Explicar qué patrón repetido captura createEntityAdapter y por qué esa repetición es peligrosa.
  • Diseccionar su anatomía: getInitialState, los reducers CRUD y getSelectors.
  • Configurar la identidad y el orden con selectId y sortComparer.
  • Integrarlo con createSlice y RTK 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.

ℹ️
Los selectores del adapter ya vienen memoizados

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.

El adapter es la tabla, el índice y las consultas en una línea

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.

⚔️ Modela una colección con el adapter
  1. Elige una entidad de tu app y crea su createEntityAdapter, decidiendo su selectId y si necesita sortComparer.
  2. Móntalo dentro de un createSlice usando los helpers CRUD directamente como reducers, con un campo extra no-entidad en getInitialState.
  3. Exporta los selectores con getSelectors re-basados sobre la rama del store y úsalos en un componente.
  4. Sincroniza con el servidor haciendo upsertMany en extraReducers al cumplirse una query, y observa cómo las entidades intactas conservan su referencia.
  5. 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.