Una app host con un único store compartido que dos features publicadas por separado llenan con datos en vivo de una API

Un store compartido: estado de servidor entre remotes federados en React Native

El post anterior terminó con una frase con un agujero: una pantalla que renderizaba “Opened from the ” y nada más, porque un tipo comprobado en build no sirve de nada frente a un valor que solo aparece en runtime. También terminó con una promesa: datos reales, un store, compartido entre los remotes. Este post cumple la promesa, y el agujero recibe su respuesta de verdad por el camino.

Se apoya en estado de servidor y estado de cliente, del breve desvío de la serie. La división entre los datos de los que es dueño un servidor y los datos de los que es dueña la app se da por sabida aquí, no se vuelve a explicar. Este post trata de una de sus mitades, el estado de servidor, bajo federación: un store de Redux Toolkit (RTK) en el host, una caché de RTK Query que el dominio Pokédex llena con sus dos endpoints, mientras la vista instalada de @pokedex/detail renderiza lo que le dan. Datos en vivo de PokéAPI reemplazan cada Pokémon hardcodeado de la app, incluidas las dos copias desviadas.

Esto es lo que vamos a construir, antes de cualquier código:

host el shellinjectEndpointsla alimenta desde sucontenedoruna sola instanciafetch, caché, dedupuseGetPokemonListQuery ·useGetPokemonDetailQueryrenderiza en una pestaña, notoca nadastore de Redux+ caché de RTK Query@pokedex/contractsel baseApi compartidoremote listinyecta getPokemonList +getPokemonDetail@pokedex/detail 3.0.0una vista props dentroremote partysin acceso al storePokéAPI

Lo único que hay que tener en la cabeza: baseApi es un solo objeto, y cada lado importa exactamente el mismo. Eso es lo que hace que la caché sea compartida. Rómpelo, y la app se rompe de una manera que merece la pena ver, así que lo vamos a romper a propósito cerca del final.

Continúa desde tu propio código del post 5 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-05-contracts

El paquete de contratos gana una superficie de runtime

Hasta ahora @pokedex/contracts solo ha tenido tipos. Cada export se borraba en build, así que nada suyo llegaba a un bundle. Ahora gana su primer export de runtime: el objeto de API de RTK Query a través del cual hace fetch toda la app.

El host parece el sitio natural para él. El host es dueño del store, el store es dueño de la caché, y un objeto de API junto al store que alimenta es donde lo pondría una app normal. En una app normal ese instinto es correcto. Bajo federación falla por identidad: un consumidor solo puede añadir endpoints a la misma instancia de baseApi que el store del host cableó, y una instancia que vive dentro del código fuente del host es una que nada más puede importar. El paquete de contratos es el único módulo que cada lado ya resuelve a una sola copia, porque es un singleton de Module Federation. Pon la instancia ahí y el baseApi.injectEndpoints({...}) de un consumidor se registra contra la única caché y el único middleware que el store ya ejecuta. Una instancia significa una caché HTTP, una deduplicación de peticiones, un grafo de tags entre todas las features, incluidas las que se publiquen mucho después del shell. packages/contracts/src/api.ts:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { z } from 'zod';

export const baseApi = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: 'https://pokeapi.co/api/v2/' }),
  tagTypes: ['PokemonList'],
  endpoints: () => ({}),
});

export interface PokemonSummary {
  id: number;
  name: string;
  spriteUri: string;
}

export interface PokemonDetail {
  id: number;
  name: string;
  spriteUri: string;
  types: string[];
}

const PokemonListResponseSchema = z.object({
  results: z.array(z.object({ name: z.string(), url: z.string() })),
});

const PokemonDetailResponseSchema = z.object({
  id: z.number(),
  name: z.string(),
  types: z.array(z.object({ type: z.object({ name: z.string() }) })),
});

export function artworkUri(id: number): string {
  return `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${id}.png`;
}

export function idFromResourceUrl(url: string): number {
  const match = url.match(/\/(\d+)\/?$/);
  return match ? Number(match[1]) : 0;
}

function formatName(name: string): string {
  return name
    .split('-')
    .map(word => word.charAt(0).toUpperCase() + word.slice(1))
    .join(' ');
}

export function parsePokemonList(raw: unknown): PokemonSummary[] {
  const { results } = PokemonListResponseSchema.parse(raw);
  return results.map(entry => {
    const id = idFromResourceUrl(entry.url);
    return { id, name: formatName(entry.name), spriteUri: artworkUri(id) };
  });
}

