wandres.dev
MIGRAR DE REDUX · a Zustand o RTK

Paso dos: slice a slice hacia Zustand

Con el estado del servidor ya fuera, lo que queda en el store es estado de cliente auténtico y se puede mover a Zustand pieza a pieza, con los dos stores conviviendo durante todo el proceso. Esta lección define la única regla que hace segura esa convivencia —un slice tiene un solo dueño en cada momento, nunca dos sincronizados—, muestra el hook adaptador que oculta a los componentes de qué store leen y permite migrar sin tocar las vistas, ordena los slices por dificultad creciente y explica cómo cerrar la migración retirando el Provider y verificando que no queda ningún lector huérfano.

⏱ 18 min

Terminada la extracción del servidor, lo que sobrevive dentro del store es por fin lo que su nombre prometía: estado que el cliente posee, que nadie más conoce y que no caduca solo. Ese resto es pequeño, y precisamente por serlo se puede mover con una técnica que sería temeraria a mayor escala: pieza a pieza, con el store antiguo y el nuevo funcionando a la vez durante semanas, y sin ninguna ventana en la que la aplicación quede a medio migrar de forma inconsistente. La clave de que esa convivencia sea segura no está en ninguna librería puente ni en ningún adaptador mágico, sino en una regla de propiedad tan simple que se puede sostener mentalmente: cada porción de estado tiene exactamente un dueño en cada instante del proyecto. Toda la técnica de esta lección consiste en respetar esa regla mientras el dueño cambia.

🎯 Al terminar esta lección sabrás
  • Aplicar la regla de propiedad única que hace segura la convivencia de dos stores.
  • Escribir el hook adaptador que oculta a los componentes desde qué store leen.
  • Ordenar los slices por dificultad creciente y migrar cada uno en un cambio atómico.
  • Cerrar la migración: retirar el Provider, borrar el residuo y verificar que no quedan lectores huérfanos.

La regla de propiedad y por qué no se sincroniza

La tentación al empezar es construir un puente bidireccional: un middleware que copie cada cambio de Redux al store nuevo y una suscripción que devuelva los cambios del nuevo a Redux. Parece prudente porque garantiza que ambos lados vean lo mismo, y es la forma más fiable de arruinar la migración. Dos copias sincronizadas de un valor son dos fuentes de verdad con un contrato implícito que nadie escribe, y en cuanto aparece una escritura simultánea, un orden de eventos inesperado o un ciclo de eco entre los dos puentes, el sistema entra en estados que ninguna de las dos herramientas puede explicar por separado. Depurar eso cuesta más que la migración entera.

⚠️
Nunca sincronices: transfiere la propiedad

La alternativa correcta no es sincronizar mejor sino no sincronizar en absoluto. Un slice pertenece a Redux hasta el momento exacto en que pertenece a Zustand, y ese momento es un único cambio en el repositorio: se crea el slice nuevo, se reescriben todas sus lecturas, se reescriben todas sus escrituras y se borra el slice viejo, todo junto. Antes de ese cambio nadie lee el store nuevo para ese dato; después, nadie lee el viejo. No hay ventana de coexistencia para una misma porción de estado, y por tanto no hay nada que sincronizar. Lo que sí coexiste, y durante mucho tiempo, son los dos stores completos: uno gobernando los slices aún no migrados y otro los ya migrados. Esa coexistencia es inofensiva justamente porque sus contenidos son disjuntos.

flowchart TD
C[componentes] --> H[hooks adaptadores]
H --> R[redux con slices pendientes]
H --> Z[zustand con slices migrados]
R -.->|un slice cruza una sola vez| Z
style R fill:#f38ba8,color:#11111b
style Z fill:#a6e3a1,color:#11111b
style H fill:#89b4fa,color:#11111b

El adaptador que desacopla la vista de la migración

Si los componentes llaman directamente a useSelector, migrar un slice obliga a tocar cada componente que lo lee, y el cambio atómico que exige la regla de propiedad se vuelve enorme y arriesgado. La solución es interponer una capa de hooks de dominio antes de mover nada: los componentes dejan de saber de qué store viene el dato y pasan a pedirlo por su nombre de negocio.

// paso previo: los componentes ya no conocen el store, solo el dominio
export function useTema() {
  return useSelector((s: RootState) => s.ui.tema)
}

export function useCambiarTema() {
  const dispatch = useDispatch()
  return (tema: Tema) => dispatch(uiSlice.actions.setTema(tema))
}

