Isang host app na may hawak na isang shared store na pinupunan ng isang federated remote ng live na API data

Isang shared store: server state sa mga federated remote sa React Native

Ang nakaraang post ay nagtapos sa isang pangako: titigil ang mga tab sa paghawak ng hardcoded na data at magsi-share ng isang store sa mga remote, na may totoong data mula sa isang API. Tinutupad ito ng post na ito.

Nakasandal ito sa server state at client state mula sa maikling pahinga sa serye. Ang hatian sa pagitan ng data na pag-aari ng server at data na pag-aari ng app ay ipinapalagay na dito, hindi na muling ipinapaliwanag. Ang post na ito ay tungkol sa isang kalahati nito, ang server state, sa ilalim ng federation: isang Redux Toolkit (RTK) store sa host, isang RTK Query cache na sha-share ng mga remote, at live na data mula sa PokéAPI na papalit sa limang pangalang dala-dala ng list mula pa noong post 2.

Ang hugis na binubuo natin, bago ang anumang code:

host ang shellinjectEndpointsisang instancei-fetch, i-cache, i-dedupuseGetPokemonListQueryRedux store+ RTK Query cache@pokedex/contractsang shared na baseApilist remotenag-i-inject nggetPokemonListPokéAPI

Ang nag-iisang bagay na dapat tandaan: ang baseApi ay iisang object, at bawat panig ay nag-i-import ng eksaktong iisa. Iyon ang dahilan kung bakit shared ang cache. Sirain iyon, at masisira ang app sa paraang sulit makita, kaya sadya natin itong sisirain malapit sa dulo.

Magpatuloy mula sa sarili mong post 5 code kung sumunod ka sa build. Kung hindi, magsimula mula sa tapos nitong estado:

git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-05-contracts

Nagkakaroon ng runtime seam ang contract package

Hanggang ngayon, mga type lang ang hawak ng @pokedex/contracts. Binubura ang bawat export sa build, kaya walang anuman mula rito ang umaabot sa isang bundle. Ngayon nakakakuha ito ng unang runtime export nito: ang RTK Query API object na dinaraanan ng buong app sa pag-fetch.

Naririto ito, sa shared package, at hindi sa host, sa isang dahilan. Ang isang federated remote ay maidaragdag lang ang mga endpoint nito sa parehong baseApi instance na ikinabit ng host store. Dahil ang @pokedex/contracts ay isang Module Federation singleton, ini-import ng host at ng bawat remote ang eksaktong object na ito. Kaya ang baseApi.injectEndpoints({...}) ng isang remote ay nagrerehistro laban sa iisang cache at middleware na pinapatakbo na ng store. Ang isang instance ay nangangahulugang isang HTTP cache, isang deduplication pipeline, isang tag graph sa buong mga remote, kasama ang mga remote na ini-ship nang matagal na pagkatapos ng 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;
}

const PokemonListResponseSchema = z.object({
  results: z.array(z.object({ name: z.string(), url: 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) };
  });
}

Ang createApi na walang endpoint ay bumubuo ng isang walang-lamang shell: isang reducer, kaunting middleware, at isang injectEndpoints method na tatawagin ng mga remote. Ang fetchBaseQuery ay isang maliit na wrapper sa fetch na naglalagay ng base URL sa unahan at nagpa-parse ng JSON. Ang tagTypes ang nagbibigay-pangalan sa iisang label na ini-invalidate ng app na ito; wala pa itong ginagawa ngayon, at may tunay na trabaho ito sa huling seksyon.

Ang parsePokemonList ang bahaging itinuro ng post 5. Hawak ng contract ang type na pinagkakasunduan ng magkabilang panig, pero ang isang type ay isang compile-time na pangako, at wala na ito sa oras na talagang dumating ang isang response. Ang isang na-rename na field o isang null kung saan dating isang string ay dumudulas nang tuwiran sa isang hand-written na cast at nag-crash makalipas ang tatlong screen. Kaya ang raw na response ay bina-validate gamit ang isang Zod schema mismo sa tahi, at ang isang masamang hugis ay nagiging isang value na kaya nating hawakan sa halip na isang crash. May sarili nitong post ang runtime validation sa bandang huli; dito, ito ang boundary guard na pinagdadaanan sa pagpuno sa shared cache.

