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 de los que es dueña PokéAPI, guardados en una caché que todos los módulos comparten. La app en sí todavía no posee nada.
Este post le da algo que poseer: 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 bajo federación un slice plantea la pregunta alrededor de la cual gira toda esta serie: ¿quién es su dueño, y cómo lo tocan las demás features sin tocar a su dueño? 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 tiene exactamente un dueño. La app party es dueña del equipo. Todo lo que otro módulo necesita (una acción, una forma de lectura, un tope) cruza a través del contrato, y cerca del final despachamos contra el slice antes de que su dueño haya cargado, 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, parte de su estado final:
git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-06-shared-store
¿Quién es dueño del estado de cliente?
En esta app ya viven tres clases de estado, y cada una tiene un dueño distinto.
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 posee los datos. Nadie es dueño de una entrada de la caché; PokéAPI lo es.
El estado privado tiene un dueño y se queda dentro de él. Qué miembros tiene el equipo, cómo funciona quitar uno, qué significa el tope: todo eso es asunto de la app party, en un slice que nadie más importa.
La interacción que cruza es la franja estrecha entre ambos. La Pokédex necesita añadir un miembro a un equipo que no posee, 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 el reducer se muda
El slice del equipo 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 cableó 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 cableó. 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 queda en 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 host sigue siendo dueño del store: el middleware, el Provider, el cableado. El contrato ahora es dueño del reducer igual que es dueño de la instancia de api. 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 aterriza en el reducer que el shell ya ejecuta.
El contrato lleva el cruce
Ahora la fila central de la taxonomía. Las interacciones que cruzan una frontera de app 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 interface PartySliceShape {
party?: { members: PartyMember[] };
}
addToParty es la única acción que algo fuera del equipo despacha. El contrato es dueño de la forma de la acción; el reducer del equipo es dueño de 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 se sienta en la frontera 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.
PartySliceShape es el lado de lectura, y el marcador opcional es el diseño, no una defensa. 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 lee, 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 enhebra el slice posiblemente ausente vía declaration merging), pero la forma tolerante enseña la situación en lugar de esconderla, y el sabotaje del final de este post 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. Publica:
cd packages/contracts
npm install && npm publish
+ @pokedex/contracts@3.1.0
El host y la list están los dos 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.0, e install respeta el lockfile aunque el caret aceptaría 3.1.0. 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. Hacer crecer un slice acaba con eso en todos los frentes, 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, de forma útil, delante de la cámara. El stack del equipo todavía monta la pantalla de detalle a la manera 1.0.0: import PokemonDetailScreen from '@pokedex/detail', directo a component=. El paquete 3.x ya no tiene default export, así que ese import ahora resuelve al objeto namespace del paquete, y el montaje deja de pasar el chequeo de tipos:
error TS2322: Type 'typeof import(".../@pokedex/detail/dist/index")' is not
assignable to type 'ScreenComponentType<PartyParamList, "PokemonDetail"> | undefined'.
Tres majors del paquete detail le han pasado de largo a esta app mientras no miraba. 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 ocho líneas que la app list escribió en el post 6. Un contenedor necesita datos, y eso se convierte en un muro propio dentro de dos secciones. 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 cargan con el diseño. La acción que cruza se ata vía extraReducers: el equipo atiende el addToParty del contrato, y esa correspondencia funciona entre apps construidas por separado porque ambos lados sostienen el mismo objeto action creator. Contracts es un singleton, así que party/add es un solo creator, no dos que casualmente comparten un string. Y la guarda del tope vive aquí, en el dueño. El contrato publica el número; solo el dueño lo hace cumplir. Un despachador que 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 del equipo 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 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 de relleno 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 empuja 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 opcional se gana el sitio: el primer render puede llegar al store antes que la primera acción, y la inyección registra el reducer pero deja state.party en undefined hasta que llegue la siguiente acción.
Los módulos de estado cargan en el arranque
El slice se inyecta cuando su módulo se importa. De momento lo importan dos cosas: la pantalla del equipo (que también importa remove), y nada más. Eso significa que el slice existe solo después de que el usuario abra la pestaña Party, y la Pokédex está a punto de despachar contra él mucho antes. Alguien tiene que cargar el módulo de estado pronto, y solo un participante está vivo en el arranque en todos los caminos: el shell. apps/host/App.tsx, una línea a nivel de módulo:
// Screens load on demand; state modules load at boot.
import('partyApp/partySlice').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. Dispara el import y no guarda referencia al resultado: 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. El estado carga en el arranque, porque el estado tiene que existir antes del primer dispatch que le apunte.
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 reporta el fetch del manifest fallido bien alto en desarrollo ([ Federation Runtime ]: Failed to get manifest. #RUNTIME-003), y el shell sigue adelante. La Pokédex renderiza, el contador lee un 0/6 honesto a través de la forma tolerante, 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 guarda el camino del rechazo para que una carga fallida nunca acabe siendo un unhandled rejection.
La escritura llega como prop
El lado Pokédex del cruce empieza en la vista de detalle, y la vista de detalle 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 cablea 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 llega como callback y sale como un toque.
El contenedor de la app list cablea las tres:
function PokemonDetailRoute({ route }: { route: { params: DetailParams } }) {
const { data, isLoading, isError, refetch } = useGetPokemonDetailQuery(route.params.id);
const dispatch = useDispatch();
const count = useSelector((s: PartySliceShape) => s.party?.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}
addLabel={full ? 'Party is full' : 'Add to party'}
/>
);
}
Lee lo que hace ese toque. Una pantalla del equipo Pokédex despacha un creator del contrato, y la acción aterriza en un reducer del equipo party. Tres partes, y ninguna importa el código de otra. 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. 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. Así se pensó el caret: una minor llega al pedirla; una major exige editar a mano.
El contenedor del equipo es el otro consumidor de la misma vista, y no cablea ninguna de las tres props. Un Pokémon abierto desde dentro del equipo no muestra ningún botón de añadir:
Una vista, dos consumidores, uno de ellos cableando una escritura. Esa captura es la regla de componentes del ensayo de propiedad en plena ejecución.
El equipo necesita datos propios
Ese contenedor del lado party es el muro prometido antes. Tocar un miembro del equipo empuja PokemonDetail dentro del stack del equipo, y el contenedor de detrás 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, dueño del 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: cada consumidor de un paquete compartido se mueve al compás de sus releases, así que los cambios de endpoint del equipo Pokédex pasarían a frenar las fechas del equipo party. 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`
La segunda inyección se omite: con ruido en desarrollo, en silencio en producción, donde esa guarda se compila fuera. Ambas apps comparten la definición que cargó primero, y con ella sus entradas de caché: abre a Bulbasaur desde la Pokédex y luego desde el equipo, y la segunda apertura es un acierto de caché, un fetch en total. Definiciones idénticas hacen que el salto sea inofensivo; aquí es casi la gracia. La trampa es la deriva: si las dos copias divergen algún día, la que cargue primero gana en silencio para ambas, y el ruido de consola que aprendiste a ignorar era el único aviso. La regla, entonces: inyecta una definición idéntica y deja que el salto deduplique, pasa overrideExisting: true solo como acto deliberado, o ponle a tu endpoint un nombre propio.
Ahora rómpelo
La afirmación que atacar: el import de arranque del shell es lo que hace fiable al slice del equipo, y no solo educado. Coméntalo:
// import('partyApp/partySlice').catch(err =>
// console.warn('party state module failed to load', err),
// );
Relanza 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 aterriza, el contador lee 0/6, y no pasa nada más. Sin aviso, sin error, sin línea de consola. El rómpelo del post 6 al menos colgaba un spinner 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 cayó en un vacío que antes era un error.
Ahora digamos la parte más afilada 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 cada añadir de ahí en adelante funciona. Así que el fallo tiene una forma más estrecha y más fea: cada añadir hecho antes de 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. Esa clase de bug es la razón de que el patrón sea un import de arranque y no una esperanza: las pantallas cargan bajo demanda, los módulos de estado cargan en el arranque, y la diferencia es si la forma del store depende del historial de rutas del usuario.
Restaura la línea, relanza, y el mismo toque sube el contador a 1/6.
Ejecútalo
Los paquetes están publicados e instalados, así que la ejecución son 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 aterriza en la Pokédex con el contador ya en 0/6: el import de arranque ha hecho su trabajo antes de que nada del equipo haya renderizado. Añade desde un detalle, mira el contador subir, y encuentra al miembro sentado en la pestaña Party:
Sigue añadiendo hasta seis y el tope llega por los dos lados a la vez: el sexto añadir cambia en vivo el botón del detalle abierto a su estado deshabilitado, porque el mismo selector que cuenta para la cabecera cuenta para addDisabled:
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 el siguiente añadir funciona. Un slice, un dueño, tres superficies de acuerdo porque todas leen el mismo estado a través de la misma forma.
Lo que has construido, y lo que viene
La app ya posee algo. El equipo vive en un slice del que solo la app party es dueña, inyectado en el arranque en un store que el host cableó alrededor del rootReducer del contrato. La única interacción que cruza (addToParty, su tope, su forma de lectura) viaja versionada en el contrato, la escritura llega a la vista de detalle compartida como una prop que cablea su consumidor, y la party trae sus propios datos en lugar de tomar prestado el calendario de releases de otro equipo. También has visto el fallo que este diseño existe para prevenir: un dispatch contra 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 es problema de otro post, y fingir lo contrario sería la clase de ampliación silenciosa de alcance que esta serie intenta evitar.
La pregunta más afilada es la que este stack no deja de plantear. El equipo son seis elementos y una regla, y cruzar la frontera con educación 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 que la mayoría de equipos de React Native prueba primero, y observa qué le hace la federación a esa elección.
Fuentes
- Redux Toolkit:
combineSlices— el reducer raíz inyectable einject - Redux Toolkit:
createAction— los callbacks prepare, ynanoiddentro de RTK - RTK Query: code splitting —
injectEndpointsy la guardaoverrideExisting - PokéAPI — la API REST gratuita de la que la app hace fetch
- react-native-module-federation — el repo de acompañamiento, en el tag
post-08-client-state