export function parsePokemonDetail(raw: unknown): PokemonDetail {
  const parsed = PokemonDetailResponseSchema.parse(raw);
  return {
    id: parsed.id,
    name: formatName(parsed.name),
    spriteUri: artworkUri(parsed.id),
    types: parsed.types.map(entry => formatName(entry.type.name)),
  };
}

createApi sin endpoints construye un armazón vacío: un reducer, algo de middleware, y un método injectEndpoints que los consumidores van a llamar. fetchBaseQuery es un envoltorio pequeño sobre fetch que antepone la URL base y parsea el JSON. tagTypes nombra la única etiqueta por la que esta app invalida; todavía no hace nada, y hace trabajo de verdad en la última sección.

Las dos funciones de parseo son la parte que señalaba la frase rota del post 5. Un tipo es una promesa de tiempo de compilación, y ya no existe cuando una respuesta aterriza de verdad. Un campo renombrado o un null donde antes había un string se cuela directo por un cast escrito a mano y revienta tres pantallas más allá. Así que cada respuesta cruda se valida con un esquema de Zod justo en la frontera, y una forma incorrecta se convierte en un valor que la pantalla puede manejar en vez de en un crash. El esquema del detail conserva solo lo que renderiza la pantalla de detail; el payload completo de PokéAPI para un Pokémon se acerca a los 300 KB de JSON, y parsear campos que nada muestra solo sería más superficie donde romperse. La validación en runtime tiene su propio post más adelante en la serie.

Un export de runtime y dos peer dependencies nuevas son un cambio que rompe, así que la versión sube de major. El número en sí necesita primero un vistazo a tu propio registro: la escalera del post 5 ya publicó 1.1.0 y 2.0.0, y un número publicado queda gastado para siempre, porque el registro se niega a reutilizarlo y cada install depende de esa negativa. Así que la superficie de runtime sale como 3.0.0. packages/contracts/package.json:

{
  "name": "@pokedex/contracts",
  "version": "3.0.0",
  "dependencies": {
    "zod": "^3.25.76"
  },
  "peerDependencies": {
    "@reduxjs/toolkit": ">=2.10.0",
    "react": "*",
    "react-redux": ">=9"
  }
}

zod es una dependencia real de runtime, así que viaja dentro del paquete. @reduxjs/toolkit y react-redux son peers: las apps los instalan, y el contrato usa sus copias en vez de empaquetar la suya. Instala las dependencias de desarrollo del paquete y publica:

cd packages/contracts
npm install
npm publish
+ @pokedex/contracts@3.0.0

El caret hace aquí lo que el post 5 mostró que hace. Cada consumidor está en ^1.0.0, así que npm install deja a las tres apps exactamente donde están aunque 3.0.0 ya exista en el registro. Adoptar el contrato nuevo es un movimiento deliberado, app por app, y una de las tres se queda quieta a propósito.

Un store en el host

El host gana un store. apps/host/src/store.ts:

import { combineSlices, configureStore } from '@reduxjs/toolkit';
import { baseApi } from '@pokedex/contracts';

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

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

El reparto de propiedad es el sentido del archivo. El store vive en el host: un store por app es trabajo del shell, como la barra de pestañas. La instancia de API alrededor de la que se construye el store vive en el paquete de contratos, por la razón de identidad de arriba. El host es dueño del cableado; el contrato es dueño de la instancia.

baseApi.middleware es una pieza estructural. Ejecuta el ciclo de vida de la caché: fetching, deduplicación, invalidación por tags, expulsión de caché. Déjalo fuera del store y la primera pantalla que ejecute un hook de query lanza un red box a pantalla completa en desarrollo, y RTK nombra el error sin rodeos:

Warning: Middleware for RTK-Query API at reducerPath "api" has not been added to the store.
You must add the middleware for RTK-Query to function correctly!
Un cuadro de error de React Native a pantalla completa: el middleware de la API de RTK-Query en el reducerPath 'api' no se ha añadido al store, con la pila de componentes apuntando al chunk federado del remote list

Merece la pena pararse en la pila de componentes de esa captura: el punto del crash es PokedexScreen dentro de __federation_expose_ListStack.chunk.bundle. La pantalla de un remote, en un chunk que el host descargó en runtime, choca con un error causado por una línea que falta en el archivo de store del propio host. El fallo cruza la frontera de módulos en la dirección contraria a todo lo demás de esta serie. Un fallo ruidoso, al menos. Tenlo presente, porque el sabotaje del final de este post no recibe ningún error.