Ang pagdaragdag ng isang runtime export at dalawang peer dependency ay isang breaking change, kaya napupunta sa 2.0.0 ang bersyon. packages/contracts/package.json:

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

Ang zod ay isang tunay na runtime dependency, kaya ini-ship ito sa loob ng package. Ang @reduxjs/toolkit at react-redux ay mga peer: ini-install sila ng host, at hinihiram ng contract ang mga kopya ng host sa halip na mag-bundle ng sarili nito. Isang hakbang ng paglilinis muna: kung pinatakbo mo ang breaking-change demo ng post 5, may 2.0.0 na ng string na id ang registry mo, at tumatangging mag-publish ang Verdaccio sa ibabaw ng isang umiiral nang bersyon. Alisin ito:

npm unpublish @pokedex/contracts@2.0.0 --registry http://localhost:4873

Pagkatapos, i-install ang dev dependencies ng package at i-publish ang tunay na bagong major:

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

Ipinakita ng post 5 na tinatanggihan ng caret ang isang major nang mag-isa. Ganoon pa rin. Nakaupo ang host at ang list sa ^1.1.0, kaya iniiwan sila ng npm install sa 1.1.0 kahit umiiral na ngayon ang 2.0.0. Ang pagkuha sa bagong contract ay isang sadyang hakbang, isang range sa bawat pagkakataon, na siyang susunod nating gagawin.

Isang store sa host

Nakakakuha ng store ang host. apps/host/src/store.ts:

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

const rootReducer = combineSlices(baseApi);
export type RootState = ReturnType<typeof rootReducer>;

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

export type AppDispatch = typeof store.dispatch;
export { rootReducer };

Ang combineSlices ay ang RTK 2.x root reducer na kayang tumanggap ng mas maraming slice sa runtime. Tatawagin ng isang remote ang rootReducer.inject(...) para magdagdag ng sarili nitong mga reducer mamaya; iyon ay client state, at ang susunod na post. Sa ngayon, ang tanging slice ay ang sa baseApi: ang shared na server-state cache.

Ang baseApi.middleware ay load-bearing. Pinapatakbo nito ang cache lifecycle: fetching, deduplication, tag invalidation, cache eviction. Iwan mo ito sa labas ng store at magtatapon ng red box ang unang query sa development, kung saan tuwirang pinangangalanan ng RTK ang pagkakamali:

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!

Isang maingay na pagkabigo, kung gayon. Tandaan ito, dahil ang sabotahe sa dulo ng post na ito ay walang matatanggap na anumang babala.

Inilalagay ang store sa ibabaw ng buong tree, para mabasa ng bawat remote na na-federate rito ang cache. apps/host/App.tsx, ang wrapper:

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

export default function App() {
  return (
    <Provider store={store}>
      <SafeAreaProvider>
        <NavigationContainer>
          {/* the tab navigator from post 4 */}
        </NavigationContainer>
      </SafeAreaProvider>
    </Provider>
  );
}

Hindi kailanman gumagawa o nag-i-import ng store ang mga remote. Nire-render sila sa loob ng React tree na ito at inaabot ito sa pamamagitan ng shared react-redux singleton, sa parehong paraang inabot nila ang shared safe-area context noong post 3.

I-share ang state trio

Para makapag-inject ang isang remote sa baseApi ng host, tatlong package ang kailangang mag-resolve sa iisang kopya sa runtime: @reduxjs/toolkit, react-redux, at ang @pokedex/contracts mismo. Idagdag sila sa shared config ng host bilang mga eager singleton. apps/host/rspack.config.mjs, ang mga dagdag sa shared:

'@reduxjs/toolkit': {
  singleton: true,
  eager: true,
  requiredVersion: pkg.dependencies['@reduxjs/toolkit'],
},
'react-redux': {
  singleton: true,
  eager: true,
  requiredVersion: pkg.dependencies['react-redux'],
},
'@pokedex/contracts': {
  singleton: true,
  eager: true,
  requiredVersion: pkg.dependencies['@pokedex/contracts'],
},

Idineklara ng list remote ang parehong tatlo, singleton: true pero hindi eager: kinukonsumo nito ang mga kopya ng host mula sa share scope sa halip na magbigay ng sarili nito. Ito ang unang beses na lumitaw ang @pokedex/contracts sa isang shared map. Hanggang ngayon, types-only ito, binubura sa build, kaya walang maishe-share. Ngayon, dala nito ang baseApi, at ang isang instance ang buong punto.

