El post 8 terminó prometiendo este post: la misma app reconstruida sobre TanStack Query y Zustand, el stack que la mayoría de equipos de React Native prueba primero. Esa construcción ya existe, en una rama creada a partir del tag del post 8.
Las reglas que me puse: las mismas tres apps, el mismo paquete de detalle instalado, las mismas features (la lista del Pokédex, el detalle con su botón de añadir sujeto al tope, la cuadrícula del equipo con su control de quitar, el contador en vivo “My Party 3/6”), y ningún cambio que el stack de estado no obligara a hacer. El gemelo terminado se ve igual en el simulador, pantalla por pantalla, que es el control que esta comparación necesita.
El veredicto, antes de la prueba. Los dos stacks construyen bien esta app. La versión de TanStack y Zustand tiene menos piezas móviles, y para añadir estado de servidor desde un remote es la más cómoda de las dos bajo federación: nada que registrar, nada que inyectar. Redux Toolkit te da a cambio dos cosas concretas: un store que crece en runtime y un conjunto cerrado de escrituras. Solo importan cuando equipos desplegados por separado comparten estado de cliente en vivo con reglas que deben cumplirse. Si tu app nunca llega a ese punto, gana el stack más ligero y la mayor parte de este argumento no se aplicará a tu caso.
Las flechas entran en el contrato en el lado de Redux y salen de él en el gemelo: Redux deja que los remotes le añadan piezas en runtime, y en esta rama consumen lo que ya trae. @pokedex/detail aparece en ambas mitades con la misma versión, porque es el mismo paquete, sin cambios.
Estado de servidor: nada que inyectar
El modelo de TanStack Query es descentralizado, y ese es su mejor argumento aquí. No hay objeto de API central, ni paso de registro, ni lista en tiempo de build de lo que existe. Cualquier componente en cualquier bundle llama a useQuery con cualquier clave contra el cliente compartido. Un remote publicado un año después del shell puede añadir estado de servidor a la app en marcha llamando a un hook.
El post 8 necesitó baseApi.injectEndpoints, una instancia compartida y el middleware que ejecuta su caché. El acceso a datos de la app list en esta rama es un fetch y un hook:
// apps/list/src/listApi.ts
async function fetchPokemonList(): Promise<PokemonSummary[]> {
const res = await fetch('https://pokeapi.co/api/v2/pokemon?limit=151');
if (!res.ok) {
throw new Error(`PokéAPI responded ${res.status}`);
}
return parsePokemonList(await res.json());
}
export function usePokemonList() {
return useQuery({ queryKey: pokemonKeys.list(), queryFn: fetchPokemonList });
}
parsePokemonList se importa del contrato y es idéntico byte a byte al del post 8. El parseo con Zod, el modelo, las formas: todo sobrevive al cambio, porque el modelo de datos pertenece al dominio y no a la librería que sostenga la caché esta semana.
Compartir un cliente entre bundles construidos por separado tiene soporte, con una salvedad. La respuesta de la guía de migración a v5 para micro-frontends es pasar un queryClient a un hook o a un provider, en sustitución del contexto propio de v4. Eso es un párrafo en una guía de migración, no un capítulo sobre federación. Los modos de fallo en React Native son los de singleton que esta serie ya conoce: dos copias de @tanstack/react-query te dan “No QueryClient set”, dos copias de React te dan hooks nulos.
El coste aterriza en otro sitio. Los endpoints inyectados de RTK extienden una superficie tipada, así que un equipo que añade un endpoint ve la API completa y el grafo de tags que comparte. La coordinación de TanStack es un array de strings. Mi respuesta vuelve a ser el paquete de contratos: la fábrica de claves viaja junto al cliente, así que un cambio de nombre es un bump de versión que todos los consumidores ven.
// packages/contracts/src/query.ts
export const queryClient = new QueryClient();
export const pokemonKeys = {
all: ['pokemon'] as const,
list: () => [...pokemonKeys.all, 'list'] as const,
detail: (id: number) => [...pokemonKeys.all, 'detail', id] as const,
};
Eso es una recomendación, no una práctica documentada. Las fábricas de claves son la respuesta documentada al desorden de claves a escala; nada en la documentación trata el caso de dos equipos que poseen claves bajo un mismo prefijo.
La colisión de nombres de endpoint del post 8 tiene aquí un espejo más silencioso. En RTK, dos apps que inyectan un endpoint llamado getPokemonDetail comparten la definición que cargó primero: un error de consola en desarrollo, silencio en producción. En TanStack, dos apps que llaman a useQuery con pokemonKeys.detail(1) comparten una entrada de caché, porque las claves se comparan por valor. Nada se registra, así que nada colisiona. Abre a Bulbasaur en el Pokédex y ábrelo otra vez desde la pestaña Party, y la segunda pantalla se renderiza desde la caché sin petición. El caso malo: dos equipos cuyos queryFn han divergido, y los datos dependen de qué pantalla abrió primero el usuario.
El shell, por su parte, cede una cosa pequeña. El baseApi del post 8 llevaba la URL base para todos los consumidores. Aquí cada queryFn hace fetch de lo que quiera, y el host no decide de dónde vienen los datos de un remote.
Invalidación: un prefijo es una promesa
El botón Refresh pertenece a la interfaz del host y llega a los datos de un remote sin un solo import. El post 8 despachaba invalidateTags(['PokemonList']). El gemelo:
<Pressable onPress={() => queryClient.invalidateQueries({ queryKey: pokemonKeys.all })}>
Los filtros de queries funcionan por prefijo salvo que pidas exact, así que todo lo que la fábrica construye bajo ['pokemon'] caduca a la vez y cualquier query montada vuelve a hacer fetch. Todo el acuerdo cabe en el prefijo.
Comparé estos dos modelos en tags contra query keys, y aquel argumento vale aquí. La federación añade una cosa: los dos lados son ahora bundles desplegados por separado que nunca compartieron build. Un tag es un valor declarado en una lista compartida; un prefijo de clave es igualdad de strings entre código que se publica en días distintos. Poner la fábrica en el contrato versionado devuelve la convención a algo que un compilador y un proceso de release pueden ver.
Estado de cliente: el store vuelve al shell
Aquí es donde más se separan los dos diseños.
En el post 8 la app party creaba el estado del equipo, era su dueña y lo inyectaba en un store que el host ya había construido. combineSlices existe exactamente para eso, y el slice llegaba en runtime desde un bundle del que el shell no sabía nada.
Zustand no tiene equivalente. Nada añade un store a una aplicación en marcha después de su creación, y no parece un descuido: la respuesta federada natural de Zustand son varios stores independientes, uno por remote, sin maquinaria alguna. Esa respuesta funciona hasta que una pieza de estado tiene que cruzar una frontera de módulo, que es el caso del que trata toda esta serie.
Así que el store viaja en el paquete de contratos:
// packages/contracts/src/partyStore.ts
export const partyStore = createStore<PartyState>((set, get) => ({
members: [],
add: member => {
if (get().members.length >= MAX_PARTY) return; // the cap lives with the owner's action
set(state => ({ members: [...state.members, { ...member, uid: uid() }] }));
},
remove: id => set(state => ({ members: state.members.filter(m => m.uid !== id) })),
}));
createStore de zustand/vanilla, porque el contrato no contiene React. Los consumidores se suscriben con useStore(partyStore, selector).
La inversión de propiedad es el coste honesto. La app party ya no escribe las reglas del equipo; lee estado que llega en un paquete que instala. Su partySlice.ts está borrado, igual que la entrada ./partySlice de su config de Module Federation, así que vuelve a exponer un módulo en lugar de dos. En el lado del host, src/store.ts desapareció, el import de arranque que cargaba el módulo de estado del equipo desapareció, y la declaración ambient que lo tipaba se fue con ellos.
Esos borrados son la contrapartida. El post 8 dedicó una sección a un dispatch que se desvanece porque el dueño del slice aún no había cargado, y el import de arranque del shell existe para impedirlo. Aquí ese fallo no puede ocurrir: el estado existe en cuanto cualquier cosa importa el contrato. El precio se pagó antes, en forma de release. Cada nueva pieza de estado compartido es una nueva versión de @pokedex/contracts que todas las apps instalan: un despliegue en web, un ciclo de App Store en móvil. Ninguno de los dos stacks elimina la coordinación; ambos la mueven a un día distinto.
El paquete de componentes es el grupo de control. @pokedex/detail 3.1.0 está intacto: misma versión, mismo archivo, instalado por ambas apps, cableado a una acción de Zustand en una rama y a un action creator de Redux en la otra. A una vista que renderiza lo que le entregan le da igual qué librería lo calculó. Ese es el fruto de la regla de librería del post 6.
Propiedad: setState desde cualquier parte
El tope es una regla de la app party, y en esta rama vive dentro de add. Toda escritura que pasa por add respeta el tope. setState es público en todos los stores de Zustand, y no pasa por add.
La demostración completa está en el contenedor de detalle de la app list, junto al cableado legítimo:
// apps/list/src/ListStack.tsx — a module with no business writing this state
const bypassTheCap = () =>
data &&
partyStore.setState(s => ({
members: [
...s.members,
{ uid: `bypass-${s.members.length}`, id: data.id, name: data.name, spriteUri: data.spriteUri },
],
}));
Sin error de tipos. Sin aviso. Nada que el equipo propietario pueda revisar. El equipo se llena hasta seis, el botón de añadir se deshabilita solo, y una pulsación larga en la misma pantalla mete un séptimo Pokémon en un equipo de seis huecos:
La pulsación larga solo existe para poder grabar el bypass. La llamada es lo que importa.
Mira la cabecera. La cuadrícula del equipo son seis huecos, así que el séptimo miembro no tiene dónde aparecer, y el contador que marca 7/6 es la única superficie que lo muestra. La propia interfaz del equipo propietario no puede mostrar el estado que un módulo ajeno acaba de escribir.
Redux también permite escrituras ajenas: cualquier módulo puede despachar cualquier cosa. Lo que cambia es que un slice declara todas las escrituras que el propietario va a atender, así que una acción que el propietario nunca declaró no cambia nada, y las mutaciones legítimas se leen en un solo archivo. Un store de Zustand expone el estado en sí, y una regla dentro de una función solo obliga a quienes llaman a esa función.
Dentro de un solo equipo esto no es un problema. Todo el mundo sabe que setState está ahí, la revisión de código lo caza, y la convención se sostiene porque quienes la sostienen se sientan juntos. En el momento en que quien escribe y quien es dueño son equipos distintos con calendarios de release distintos, una convención es lo que tienes en lugar de una frontera.
Elegir
No hay ganador absoluto. El factor que decide es quién tiene permiso para escribir qué.
- Un equipo es dueño de todo el estado. TanStack Query y Zustand. Menos conceptos, menos ceremonia, nada que inyectar, y
setStatees una comodidad y no un agujero. - Equipos independientes, caché de servidor compartida, sin estado de cliente compartido. TanStack también. Comparte el cliente como singleton, pon la fábrica de claves en un contrato versionado, y tienes la coordinación que necesitas.
- Equipos independientes escribiendo el estado de cliente de otros, con reglas que deben cumplirse. Este es el caso para el que existe el peso de Redux Toolkit: un store que crece en runtime y un conjunto cerrado de escrituras por slice.
- Móvil, con un ciclo de release lento. Sopesa la forma del cambio. En la rama de Zustand cada nueva pieza de estado compartido es una release del contrato, y en móvil una release espera a la revisión de la tienda.
La señal que hay que vigilar es el día en que un segundo equipo necesita escribir estado del que el primero es dueño. A partir de ese día, la pregunta es si prefieres coordinarte con un tipo o con una convención.
El gemelo está en su propia rama, creada a partir del tag del post 8, en post-09-tanstack-zustand. Es una bifurcación, no un paso adelante: la serie continúa desde el post 8 en main.
Lo siguiente, la otra mitad de la comparación. El stack se queda igual y el backend se parte en dos: REST para la lista, GraphQL para las insignias de tipo, un cliente, una caché, dos equipos.
Fuentes
- TanStack Query: migración a v5 — la respuesta para micro-frontends, pasar un
queryClienten lugar de un contexto propio - TanStack Query: filtros de queries — filtrado por prefijo, y
exact - TanStack Query: query keys y effective query keys — fábricas de claves a escala
- Zustand:
createStoreyuseStore— el store vanilla y su suscripción desde React - Redux Toolkit:
combineSlicesy RTK Query: code splitting — aquello de lo que depende la otra rama - react-native-module-federation — el repo de acompañamiento, en el tag
post-09-tanstack-zustand