El store entra por encima de todo el árbol, para que todo lo federado dentro pueda leer la caché. apps/host/App.tsx, el envoltorio:

import { Provider } from 'react-redux';
import { store } from './src/store';

export default function App() {
  return (
    <Provider store={store}>
      <SafeAreaProvider>
        <NavigationContainer>
          {/* el navegador de pestañas del post 4 */}
        </NavigationContainer>
      </SafeAreaProvider>
    </Provider>
  );
}

Los remotes nunca crean ni importan un store. Se renderizan dentro de este árbol y llegan al store a través del singleton compartido de react-redux, igual que llegaban al contexto compartido de safe-area en el post 3.

Comparte el trío de estado

Para que algo fuera del host inyecte en el baseApi compartido, tres paquetes tienen que resolverse a una sola copia en runtime: @reduxjs/toolkit, react-redux, y el propio @pokedex/contracts. Se unen al mapa de compartidos del host como singletons eager, y la lección que tanto costó en el post 5 se aplica a dos de ellos de inmediato. apps/host/rspack.config.mjs, las adiciones:

import rtkPkg from '@reduxjs/toolkit/package.json' with { type: 'json' };
import reactReduxPkg from 'react-redux/package.json' with { type: 'json' };

// ...en el mapa shared:
'@reduxjs/toolkit': {
  singleton: true,
  eager: true,
  version: rtkPkg.version,
  requiredVersion: pkg.dependencies['@reduxjs/toolkit'],
},
'react-redux': {
  singleton: true,
  eager: true,
  version: reactReduxPkg.version,
  requiredVersion: pkg.dependencies['react-redux'],
},
'@pokedex/contracts': {
  singleton: true,
  eager: true,
  requiredVersion: pkg.dependencies['@pokedex/contracts'],
},

Los dos paquetes de Redux declaran version a mano, y @pokedex/contracts no. El post 5 encontró la regla: rspack lee la versión del paquete que está compartiendo, salvo con un paquete resuelto a través de un mapa exports, donde en su lugar omite el provide en silencio. @reduxjs/toolkit y react-redux llevan los dos un mapa exports, así que sin el version explícito ninguno aterrizaría en el share scope. El paquete de contratos no tiene mapa exports, así que no necesita nada. El grep del bundle del post 5 confirma que los tres provides se registran, contrato incluido:

"@pokedex/contracts", version: "3.0.0"
"@reduxjs/toolkit", version: "2.12.0"
"react-redux", version: "9.3.0"

El remote list refleja las mismas tres entradas sin eager: el host provee las copias, el remote las consume. Es la primera vez que @pokedex/contracts aparece en un mapa shared. Hasta ahora era solo tipos, borrados en build, así que no había nada que compartir. Ahora lleva baseApi, y el sentido es exactamente ese: una sola instancia.

Dos de las apps añaden los paquetes y avanzan el contrato:

( cd apps/host && npm install @reduxjs/toolkit@^2.12.0 react-redux@^9.3.0 @pokedex/contracts@^3.0.0 )
( cd apps/list && npm install @reduxjs/toolkit@^2.12.0 react-redux@^9.3.0 @pokedex/contracts@^3.0.0 )

La app party es la tercera, y no recibe nada. Ni paquetes de Redux, ni entradas shared nuevas, y sus dos paquetes instalados se quedan donde están: el contrato en ^1.0.0 (dos majors por detrás del contrato contra el que tipa) y la pantalla de detail en ^1.0.0, la versión estática. Eso es seguro por una razón precisa: la app party consume solo tipos del contrato, y los tipos se borran en build. Un major de runtime cambia lo que viaja en el JavaScript del paquete; un consumidor que nunca importa nada de ese JavaScript no tiene nada que romper. Los remotes entran en el estado compartido por decisión propia; el shell no les impone nada.

El remote list: datos reales

Ahora la primera inyección. apps/list/src/listApi.ts, un archivo nuevo:

import { baseApi, parsePokemonList, type PokemonSummary } from '@pokedex/contracts';