Idinadagdag din ng dalawang app ang mga package sa kanilang dependencies at ina-bump ang contract:

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

Walang nakukuha ang profile remote. Nire-render pa rin nito ang static trainer card nito, at hindi nito kailanman ginagalaw ang store. Ang mga remote ay nag-o-opt in sa shared state; hindi ito ipinipilit ng shell sa kanila.

Ang list remote: tunay na data

Ngayon, ini-inject ng remote ang endpoint nito. apps/list/src/listApi.ts:

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;

Idinadagdag ng injectEndpoints ang getPokemonList sa shared baseApi at nag-aabot pabalik ng isang typed hook. Kinukuha ng endpoint ang unang 151 Pokémon sa isang request, iniaabot ang raw body sa parsePokemonList, at nagbabalik ng alinman sa mga na-shape na row o isang nahuling error. Ang providesTags: ['PokemonList'] ay nagsi-stamp sa resulta ng label na ii-invalidate ng host. Dahil binabasa ng hook ang shared cache, ang alinmang ibang remote na humihingi ng parehong data ay nakukuha ang naka-cache na kopya, walang pangalawang request.

Nawawala sa screen ang hardcoded na array nito at binabasa ang hook sa halip. apps/list/src/PokedexScreen.tsx:

export default function PokedexScreen({ onSelectPokemon, onLongPressPokemon }: PokedexScreenProps) {
  const insets = useSafeAreaInsets();
  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={() => onSelectPokemon(item.id)}
          onLongPress={() => onLongPressPokemon?.(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>
      )}
    />
  );
}

Hindi nagbago ang PokedexScreenProps. Ang tahing ti-type ng post 5, ang onSelectPokemon handler na ipinapasa ng host dito, ay hindi nagalaw. Ang breaking version bump ay nagmula sa bagong runtime export at mga peer dependency ng package, hindi sa mga prop. Ang nagbago ay kung saan nanggagaling ang data: ang shared cache ng host, na pinunan ng isang endpoint na in-inject ng remote, na nire-render sa pamamagitan ng isang hook na hindi kailanman umiral noong build time ng host.

Ngayon, sirain ito

Ang sinasabi ay isang shared instance ang bumubuklod sa lahat. Ang pinakamabilis na paraan para paniwalaan iyon ay alisin ito at panoorin.

Burahin ang @pokedex/contracts entry mula sa shared map sa dalawang rspack config, na iniiwan ang @reduxjs/toolkit at react-redux. I-rebuild at buksan ang Pokédex tab.

Umiikot ito. Habambuhay. Walang crash, walang red box, at sa pagkakataong ito, wala ring anuman sa console. Panoorin mo ito hangga’t gusto mo: walang dumarating kahit isang linya.

Baka inasahan mo ang middleware warning mula sa seksyon ng store. Hindi ito kailanman pumuputok, at ang dahilan kung bakit hindi ito kailanman pumuputok ang buong aral. Dahil hindi na shared ang contract, binubundle ng host ang sarili nitong kopya ng @pokedex/contracts at binubundle ng remote ang isang hiwalay. Ang dalawang kopya ay nangangahulugang dalawang baseApi object. Ikinabit ng host store ang reducer at middleware ng sarili nitong kopya, kaya mula sa pananaw ng RTK, kumpleto at malusog ang setup: walang kulang, walang dapat ibabala. In-inject ng list remote ang getPokemonList sa kabilang kopya, isang kopyang hindi kailanman narinig ng store. Kaya umiiral ang endpoint, tumatakbo ang hook, at ang fetch na dapat nitong i-trigger ay walang patutunguhan. Ang bawat kopya ay consistent sa loob nito; ang pagkakamali ay nasa pagitan ng dalawa, at walang nagmamay-ari sa runtime ng “pagitan” na iyon.

Ito ang tahimik na pagkabigong paulit-ulit na binabalikan ng mga shared-singleton na post. Dalawang React ay malakas na nagka-crash sa launch. Ang isang store na walang middleware nito ay nagtatapon ng red box na nagpapangalan sa problema. Ang dalawang baseApi ay hindi nagbibigay ng alinman sa dalawa: walang error, walang babala, isang spinner lang sa ibabaw ng isang cache na hindi kailanman napupuno, at ang tanging diagnostic ay ang kawalan ng lahat ng iba pa. Ibalik ang shared entry sa dalawang config, i-rebuild, at napupunan ang list.