Con esa capa en su sitio, migrar el slice consiste en reescribir el interior de dos funciones. Ni un solo componente cambia, la superficie del cambio es diminuta y la reversión, si algo va mal, es igual de barata:

// despues: misma firma, otro dueno; los componentes no se enteran
interface EstadoUi { tema: Tema; setTema: (t: Tema) => void }

export const useUi = create<EstadoUi>((set) => ({
  tema: 'oscuro',
  setTema: (tema) => set({ tema }),
}))

export const useTema = () => useUi((s) => s.tema)
export const useCambiarTema = () => useUi((s) => s.setTema)
🎭

Firma estable

El contrato que ven los componentes no cambia nunca. La migración ocurre por debajo, y ninguna vista participa en ella.

🔬

Cambios pequeños

Cada slice migrado es un cambio de decenas de líneas, revisable en minutos y reversible con un solo movimiento.

🧪

Tests que sobreviven

Si tus pruebas usan los hooks de dominio y no el store, siguen pasando tras la migración y actúan como red de seguridad real.

🚪

Salida limpia

Cuando el último hook deja de usar useSelector, retirar el Provider es un cambio trivial en lugar de una cirugía.

La capa de adaptadores tiene además un efecto que se aprecia mucho después de la migración: obliga a nombrar el estado por lo que significa en el dominio y no por dónde está guardado. Un componente que pide useTema describe una intención; uno que pide una ruta dentro del árbol describe una implementación. Esa diferencia de vocabulario sobrevive al proyecto y hace que el siguiente cambio de infraestructura, sea cual sea, vuelva a costar lo mismo que este: la reescritura de unas pocas funciones intermedias en lugar de una batida por todas las vistas.

El orden de los slices y el cierre

El orden importa porque el primer slice migrado es una clase, no una entrega. Empieza por el más trivial e independiente —un tema, una preferencia de interfaz, una bandera de panel abierto— para que el equipo aprenda el patrón de hooks adaptadores donde un error no cuesta nada. Sigue por los slices de tamaño medio con lectores dispersos, que es donde el adaptador demuestra su valor. Y deja para el final los que tienen dependencias cruzadas con otros slices, porque son los únicos que exigen decidir si viajan juntos en un mismo store o se separan.

// los slices interdependientes viajan juntos y se componen en un solo store
const crearSesion = (set) => ({
  usuario: null as Usuario | null,
  entrar: (u: Usuario) => set({ usuario: u }),
  salir: () => set({ usuario: null, favoritos: [] }),
})

const crearFavoritos = (set, get) => ({
  favoritos: [] as string[],
  alternar: (id: string) => set({ favoritos: alterna(get().favoritos, id) }),
})

export const useApp = create((...a) => ({ ...crearSesion(...a), ...crearFavoritos(...a) }))

Antes de aceptar ese acoplamiento como un hecho, conviene preguntarse si debería existir. Que una acción de sesión limpie los favoritos es a menudo el rastro de una decisión tomada cuando todo vivía en un mismo árbol y escribir en cualquier rama salía gratis. Si el vaciado puede expresarse como una reacción al cambio de sesión en lugar de como una escritura directa, los dos slices se desacoplan y pueden migrar por separado. La migración es un buen momento para hacer esa pregunta, siempre que la respuesta se implemente en un cambio propio y anterior, nunca mezclada con el traslado.

Ese fragmento contiene el motivo por el que dos slices acoplados no se migran por separado: salir limpia los favoritos, de modo que una acción de sesión escribe estado de favoritos. Si sesión viviera ya en Zustand y favoritos siguiera en Redux, esa acción tendría que despachar hacia el store antiguo, y eso es exactamente el puente que la regla de propiedad prohíbe. Cuando dos slices comparten una escritura, forman una unidad de migración.

ℹ️
Cerrar de verdad, no dejar el residuo

Una migración incremental que no se termina es peor que no haberla empezado, porque deja al equipo manteniendo dos modelos mentales para siempre y duplica el coste de cada incorporación. El cierre tiene pasos verificables: que ninguna importación de react-redux quede en el árbol de componentes, que el Provider desaparezca del punto de entrada, que las dependencias de Redux salgan del manifiesto y que no quede ningún hook adaptador que envuelva un store inexistente. Conviene además fijar una fecha para el último slice desde el primer día: sin ella, los tres o cuatro slices más incómodos se quedan en Redux indefinidamente y la aplicación acaba con dos stores permanentes, que es el único desenlace verdaderamente malo de este camino.

