Un remote federado que añade su propio slice a un store compartido mientras otro remote accede a ese slice desde el otro lado de la frontera

El estado de cliente cruza la frontera: remotes que traen sus propios slices en React Native

El post 6 terminó con una limitación honesta: cierra la app, vuelve a abrirla, y nada de lo que hiciste sobrevive. Ni equipo, ni favoritos, ni memoria. Todo lo que ha cruzado la frontera hasta ahora ha sido estado de servidor: datos que pertenecen a PokéAPI, guardados en una caché que todos los módulos comparten. La app en sí todavía no tiene nada suyo.

Este post le da algo suyo: el equipo de seis. El estado de cliente no tiene un servidor detrás ni una caché que refrescar. Vive en un slice de Redux, y con federación de por medio un slice plantea la pregunta alrededor de la cual gira toda esta serie: ¿quién es su dueño, y cómo lo usan las demás features sin depender de él? El ensayo sobre propiedad respondió con principios. Este post responde con código.

Esto es lo que vamos a construir:

Una idea para tener en la cabeza: la caché es de todos, pero un slice de estado de cliente responde ante uno solo. El equipo es de la app party. Todo lo que otro módulo necesita (una acción, una forma de leerlo y un tope) pasa por el contrato, y cerca del final despachamos una acción al slice antes de que se haya cargado el módulo de su dueño, a propósito, para ver qué hace Redux con una acción que nadie escucha.

Continúa desde tu propio código del post 6 si lo has ido construyendo. Si no, empieza desde su estado final:

git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-06-shared-store

¿De quién es el estado de cliente?

En esta app ya viven dos clases de estado, cada una con su dueño, y entre ambas está la interacción que cruza.

La caché de servidor es de todos. El post 6 la construyó: un baseApi en el contrato, un store en el host, endpoints inyectados por quien responde de los datos. Ninguna entrada de la caché pertenece a una app concreta. Los datos son de PokéAPI y la caché solo guarda una copia, y por eso cualquier módulo puede leerla.

El estado privado tiene un dueño y se queda dentro de él. Qué miembros tiene el equipo, cómo se elimina un miembro, qué significa el tope: todo eso es asunto de la app party, en un slice que nadie más importa.

El punto de contacto entre apps es la franja estrecha entre ambos. La Pokédex necesita añadir un miembro a un equipo que no es suyo, y su cabecera quiere mostrar “My Party 3/6” con un estado que no puede ver. Esos cruces se tipan, se nombran, se versionan y se ponen en el contrato, porque la regla del ensayo de propiedad también manda aquí: las apps nunca dependen entre sí. Las apps dependen de contratos.

Esa taxonomía decide todo lo demás del post. Lo que sigue construye esa tabla fila a fila.

El muro y la mudanza del reducer

El slice de la app party tiene que unirse al store en marcha, y Redux Toolkit (RTK) tiene una API exactamente para esto: combineSlices construye un reducer con un método inject, y un slice inyectado en runtime empieza a reducir desde ese momento. Así que la app party necesita llamar a inject sobre el objeto reducer que el store del host montó de verdad.

El host del post 6 construía ese reducer inline:

// apps/host/src/store.ts, en el post 6
export const store = configureStore({
  reducer: combineSlices(baseApi),
  middleware: getDefaultMiddleware => getDefaultMiddleware().concat(baseApi.middleware),
});

No hay forma de llegar a él. El reducer es una expresión dentro del código fuente del host, nunca exportada, y ni siquiera un export ayudaría: las apps no importan apps. Una copia es peor que inútil: combineSlices(baseApi) en la app party construye un segundo reducer que ningún store ejecuta, e inyectar en él no cambia nada en pantalla.

Es el argumento de identidad del post 6, por tercera vez. baseApi se mudó al contrato porque un consumidor solo puede inyectar endpoints en la misma instancia que el store montó. El reducer raíz se muda por la misma razón, al archivo de al lado. packages/contracts/src/store.ts:

import { combineSlices } from '@reduxjs/toolkit';
import { baseApi } from './api';

export const rootReducer = combineSlices(baseApi);

Y el store del host se reduce a un import:

// apps/host/src/store.ts
import { configureStore } from '@reduxjs/toolkit';
import { baseApi, rootReducer } from '@pokedex/contracts';

export const store = configureStore({
  reducer: rootReducer,
  middleware: getDefaultMiddleware => getDefaultMiddleware().concat(baseApi.middleware),
});