const listApi = baseApi.injectEndpoints({
  endpoints: build => ({
    getPokemonList: build.query<PokemonSummary[], void>({
      async queryFn(_arg, _api, _extra, baseQuery) {
        const res = await baseQuery('pokemon?limit=151');
        if (res.error) {
          return { error: res.error };
        }
        try {
          return { data: parsePokemonList(res.data) };
        } catch (err) {
          return {
            error: {
              status: 'CUSTOM_ERROR',
              error: err instanceof Error ? err.message : 'Invalid PokéAPI response',
            },
          };
        }
      },
      providesTags: ['PokemonList'],
    }),
  }),
});

export const { useGetPokemonListQuery } = listApi;

injectEndpoints añade getPokemonList al baseApi compartido y devuelve un hook tipado. El endpoint trae los primeros 151 Pokémon en una sola petición, pasa el cuerpo crudo a parsePokemonList, y devuelve o las filas con forma o un error capturado. providesTags: ['PokemonList'] estampa el resultado con la etiqueta por la que el host va a invalidar. El shell no sabía nada de este endpoint cuando se construyó; el remote lo añade al store en marcha la primera vez que su código carga.

La pantalla se deshace de su array hardcodeado de cinco filas y lee el hook. apps/list/src/PokedexScreen.tsx:

export default function PokedexScreen() {
  const insets = useSafeAreaInsets();
  const navigation = useNavigation<NativeStackNavigationProp<ListParamList>>();
  const { data, isLoading, isError, refetch } = useGetPokemonListQuery();

  if (isLoading) {
    return (
      <View style={styles.centre}>
        <ActivityIndicator size="large" />
      </View>
    );
  }

  if (isError || !data) {
    return (
      <View style={styles.centre}>
        <Text style={styles.error}>Couldn't reach PokéAPI.</Text>
        <Pressable style={styles.retry} onPress={() => refetch()}>
          <Text style={styles.retryText}>Try again</Text>
        </Pressable>
      </View>
    );
  }

  return (
    <FlatList
      data={data}
      keyExtractor={p => String(p.id)}
      contentContainerStyle={{ paddingBottom: insets.bottom + 8 }}
      renderItem={({ item }) => (
        <Pressable
          style={styles.row}
          onPress={() => navigation.navigate('PokemonDetail', { id: item.id })}>
          <Image source={{ uri: item.spriteUri }} style={styles.sprite} />
          <Text style={styles.number}>#{String(item.id).padStart(3, '0')}</Text>
          <Text style={styles.name}>{item.name}</Text>
        </Pressable>
      )}
    />
  );
}

Tres estados en vez de uno: un spinner mientras la petición vuela, una pantalla de error con reintento cuando PokéAPI no responde, y la lista. La navegación del post 5 está intacta: tocar una fila sigue empujando PokemonDetail con { id } dentro del stack propio de este remote. Lo que cambió es de dónde salen las filas: una caché en el store del host, llenada por un endpoint que este remote inyectó, leída a través de un hook que no existía cuando el host se publicó.

El detail cobra vida; la biblioteca se queda en vista

El post 5 dejó un olor deliberado en el paquete de detail: su propia copia privada de los datos de Pokémon, desviada de la copia del list, con un comentario prometiendo que los datos en vivo la borrarían. Este es ese momento, y llegan dos releases que trazan una línea.

La línea: una biblioteca de componentes publica píxeles, y las definiciones de datos pertenecen al dominio dueño de los datos. El próximo post defiende esa línea a fondo; este la aplica. Los datos de Pokémon son del dominio Pokédex, así que el segundo endpoint aterriza junto al primero, en la app list. apps/list/src/detailApi.ts:

import { baseApi, parsePokemonDetail, type PokemonDetail } from '@pokedex/contracts';

const detailApi = baseApi.injectEndpoints({
  endpoints: build => ({
    getPokemonDetail: build.query<PokemonDetail, number>({
      async queryFn(id, _api, _extra, baseQuery) {
        const res = await baseQuery(`pokemon/${id}`);
        if (res.error) {
          return { error: res.error };
        }
        try {
          return { data: parsePokemonDetail(res.data) };
        } catch (err) {
          return {
            error: {
              status: 'CUSTOM_ERROR',
              error: err instanceof Error ? err.message : 'Invalid PokéAPI response',
            },
          };
        }
      },
    }),
  }),
});

export const { useGetPokemonDetailQuery } = detailApi;

Una query que toma el id como argumento, trae un Pokémon, y lo parsea en la frontera. Sin tags: nada invalida un solo Pokémon todavía.