Lo que se pierde por el camino y cómo suplirlo

Una migración honesta nombra también lo que deja atrás. Tres capacidades de Redux no vienen de fábrica en el destino, y conviene reponerlas de forma deliberada en lugar de descubrir su ausencia en la primera incidencia de producción. Dos se recuperan con middleware y son casi gratuitas; la tercera no se recupera del todo, y esa diferencia debería influir en la decisión de migrar.

// las piezas que reponen persistencia, inspeccion y escritura anidada comoda
export const useApp = create(
  devtools(
    persist(immer(crearEstado), { name: 'app', version: 2, migrate: migrar }),
    { name: 'app' },
  ),
)

La persistencia es la reposición más directa: el middleware correspondiente cubre lo que hacía redux-persist, incluida la versión del esquema guardado y la función de migración que traduce datos antiguos al formato nuevo. Conviene no omitir esa versión durante la migración, porque los usuarios que ya tenían estado persistido con el formato de Redux lo van a rehidratar con el store nuevo, y sin una migración explícita esa rehidratación produce estados imposibles que solo se manifiestan en las máquinas de quienes ya usaban la aplicación, jamás en desarrollo.

ℹ️
La inspección se recupera; el registro global de acciones, no del todo

El middleware de devtools conecta el store a la misma extensión de navegador y permite ver el estado y sus cambios, de modo que la inspección básica se conserva. Lo que no se traslada es la propiedad de la que dependía el viaje en el tiempo: en Redux cada cambio del árbol entero pasa por una acción nombrada, y esa disciplina hace que la secuencia completa de la aplicación sea un registro reproducible. Un store ligero admite escrituras arbitrarias desde cualquier función, y aunque nombres cada una, nada obliga a hacerlo ni impide que alguien escriba directamente. Si tu equipo depuraba de verdad rebobinando sesiones, esa pérdida es real y hay que ponerla en la balanza antes de la primera línea, no después de la primera incidencia difícil de reproducir.

La migración incremental no es una técnica de ejecución: es una técnica de reversibilidad

Se suele justificar la migración pieza a pieza por razones de riesgo operativo —no romper producción, no bloquear el desarrollo de funcionalidades— y esas razones son ciertas pero secundarias. La razón profunda es epistémica: una migración por etapas es la única que te permite descubrir, a mitad de camino, que estabas equivocado, y actuar en consecuencia sin haber perdido nada. Al migrar el tercer slice puedes darte cuenta de que el store nuevo no aporta lo que esperabas, o de que el problema real era otro, o de que lo que queda en Redux ya no molesta; y en cualquiera de esos casos detenerte es una decisión legítima y barata, porque cada slice migrado es una unidad completa y funcional, no una pieza inservible de un puente a medio construir. Una reescritura de golpe no ofrece esa salida: una vez comprometidos tres meses de trabajo, la única opción racional es terminar, incluso si a las dos semanas apareció evidencia de que la premisa era falsa. Esa asimetría —entre un camino que se puede abandonar en cualquier punto conservando el valor acumulado y otro que solo paga al final— es lo que hace que el enfoque incremental sea superior incluso cuando la reescritura completa sería más rápida en el papel, porque las estimaciones de migración están sistemáticamente sesgadas a la baja y la evidencia que las corregiría solo aparece una vez empezado el trabajo. Migrar slice a slice no es la forma prudente de llegar al final: es la forma de convertir el final en una decisión que se sigue tomando cada semana, con información nueva, en lugar de una apuesta hecha el primer día con la información más pobre que tendrás nunca.

⚔️ Migra tres slices y mide el coste real
  1. Antes de mover nada, envuelve las lecturas y escrituras de un slice en hooks de dominio y comprueba que ningún componente menciona ya useSelector para ese dato.
  2. Migra ese slice a Zustand en un cambio único que reescriba los hooks y borre el slice viejo. Verifica que ningún componente aparece en el diff.
  3. Busca en tu store dos slices acoplados por una escritura cruzada y decide si viajan juntos o si el acoplamiento debería desaparecer antes de migrarlos.
  4. Repite con un slice de lectores dispersos y anota cuántos minutos costó, para tener una estimación empírica del resto.
  5. Escribe una comprobación automática que falle si aparece una importación de react-redux fuera de la lista de slices aún pendientes.
  6. Fija por escrito la fecha del último slice y el criterio por el cual detendrías la migración a mitad, con lo que ya sabes tras estos tres.