El store sigue siendo del host: el middleware, el Provider, todo el montaje. El paquete de contratos pone ahora el reducer igual que pone la instancia de baseApi. Como contracts es un singleton de federación, rootReducer es un solo objeto en todo el runtime, y un slice inyectado por una app construida meses después del shell acaba en el reducer que el shell ya ejecuta.

Lo que cruza va en el contrato

Ahora la fila central de la taxonomía. Las interacciones que cruzan los límites entre apps van a un nuevo packages/contracts/src/party.ts, corto a propósito:

import { createAction, nanoid } from '@reduxjs/toolkit';

export const MAX_PARTY = 6;

export interface PartyMember {
  uid: string;
  id: number;
  name: string;
  spriteUri: string;
}

export const addToParty = createAction(
  'party/add',
  (member: Omit<PartyMember, 'uid'>) => ({ payload: { ...member, uid: nanoid() } }),
);

export const partyStateReady = createAction('party/stateReady');

export interface PartySliceShape {
  party?: { members: PartyMember[] };
}

addToParty es la única acción que puede despachar un módulo ajeno a party. El contrato define la forma de la acción; el reducer de la app party decide lo que significa. El callback prepare estampa cada miembro con un nanoid() (viene dentro de RTK, sin dependencia nueva) para que el reducer se mantenga puro y dos copias del mismo Pokémon sigan siendo distinguibles. Los duplicados están permitidos por diseño: un equipo de seis Magikarp es una decisión tan válida como cualquier otra, y el uid es lo que los distingue cuando quitas uno.

MAX_PARTY está en el contrato porque cada superficie que refleja la regla tiene que leer el mismo número: un botón deshabilitado en una app, un contador en otra, la guarda en el reducer del dueño del estado.

partyStateReady es la rara del grupo: ningún case del reducer la atiende, y nada del party cambia cuando se despacha. Existe para la secuencia de arranque, y la sección sobre cargar módulos de estado enseña lo único útil que hace.

PartySliceShape es el lado de lectura, y que el campo party sea opcional forma parte del diseño, no es una simple precaución. El slice lo inyecta en runtime un módulo que quien lee no controla, así que en el momento en que un módulo ajeno consulta el estado, state.party puede no existir todavía. Quien lee escribe s.party?.members ?? [] y renderiza algo honesto en ambos casos. Existen alternativas tipadas (withLazyLoadedSlices de RTK encaja el slice posiblemente ausente mediante declaration merging), pero la forma tolerante enseña la situación en lugar de esconderla, y el sabotaje de Ahora rómpelo depende de que la entiendas.

Fíjate en lo que falta. El equipo también tendrá una acción remove, y no aparece en ningún contrato, porque nadie más la despacha. El contrato lleva lo que cruza, nada más; una entrada que nadie consume es un pasivo con número de versión.

Los dos archivos nuevos son aditivos, así que la versión es una minor; el tag lleva 3.1.3, la minor más los mismos parches de endurecimiento que se llevó la línea 3.0.x. Publica:

cd packages/contracts
npm install && npm publish
+ @pokedex/contracts@3.1.3

Las apps host y list están ambas en ^3.0.0, y aquí el lockfile importa más que el caret. Un npm install normal no cambia nada: el lockfile fija 3.0.3, y npm install respeta el lockfile aunque el caret aceptaría 3.1.3. Avanzar un caret tiene su propio comando:

( cd apps/host && npm update @pokedex/contracts )

Sin editar package.json; solo se mueve una línea del lockfile. El pin solo se toca cuando un consumidor cruza una major. Y hay un consumidor a punto de hacerlo.

El dueño

La app party ha sido el grupo de control de la serie: contrato en ^1.0.0, detail en ^1.0.0, sin acceso al store, una cuadrícula estática de seis huecos vacíos. Añadir un slice acaba con todo eso a la vez, y la adopción se hace a mano, a propósito, porque dos majors son una distancia real. apps/party/package.json:

"@pokedex/contracts": "^3.1.0",
"@pokedex/detail": "^3.1.0",
"@reduxjs/toolkit": "^2.12.0",
"react-redux": "^9.3.0"

npm install, y el build se rompe de inmediato, en el compilador y no en runtime. El stack de la app party todavía monta la pantalla de detalle a la manera 1.0.0: import PokemonDetailScreen from '@pokedex/detail', y lo pasa directamente a component=. El paquete 3.x ya no tiene default export, así que ese import ahora se resuelve como el objeto que representa el namespace del paquete, y el montaje deja de pasar la comprobación de tipos:

error TS2322: Type 'typeof import(".../@pokedex/detail/dist/index")' is not
assignable to type 'ScreenComponentType<PartyParamList, "PokemonDetail"> | undefined'.

El paquete detail ha cruzado dos majors alrededor de esta app mientras no miraba, de 1.x a 3.x. Lo que 3.x exporta es la PokemonDetailView con nombre, una vista que exige los datos como props, así que el error de compilación obliga a party a escribir el mismo contenedor de cuatro líneas que la app list escribió en el post 6. Un contenedor necesita datos, y eso será otro obstáculo que veremos en El equipo necesita datos propios. Cadenas así son lo que de verdad cuesta el retraso de versiones: no un crash en producción, una pila de muros el día que por fin adoptas.

El slice es el corazón del post, y cabe en una pantalla. apps/party/src/partySlice.ts:

import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import { addToParty, MAX_PARTY, rootReducer, type PartyMember } from '@pokedex/contracts';

export const partySlice = createSlice({
  name: 'party',
  initialState: { members: [] as PartyMember[] },
  reducers: {
    // Private: nobody else dispatches remove, so it ships in no contract.
    remove(state, action: PayloadAction<string>) {
      state.members = state.members.filter(m => m.uid !== action.payload);
    },
  },
  extraReducers: builder => {
    builder.addCase(addToParty, (state, { payload }) => {
      if (state.members.length >= MAX_PARTY) return; // the cap lives with the owner
      state.members.push(payload);
    });
  },
});

export const { remove } = partySlice.actions;

// Importing this module is what adds the reducer to the shared store.
rootReducer.inject(partySlice);

Dos cosas sostienen el diseño. La acción que cruza se enlaza con extraReducers: party responde al addToParty del contrato. Conviene ser preciso con por qué eso funciona entre apps construidas por separado, porque es fácil atribuirlo a lo que no es. builder.addCase lee actionCreator.type y registra su reducer bajo ese string, así que lo que tiene que coincidir es party/add, no la identidad del objeto creator. Dos copias del contrato también encajarían. Definir el string, el tope y la forma de lectura una sola vez en lugar de reescribirlos en cada lado es trabajo del paquete versionado: publicar el contrato es lo que evita esa deriva, haya singleton o no. Donde la identidad singleton en runtime se gana el sitio es en los objetos, rootReducer y baseApi: una inyección solo aterriza en el store que conectó el host si los dos lados sostienen la misma instancia.

Y la guarda del tope vive aquí, en el dueño. El contrato publica el número; solo el dueño lo hace cumplir. Quien despacha, aunque olvide deshabilitar su botón, sigue sin poder meter un séptimo miembro.

La última línea es el mecanismo. inject recibe el slice en sí (createSlice usa name como reducerPath por defecto, así que el estado se monta en state.party, encajando con PartySliceShape), e importar el módulo es lo que ejecuta la inyección. Eso convierte al slice en un módulo de estado: un módulo federado cuyo valor es su efecto secundario. La config del bundler de la app party lo expone junto al stack:

exposes: {
  './PartyStack': './src/PartyStack.tsx',
  './partySlice': './src/partySlice.ts',
},

La app party también se une al trío de estado en su mapa shared: @reduxjs/toolkit y react-redux con el version declarado a mano que todo paquete con mapa exports necesita en esta config, @pokedex/contracts como singleton, ninguno configurado como eager, exactamente como los declara la app list. La misma regla del post 6: el host provee las copias, los remotes las consumen.

Una observación del bucle de desarrollo, porque RTK se protege de un peligro aquí: sobre el papel, re-ejecutar este módulo intenta colar una función con identidad nueva en un reducerPath existente, y el inject de RTK se niega a reemplazarla (un error de consola en desarrollo; el reducer original sigue vivo). En este montaje con Re.Pack el peligro se queda en teoría: editar el archivo del slice recarga la app en lugar de intercambiar el módulo en caliente, así que obtienes un store nuevo en vez de una doble inyección. Merece la pena saber cuál de las dos cosas hace tu stack antes de fiarte de ninguna.

Con estado que renderizar, la pestaña provisional se convierte en pantalla. Las líneas interesantes de PartyScreen.tsx:

import { useDispatch, useSelector } from 'react-redux';
import { MAX_PARTY, type PartySliceShape } from '@pokedex/contracts';
import { remove } from './partySlice';

const members = useSelector((s: PartySliceShape) => s.party?.members ?? []);

Los huecos llenos renderizan el sprite, el nombre y un control de quitar que despacha remove(member.uid); los vacíos conservan el borde discontinuo hasta seis; la cabecera cuenta {members.length}/{MAX_PARTY}; tocar un miembro abre PokemonDetail con { id }, el mismo DetailParams del post 5, sin campos nuevos. Fíjate en que el dueño lee su propio estado a través de la forma tolerante. Incluso aquí el party opcional se gana el sitio: el primer render puede producirse antes de que llegue la primera acción al store, y la inyección registra el reducer pero deja state.party en undefined hasta que llegue esa acción.

Los módulos de estado cargan en el arranque

El slice se inyecta cuando su módulo se importa. De momento solo lo importa una cosa: la pantalla de party (que también importa remove). Eso significa que el slice existe solo después de que el usuario abra la pestaña Party, y la Pokédex va a despachar acciones hacia él mucho antes. Alguien tiene que cargar el módulo de estado pronto, y solo hay un participante que esté activo en el arranque pase lo que pase: el shell. apps/host/App.tsx, dentro de un efecto:

// Screens load on demand; state modules load at boot.
useEffect(() => {
  import('partyApp/partySlice')
    .then(() => store.dispatch(partyStateReady()))
    .catch(err => console.warn('party state module failed to load', err));
}, []);

El host ya declara partyApp en su mapa de remotes, así que esto no añade ningún acoplamiento que no tuviera. Lanza el import y no guarda ninguna referencia al contenido del módulo: la declaración ambient tipa el módulo como vacío, porque el host lo importa por el efecto secundario y no tiene por qué nombrar nada de dentro. Las pantallas siguen siendo lazy, porque aplazar una pantalla que el usuario no ha abierto no cuesta nada.

Cargar en el arranque es una ventaja de salida, no una garantía. El chunk cruza la red, y nada impide que un dedo rápido llegue a un botón Add antes que él. Un party/add despachado en un store que no tiene reducer para él desaparece sin dejar rastro: ni aviso ni error, porque Redux ignora por diseño una acción que nadie atiende. Así que el resolve se hace visible, y aquí es donde partyStateReady se gana el sueldo. inject() cambia una entrada del mapa de reducers y reconstruye el reducer combinado, pero nunca despacha, así que state.party sigue indefinido hasta que la siguiente acción pase por el reducer nuevo. El marcador es esa acción: el .then la despacha en cuanto el módulo resuelve, state.party aparece, y cada suscriptor vuelve a renderizar con el slice en su sitio.

El lado de escritura cierra el círculo justo ahí: el contenedor de La escritura entra por una prop deshabilita Add mientras s.party sea indefinido. Un toque que no puede ocurrir es un dispatch que no se puede perder.

La colocación del efecto es una observación, no un diagnóstico. En el ámbito del módulo, este import producía Can't perform a React state update on a component that hasn't mounted yet en algunos arranques en frío y en otros no, y moverlo a un efecto hizo desaparecer el aviso en arranques en frío repetidos. Qué produce esa actualización es algo que este post no ha llegado a fijar: rootReducer.inject() no es el culpable, porque cambia una entrada del mapa de reducers y reconstruye el reducer combinado sin despachar nada ni llamar a replaceReducer, así que por sí solo no avisa a nadie. Hasta que la actualización se rastree hasta su origen, trata la colocación como una observación local: el efecto es el montaje que hizo callar el aviso, y un efecto es donde React espera que viva un efecto secundario.