@pokedex/detail sube a 3.0.0: un major, porque las props cambian de forma por completo. La copia estática y su búsqueda se han ido; lo que queda es una vista: PokemonDetailView toma el Pokémon y los tres estados como props, los renderiza, y no hace nada más. La app list compone hook y vista en un contenedor pequeño en su ruta de detail:

import { PokemonDetailView } from '@pokedex/detail';
import { useGetPokemonDetailQuery } from './detailApi';

function PokemonDetailRoute({ route }: { route: { params: DetailParams } }) {
  const { data, isLoading, isError, refetch } = useGetPokemonDetailQuery(route.params.id);
  return <PokemonDetailView pokemon={data} loading={isLoading} error={isError} onRetry={refetch} />;
}

De dónde salen los datos es asunto de la app; qué aspecto tienen es asunto de la biblioteca. Nada en el paquete toca el store, y pasar a datos en vivo no le añadió ni una dependencia: los mismos cuatro peers que tenía la versión estática, y Redux no aparece entre ellos.

Suma el coste de cobrar vida: un archivo de endpoint en el dominio dueño de los datos, un major honesto en la vista, y un contenedor de ocho líneas. Y la recompensa más silenciosa: la copia de datos del list y la copia del paquete se han ido las dos, así que la deriva entre ellas también se ha ido. Las dos pantallas renderizan ahora lo que diga PokéAPI, a través de una caché, y contradecirse la una a la otra ya no es algo que sepan hacer.

Ahora rómpelo

La afirmación es que una sola instancia compartida lo sostiene todo. La manera más rápida de fiarte es quitarla y mirar.

Borra la entrada @pokedex/contracts del mapa shared en las dos configs que la llevan, host y list, y deja @reduxjs/toolkit y react-redux en su sitio. Reinicia los servidores de desarrollo y relanza.

El spinner gira. Para siempre. Sin crash, sin red box, y esta vez tampoco nada en la consola. Veinte segundos después, la única línea que el servidor de desarrollo ha registrado es el arranque de la app. Míralo el rato que quieras: no llega nada más.

El aviso del middleware de la sección del store parecería el fallo natural aquí, y la razón de que nunca salte es la lección entera. Con el contrato sin compartir, el host empaqueta su propia copia de @pokedex/contracts y el remote list empaqueta otra distinta. Dos copias significan dos objetos baseApi. El store del host cableó el reducer y el middleware de su copia, así que desde donde mira RTK el montaje está completo y sano: no falta nada, nada de que avisar. Los dos endpoints del dominio se registraron en la copia del list, una que ningún store cableó jamás, así que los endpoints existen, los hooks corren, y los fetch que deberían disparar no van a ninguna parte. Cada copia es internamente consistente. El error está entre ellas, y nada en runtime es dueño de ese “entre”.

Este es el fallo silencioso al que los posts del singleton compartido vuelven una y otra vez, y ya tiene una escalera completa. Dos Reacts revientan al arrancar. Un middleware que falta lanza un red box que nombra el archivo a arreglar. Dos baseApi te dan un spinner sobre una caché que nunca se llena, y el único diagnóstico es la ausencia de todo lo demás. Un fallo ruidoso es barato; el silencioso cuesta la tarde. Devuelve las entradas shared, reinicia, y la lista se llena otra vez.

La invalidación cruza la frontera

El grafo de tags lleva sin usarse desde que el contrato lo declaró. Toca tirar de él.

El post 4 escondió todas las cabeceras con headerShown: false en el navegador de pestañas, y el post 5 le dio a cada stack de remote sus propias cabeceras dentro de la pestaña. El host ahora vuelve a encender la cabecera de la pestaña Pokédex, porque va a poner en ella algo que es suyo. apps/host/App.tsx:

import { useDispatch } from 'react-redux';
import { baseApi } from '@pokedex/contracts';

function RefreshButton() {
  const dispatch = useDispatch();
  return (
    <Pressable
      style={styles.refresh}
      onPress={() => dispatch(baseApi.util.invalidateTags(['PokemonList']))}
      hitSlop={12}
      accessibilityRole="button"
      accessibilityLabel="Refresh Pokédex">
      <Text style={styles.refreshText}>Refresh</Text>
    </Pressable>
  );
}
import { getFocusedRouteNameFromRoute } from '@react-navigation/native';