I-invalidate sa kabila ng tahi

Nakaupo nang hindi ginagamit ang tag graph. Pag-aari ng host ang header, at nakakakuha ang header ng isang Refresh control. 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>
  );
}

Itinago ng post 4 ang lahat ng header gamit ang headerShown: false sa screenOptions. Muling binubuksan ng Pokédex tab ang sarili nitong header at doon ikinakabit ang button, sa parehong file:

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

Kailangan ng RefreshButton ng Pressable at Text na idinagdag sa react-native import, dagdag pa ang dalawang maliit na style entry; nasa companion tag ang kumpletong file.

Hindi kailanman idineklara ng host ang getPokemonList. Wala itong reference sa endpoint ng list remote, sa hook nito, o sa query nito. Ang tanging idi-dispatch nito ay isang tag. Nilalakad ng invalidateTags(['PokemonList']) ang shared cache, hinahanap ang bawat query na nagbigay ng tag na iyon, at muling kinukuha ang mga nasa screen. Nire-reload ang data ng list remote, na na-trigger ng isang button sa isang module na walang alam tungkol dito.

Iyon ang shared tag graph na ginawang nakikita. Sa isang tunay na app, ang invalidation ay nakasabit sa invalidatesTags ng isang mutation sa halip na isang button, pero pareho ang abot nito sa kabila ng module boundary: isang label, idineklara sa isang panig, iginagalang sa kabila, sa pamamagitan ng iisang cache na sha-share ng dalawa.

Patakbuhin ito

Nai-publish at nai-install na ang contract, kaya hindi na kailangan ang Verdaccio para sa pagpapatakbo. Simulan ang bawat remote at ang host sa sarili nitong terminal, katulad ng post 5, pero nakakabit na ngayon ang store:

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

Nagpapakita ng spinner ang Pokédex tab sandali, tapos napupunan ng unang 151 Pokémon, kasama ang mga sprite, diretso mula sa PokéAPI. Pindutin ang Refresh sa header at nire-reload ang list sa pamamagitan ng shared cache.

Ang host shell sa iOS: ang Pokédex tab na nagpapakita ng live na listahan ng Pokémon na may official-artwork na mga sprite, na ini-serve ng list remote mula sa isang shared store, na may Refresh control sa header ng host

Ang nabuo mo, at ang susunod

Pag-aari ng host ang isang store. Pag-aari ng contract package ang iisang baseApi na inje-inject-an ng bawat panig, kaya ang cache, ang deduplication, at ang tag graph ay shared sa mga remote na binuo at na-ship nang mag-isa. Kinukuha ng list remote ang live na data papunta sa cache na iyon, binabantayan ang boundary gamit ang isang schema, at ini-invalidate ito ng host ayon sa tag nang hindi nag-i-import ng kahit isang linya ng code ng remote.

Lahat ito ay server state, gayunpaman: data na pag-aari ng server at may hawak na kopya ang cache. Wala pang anumang pag-aari ng app ang tumawid sa isang module boundary. Walang napiling Pokémon, walang filter, walang session na itinatakda ng isang remote at binabasa ng iba. Iyon ay client state, at hindi ito naninirahan sa isang shared query cache; naninirahan ito sa mga slice na inje-inject ng isang remote sa runtime.

Susunod: client state sa kabila ng tahi. Titigil ang mga remote sa panghihiram ng store ng host at magsisimulang magdagdag dito, na nag-i-inject ng sarili nilang mga reducer at nagdi-dispatch ng mga action na tinutugunan ng ibang module.

Mga sanggunian

  • Redux Toolkit: code splitting — ang injectEndpoints at ang pagdaragdag ng mga endpoint sa isang umiiral nang API sa runtime
  • RTK Query — ang cache, ang mga tag, at ang mga generated hook
  • Zod — ang schema library na nagbabantay sa runtime boundary
  • PokéAPI — ang libreng REST API kung saan kinukuha ang list
  • react-native-module-federation — ang companion repo, sa tag post-06-shared-store
Warren de Leon
Warren de Leon

Software Engineering Manager. Pinakahuling pinamunuan ang Mobile Platform team sa Hargreaves Lansdown. Sumusulat tungkol sa engineering leadership, React Native, at pagbuo ng magagandang team.

Tingnan ang profile