Nada espera ese import, así que un servidor de party caído no puede bloquear el arranque. Esa prueba merece hacerse en lugar de darla por buena. Detén el dev server de party y arranca la app en frío: el runtime de federación avisa por consola en desarrollo de que el fetch del manifest ha fallado ([ Federation Runtime ]: Failed to get manifest. #RUNTIME-003), y el shell sigue adelante. La Pokédex renderiza, el contador muestra 0/6, que refleja el estado real, gracias a la forma tolerante, el botón Add se queda deshabilitado porque partyStateReady no llegó a dispararse, y la app funciona sin el slice, que es exactamente el estado que PartySliceShape fue diseñada para describir.

Un detalle de la ejecución observada: el import se resuelve sin rechazar, así que el .catch nunca llega a dispararse en este fallo; el runtime lo contiene y lo reporta por su cuenta. El catch se queda: una línea que cubre el caso del rechazo para que una carga fallida nunca acabe siendo un unhandled rejection.

La escritura entra por una prop

En el lado de la Pokédex, el cruce empieza en la vista de detalle, que es una librería de componentes instalada: el único sitio donde la escritura no debe vivir. El ensayo de propiedad trazó esta línea: un componente compartido renderiza lo que le dan; una escritura que cruza una frontera de dominio la conecta el consumidor. Así que @pokedex/detail 3.1.0 es una minor aditiva con tres props opcionales:

export interface PokemonDetailViewProps {
  pokemon?: PokemonDetail;
  loading: boolean;
  error: boolean;
  onRetry: () => void;
  onAddToParty?: () => void;
  addDisabled?: boolean;
  addLabel?: string;
}

La vista renderiza un botón cuando un consumidor le pasa onAddToParty y no renderiza nada cuando no. Sigue sin importar store, ni contrato, ni action creator. La escritura cruza una frontera de dominio, así que viaja como callback y se activa con un toque.

El contenedor de la app list conecta las tres:

function PokemonDetailRoute({ route }: { route: { params: DetailParams } }) {
  const { data, isLoading, isError, refetch } = useGetPokemonDetailQuery(route.params.id);
  const dispatch = useDispatch();
  const members = useSelector((s: PartySliceShape) => s.party?.members);
  const partyReady = members !== undefined;
  const count = members?.length ?? 0;
  const full = count >= MAX_PARTY;
  return (
    <PokemonDetailView
      pokemon={data}
      loading={isLoading}
      error={isError}
      onRetry={refetch}
      onAddToParty={() =>
        data && dispatch(addToParty({ id: data.id, name: data.name, spriteUri: data.spriteUri }))
      }
      addDisabled={full || !partyReady}
      addLabel={full ? 'Party is full' : 'Add to party'}
    />
  );
}

Lee lo que hace ese toque. Una pantalla del equipo Pokédex despacha una acción creada por el contrato, y esa acción acaba en un reducer del equipo party. Tres participantes, y ninguno importa el código de otro. El estado deshabilitado lee el mismo MAX_PARTY que el reducer usa como guarda, así que el botón y el tope no pueden desviarse. También mantiene el botón abajo hasta que state.party existe, así que el único dispatch que podría perderse es el que no se puede llegar a hacer. Y la cabecera de la Pokédex obtiene su contador con la lectura idéntica:

const partyCount = useSelector((s: PartySliceShape) => s.party?.members.length ?? 0);
// ...
<Text style={styles.partyCount}>My Party {partyCount}/{MAX_PARTY}</Text>

La app list no edita ningún pin de versión para nada de esto: estaba en ^3.0.0 en ambos paquetes, y npm update @pokedex/contracts @pokedex/detail avanza los dos carets hasta las minors nuevas. Solo la app rezagada tocó su package.json. Ese es el comportamiento previsto del caret: una minor llega cuando la pides; una major exige editar a mano.

El contenedor del equipo es el otro consumidor de la misma vista, y no conecta ninguna de las tres props. Un Pokémon abierto desde dentro del equipo no muestra ningún botón de añadir:

La pantalla de detalle abierta desde la pestaña Party: la misma vista compartida sin botón de añadir, porque el contenedor del equipo no conecta ninguna escritura

Una vista, dos consumidores, uno de ellos conectando una escritura. Esa captura es la regla de componentes del ensayo de propiedad en acción.

El equipo necesita datos propios

Ese contenedor del lado de party es el obstáculo que anunciamos antes. Tocar un miembro del equipo abre PokemonDetail dentro del stack de party, y el contenedor que lo alimenta necesita getPokemonDetail, que vive en la app list, que la app party no puede importar. Las apps dependen de contratos, nunca entre sí, y esta es la primera vez que la regla cuesta algo visible.

La salida tentadora es un paquete de datos compartido: @pokedex/data, con el endpoint que ambas apps necesitan. Eliminaría la duplicación, y para dos features de un mismo equipo hasta podría ser lo correcto. El precio es el que el ensayo de propiedad puso encima de la mesa: un paquete compartido pone el acceso a datos de party en el tren de releases de otro equipo. Las releases compatibles no obligan a nadie a moverse, pero el día que party necesite un arreglo en ese paquete, o aterrice una versión que rompe, la fecha de salida de party espera a la revisión y la release de otro equipo. Veinte líneas de definición de query no justifican ese acoplamiento. La app party escribe su propio apps/party/src/detailApi.ts con el mismo nombre de endpoint, el mismo queryFn y el mismo parsePokemonDetail del contrato, copiado a propósito:

const detailApi = baseApi.injectEndpoints({
  endpoints: build => ({
    getPokemonDetail: build.query<PokemonDetail, number>({
      // byte for byte, the list app's definition
    }),
  }),
});

export const { useGetPokemonDetailQuery } = detailApi;

Dos apps inyectan ahora un endpoint llamado getPokemonDetail en un solo baseApi, y RTK se da cuenta. Ejecuta la app, abre la pestaña Party, y la consola de desarrollo imprime:

called `injectEndpoints` to override already-existing endpointName getPokemonDetail without specifying `overrideExisting: true`

Si lo dejas así, RTK omite la segunda inyección y conserva la primera: con un aviso en desarrollo y en silencio en producción, donde esa guarda se compila fuera. Este build declara el duplicado en su lugar: las dos apps pasan overrideExisting: true, el error de consola desaparece y gana la última inyección. En cualquier caso decide el orden de carga, y ninguna de las dos apps controla el orden de carga. Esa es la razón honesta por la que las dos definiciones tienen que ser idénticas y no solo parecidas.

La caché no cambia: un solo nombre de endpoint significa un solo conjunto de entradas, así que abrir a Bulbasaur desde la Pokédex y luego desde el party cuesta un fetch en total. La trampa es la deriva: se declare o no, la copia que favorezca el orden de carga gana en silencio para las dos, y una vez que overrideExisting ha callado la consola ya no queda aviso que ignorar. La regla, entonces: mantén las copias idénticas byte a byte, declara la colisión para que se vea deliberada, o ponle a tu endpoint un nombre propio.

Ahora rómpelo

La afirmación que vamos a poner a prueba: la guarda sobre state.party es lo que se interpone entre un chunk lento y un toque perdido. Primero quita la guarda (devuelve el addDisabled del contenedor a un simple full) y después comenta el import de arranque:

// useEffect(() => {
//   import('partyApp/partySlice')
//     .then(() => store.dispatch(partyStateReady()))
//     .catch(err => console.warn('party state module failed to load', err));
// }, []);

Vuelve a arrancar la app en frío y no abras la pestaña Party. Eso importa: PartyScreen importa remove del archivo del slice, así que visitar la pestaña inyecta el slice como efecto secundario y esconde el bug. Un usuario que va directo a la Pokédex es la forma de reproducirlo.

Toca Bulbasaur. Toca Add to party. El toque se registra, el contador sigue en 0/6, y no pasa nada más. Sin aviso, sin error, sin línea de consola. La prueba de fallo del post 6 al menos dejaba un spinner colgado que podías mirar. Aquí el dispatch llegó al store, el store no encontró ningún reducer registrado para party/add, y Redux hizo lo que Redux hace con una acción sin destinatario: nada, por diseño. El toque del usuario llegó a un store sin nada registrado que lo atendiera, y Redux se comporta exactamente como está documentado.

Ahora digamos lo peor en voz alta. Sin el import de arranque, el slice acaba existiendo tarde o temprano: una visita a la pestaña Party lo inyecta, y a partir de ahí todos los añadidos funcionan. Así que el fallo es más concreto y más desagradable: cada intento anterior a que el usuario abra por casualidad la pestaña del dueño se pierde en silencio, y eso se reproduce para unos usuarios y para otros nunca, según el orden de pestañas. Y el import de arranque por sí solo únicamente estrecha la ventana, de «hasta que el usuario abra la pestaña Party» a «hasta que aterrice el chunk». Cerrarla no puede, porque la red no te debe nada.

Cerrarla es trabajo de la guarda: devuelve la guarda, deja el import comentado, y el mismo arranque en frío muestra un botón Add sencillamente deshabilitado. Feo, visible y honesto: un toque que no puede ocurrir en lugar de un toque que miente.

Restaura el import, relanza, y el botón despierta en cuanto se dispara partyStateReady; el mismo toque sube el contador a 1/6. El patrón son las tres piezas: el import de arranque para la ventaja de salida, el marcador para que el slice aflore, y la guarda para la ventana que ninguno de los dos puede cerrar.

Termina exactamente en el estado final de este post. El recorrido imprime los archivos que sostienen el build; los manifests, las configs, los tests y los cambios menores viven en el tag. Para acabar con un árbol idéntico byte a byte al del tag, vuelca encima la copia de referencia. Un archivo que escribiste bien se sobrescribe consigo mismo, y el volcado rellena lo que la prosa no imprimió:

npx degit@3.8.0 --force warrendeleon/react-native-module-federation#post-08-client-state /tmp/pokedex-ref-08
cp -R /tmp/pokedex-ref-08/. .

El volcado es también cómo el comportamiento consigue su prueba: lleva los dos tests de regresión que exige el diseño de este post (el store de party recorrido por la ventana desaparecer-inyectar-marcador-añadir, y la guarda del Add del contenedor de list mantenida abajo hasta que hay readiness), los mocks y el montaje de Jest sobre el que corren, y las declaraciones ambient de federación que el recorrido resumió en vez de imprimir.

Ejecútalo

Los paquetes están publicados e instalados, así que para ejecutarlo hacen falta tres dev servers y un build en el simulador:

cd apps/list && npm run start:remote      # :8082
cd apps/party && npm run start:remote     # :8083
cd apps/host && npm start                 # :8081
cd apps/host && npm run ios

El arranque en frío abre directamente la Pokédex con el contador en 0/6. No lo leas como prueba de que el import de arranque ha funcionado: la forma tolerante devuelve 0/6 tanto si el slice está registrado como si todavía falta, que es justo para lo que está. La prueba es que el botón Add esté habilitado siquiera, ya que solo despierta cuando partyStateReady ha sacado el slice a la luz. Añade desde un detalle, mira subir el contador y busca al miembro en la pestaña Party:

El flujo de añadir en iOS: la cabecera de la Pokédex lee My Party 0/6, un detalle se abre con el botón Add to party, se pulsa, el contador sube a 1/6, y la pestaña Party muestra a Bulbasaur en el primer hueco

Sigue añadiendo hasta seis y el tope actúa en los dos sitios a la vez: la sexta incorporación deshabilita en vivo el botón del detalle que tengas abierto, porque addDisabled usa el mismo selector que la cabecera:

La pantalla de detalle con el equipo al tope: el botón de añadir deshabilitado y con el texto cambiado a Party is full

Quita un miembro en la pestaña Party y el hueco liberado vuelve al borde discontinuo, el contador baja en todas partes a la vez, y la siguiente incorporación funciona. Un slice, un dueño y tres superficies que muestran lo mismo porque todas leen el mismo estado con la misma forma.

Lo que has construido, y lo que viene

La app ya posee algo. El equipo vive en un slice que pertenece solo a la app party, registrado en el arranque en un store que el host montó alrededor del rootReducer del contrato. Registrado, no rellenado: state.party sigue ausente hasta que la siguiente acción llega al reducer combinado. El marcador partyStateReady es esa acción, y la forma tolerante de lectura cubre cada instante anterior a que se dispare. La única interacción que cruza (addToParty, su tope y la forma de leerlo) va versionada en el contrato, la escritura llega a la vista de detalle compartida como una prop que la conecta su consumidor, y la app party trae sus propios datos en lugar de atarse al calendario de releases de otro equipo. También has visto el fallo que este diseño existe para prevenir: un dispatch dirigido a un slice cuyo dueño nunca cargó, descartado sin hacer ruido.

Queda una limitación honesta: reinicia la app y el equipo desaparece. El slice vive en memoria, la persistencia queda para otro post, y fingir lo contrario sería la clase de ampliación silenciosa de alcance que esta serie intenta evitar.

La pregunta incómoda es la que este stack no deja de plantear. El equipo no es más que una lista de seis y una regla, y cruzar la frontera como es debido costó un store, una minor del contrato, un reducer inyectado y un import de arranque. RTK hizo posible el cruce; no lo hizo pequeño. Lo siguiente: la serie reconstruye esta misma app sobre TanStack Query y Zustand, el stack con el que empieza la mayoría de equipos de React Native, y ve qué pasa con esa elección cuando entra la federación.

Fuentes

Warren de Leon
Warren de Leon

Software Engineering Manager. Recientemente lideré el equipo de Mobile Platform en Hargreaves Lansdown. Escribo sobre liderazgo técnico, React Native y cómo construir buenos equipos.

Ver perfil