<Tab.Screen
  name="Pokédex"
  component={PokedexTab}
  options={({ route }) => ({
    headerShown: getFocusedRouteNameFromRoute(route) !== 'PokemonDetail',
    headerRight: () => <RefreshButton />,
  })}
/>

Un detalle de ahí merece mirarse de cerca. La ruta de detail trae su propia cabecera de stack con botón de volver, y una cabecera de pestaña apilada encima pondría dos barras en pantalla. Así que el host esconde su barra mientras el stack está en el detail, y la enseña en cuanto el stack vuelve atrás. Comprobar contra 'PokemonDetail' parece que el host se mete en los nombres de ruta internos del remote, y eso es exactamente lo que sería si el nombre viviera en el remote. Pero no vive ahí. Es el nombre de ruta de DetailParamList en @pokedex/contracts, el mismo acuerdo del que salen los params. El contrato sigue dando fruto en sitios que el post anterior nunca predijo.

RefreshButton necesita Pressable y Text añadidos al import de react-native, más dos entradas pequeñas de estilo; el archivo completo está en el tag de acompañamiento.

El host nunca definió getPokemonList. No guarda ninguna referencia al endpoint del remote list, ni a su hook, ni a su query. Lo único que despacha es una etiqueta. invalidateTags(['PokemonList']) recorre la caché compartida, encuentra cada query que proveyó esa etiqueta, y vuelve a pedir las que tienen un suscriptor en pantalla. La lista se recarga; el endpoint del detail, que no provee tags, queda intacto. Una etiqueta, declarada en el contrato, provista por una feature, despachada por el host, y el refetch aterriza en código que quien despacha no ha visto nunca.

En una app real la invalidación cuelga del invalidatesTags de una mutation en vez de un botón, pero el alcance a través de la frontera de módulos es el mismo. Los equipos acuerdan nombres de tags en el contrato igual que acuerdan tipos, y ese acuerdo es el protocolo entero de refresco entre ellos.

Ponlo en marcha

Los paquetes están publicados e instalados, así que Verdaccio no hace falta para la ejecución. Tres servidores de desarrollo, uno por app, y la build del 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

La pestaña Pokédex enseña un spinner un momento, y luego se llena con los primeros 151 Pokémon, artwork oficial incluido, directos de PokéAPI. Toca una fila y la ruta de detail trae su Pokémon a través de la misma caché. Toca Refresh en la cabecera y la lista se recarga al otro lado de la frontera:

El shell host en iOS: la pestaña Pokédex carga un spinner, se llena con 151 Pokémon en vivo del remote list, y una fila tocada abre una pantalla de detalle que trae datos en vivo a través del mismo store compartido

La pestaña Party sigue renderizando su cuadrícula vacía, con el contrato dos majors por detrás, la pantalla de detail todavía en la 1.0.0 estática, sin store a la vista, funcionando exactamente igual que antes de este post. La independencia incluye la libertad de no participar.

Lo que has construido, y lo que viene

El paquete de contratos es dueño del único baseApi en el que cada lado inyecta, así que la caché, la deduplicación y el grafo de tags se comparten entre features que se construyeron y publicaron por su cuenta. El host es dueño del store construido a su alrededor. El dominio Pokédex llena la caché con sus dos endpoints, la vista instalada renderiza lo que los contenedores le dan, la frontera queda protegida con un esquema, y el host refresca datos que no sabe nombrar despachando una etiqueta. Sin las entradas shared, el fallo es un spinner silencioso; ya lo has visto una vez aquí, así que lo reconocerás cuando sea caro.

Todo lo que cruza la frontera sigue siendo estado de servidor, eso sí: datos de los que es dueño un servidor y de los que la caché guarda una copia. Nada de lo que es dueña la propia app ha cruzado todavía una frontera de módulos, y la app empieza a acumular cosas suyas. La cuadrícula del Party sigue vacía porque nada de lo que haces en la Pokédex puede llegarle. Cierra la app y vuélvela a abrir, y nada de lo que hiciste sobrevive: ni party, ni favoritos, ni memoria. El estado de cliente es la otra mitad de la división de la que partió este post, y el build-along vuelve a por él dentro de dos posts. Antes, la serie da un paso atrás y defiende las decisiones de propiedad que los últimos tres posts fueron tomando una a una: dónde va una frontera, qué puede saber un componente compartido, dónde vive una definición de datos.

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