La mateixa app federada connectada a dos stacks d'estat diferents

Stacks d'estat sota federació: la mateixa app de React Native construïda dues vegades

El post 8 va acabar prometent aquest post: la mateixa app reconstruïda sobre TanStack Query i Zustand, l’stack que la majoria d’equips de React Native prova primer. Aquella construcció ja existeix, en una branca creada a partir del tag del post 8.

Les regles que em vaig posar: les mateixes tres apps, el mateix paquet de detall instal·lat, les mateixes features (la llista del Pokédex, el detall amb el seu botó d’afegir subjecte al límit, la graella de l’equip amb el seu control de treure, el comptador en viu “My Party 3/6”), i cap canvi que l’stack d’estat no obligués a fer. El bessó acabat es veu igual al simulador, pantalla per pantalla, que és el control que aquesta comparació necessita.

El veredicte, abans de la prova. Els dos stacks construeixen bé aquesta app. La versió de TanStack i Zustand té menys peces mòbils, i per afegir estat de servidor des d’un remote és la més còmoda de les dues sota federació: res a registrar, res a injectar. Redux Toolkit et dona a canvi dues coses concretes: un store que creix en runtime i un conjunt tancat d’escriptures. Només importen quan equips desplegats per separat comparteixen estat de client en viu amb regles que s’han de complir. Si la teva app mai no arriba a aquest punt, guanya l’stack més lleuger i la major part d’aquest argument no s’aplicarà al teu cas.

el bessó TanStack Query + ZustanduseQuery sota les clauscompartidescrida partyStore.addllegeix members · cridaremovecableja el botó d'afegirlist@pokedex/contracts 4.0.0queryClient · pokemonKeys ·partyStoreparty@pokedex/detail 3.1.0post 8 Redux Toolkit + RTK Queryinjecta dos endpointsdespatxa addToPartyinjecta el seu propi slice enruntimecableja el botó d'afegirlist@pokedex/contracts 3.1.0baseApi · rootReducer ·addToPartyparty@pokedex/detail 3.1.0

Les fletxes entren al contracte al costat de Redux i en surten al bessó: Redux deixa que els remotes li afegeixin peces en runtime, i en aquesta branca consumeixen el que ja porta. @pokedex/detail apareix a totes dues meitats amb la mateixa versió, perquè és el mateix paquet, sense canvis.

Estat de servidor: res a injectar

El model de TanStack Query és descentralitzat, i aquest és el seu millor argument aquí. No hi ha objecte d’API central, ni pas de registre, ni llista en temps de build del que existeix. Qualsevol component en qualsevol bundle crida useQuery amb qualsevol clau contra el client compartit. Un remote publicat un any després del shell pot afegir estat de servidor a l’app en marxa cridant un hook.

El post 8 va necessitar baseApi.injectEndpoints, una instància compartida i el middleware que executa el seu cache. L’accés a dades de l’app list en aquesta branca és un fetch i un hook:

// apps/list/src/listApi.ts
async function fetchPokemonList(): Promise<PokemonSummary[]> {
  const res = await fetch('https://pokeapi.co/api/v2/pokemon?limit=151');
  if (!res.ok) {
    throw new Error(`PokéAPI responded ${res.status}`);
  }
  return parsePokemonList(await res.json());
}

export function usePokemonList() {
  return useQuery({ queryKey: pokemonKeys.list(), queryFn: fetchPokemonList });
}

parsePokemonList s’importa del contracte i és idèntic byte a byte al del post 8. El parseig amb Zod, el model, les formes: tot sobreviu al canvi, perquè el model de dades pertany al domini i no a la llibreria que sostingui el cache aquesta setmana.

Compartir un client entre bundles construïts per separat té suport, amb una salvetat. La resposta de la guia de migració a v5 per a micro-frontends és passar un queryClient a un hook o a un provider, en substitució del context propi de v4. Això és un paràgraf en una guia de migració, no un capítol sobre federació. Els modes de fallada a React Native són els de singleton que aquesta sèrie ja coneix: dues còpies de @tanstack/react-query et donen “No QueryClient set”, dues còpies de React et donen hooks nuls.

El cost aterra en un altre lloc. Els endpoints injectats d’RTK estenen una superfície tipada, així que un equip que afegeix un endpoint veu l’API sencera i el graf de tags que comparteix. La coordinació de TanStack és un array de strings. La meva resposta torna a ser el paquet de contractes: la fàbrica de claus viatja al costat del client, així que un canvi de nom és un bump de versió que tots els consumidors veuen.

// packages/contracts/src/query.ts
export const queryClient = new QueryClient();

export const pokemonKeys = {
  all: ['pokemon'] as const,
  list: () => [...pokemonKeys.all, 'list'] as const,
  detail: (id: number) => [...pokemonKeys.all, 'detail', id] as const,
};

Això és una recomanació, no una pràctica documentada. Les fàbriques de claus són la resposta documentada al desordre de claus a escala; res a la documentació no tracta el cas de dos equips que posseeixen claus sota un mateix prefix.

La col·lisió de noms d’endpoint del post 8 té aquí un mirall més silenciós. A RTK, dues apps que injecten un endpoint anomenat getPokemonDetail comparteixen la definició que va carregar primer: un error de consola en desenvolupament, silenci a producció. A TanStack, dues apps que criden useQuery amb pokemonKeys.detail(1) comparteixen una entrada de cache, perquè les claus es comparen per valor. Res no es registra, així que res no col·lisiona. Obre Bulbasaur al Pokédex i obre’l una altra vegada des de la pestanya Party, i la segona pantalla es renderitza des del cache sense petició. El cas dolent: dos equips amb els queryFn divergits, i les dades depenen de quina pantalla va obrir primer l’usuari.

El shell, per la seva banda, cedeix una cosa petita. El baseApi del post 8 portava la URL base per a tots els consumidors. Aquí cada queryFn fa fetch del que vulgui, i el host no decideix d’on venen les dades d’un remote.

Invalidació: un prefix és una promesa

El botó Refresh pertany a la interfície del host i arriba a les dades d’un remote sense ni un sol import. El post 8 despatxava invalidateTags(['PokemonList']). El bessó:

<Pressable onPress={() => queryClient.invalidateQueries({ queryKey: pokemonKeys.all })}>

Els filtres de queries funcionen per prefix llevat que demanis exact, així que tot el que la fàbrica construeix sota ['pokemon'] caduca alhora i qualsevol query muntada torna a fer fetch. Tot l’acord cap dins del prefix.

Vaig comparar aquests dos models a tags contra query keys, i aquell argument val aquí. La federació hi afegeix una cosa: els dos costats són ara bundles desplegats per separat que mai no van compartir build. Un tag és un valor declarat en una llista compartida; un prefix de clau és igualtat de strings entre codi que es publica en dies diferents. Posar la fàbrica al contracte versionat retorna la convenció a una cosa que un compilador i un procés de release poden veure.

Estat de client: l’store torna al shell

Aquí és on més se separen els dos dissenys.

Al post 8 l’app party creava l’estat de l’equip, n’era la propietària i l’injectava en un store que el host ja havia construït. combineSlices existeix exactament per a això, i l’slice arribava en runtime des d’un bundle del qual el shell no sabia res.

Zustand no té equivalent. Res no afegeix un store a una aplicació en marxa després de la seva creació, i no sembla un descuit: la resposta federada natural de Zustand són diversos stores independents, un per remote, sense cap maquinària. Aquella resposta funciona fins que una peça d’estat ha de creuar una frontera de mòdul, que és el cas del qual tracta tota aquesta sèrie.

Així que l’store viatja al paquet de contractes:

// packages/contracts/src/partyStore.ts
export const partyStore = createStore<PartyState>((set, get) => ({
  members: [],
  add: member => {
    if (get().members.length >= MAX_PARTY) return; // the cap lives with the owner's action
    set(state => ({ members: [...state.members, { ...member, uid: uid() }] }));
  },
  remove: id => set(state => ({ members: state.members.filter(m => m.uid !== id) })),
}));

createStore de zustand/vanilla, perquè el contracte no conté React. Els consumidors s’hi subscriuen amb useStore(partyStore, selector).

La inversió de propietat és el cost honest. L’app party ja no escriu les regles de l’equip; llegeix estat que arriba en un paquet que instal·la. El seu partySlice.ts està esborrat, igual que l’entrada ./partySlice de la seva config de Module Federation, així que torna a exposar un mòdul en lloc de dos. Al costat del host, src/store.ts va desaparèixer, l’import d’arrencada que carregava el mòdul d’estat de l’equip va desaparèixer, i la declaració ambient que el tipava se’n va anar amb ells.

Aquests esborraments són la contrapartida. El post 8 va dedicar una secció a un dispatch que s’esvaeix perquè el propietari de l’slice encara no havia carregat, i l’import d’arrencada del shell existeix per impedir-ho. Aquí aquella fallada no pot passar: l’estat existeix tan bon punt qualsevol cosa importa el contracte. El preu es va pagar abans, en forma de release. Cada nova peça d’estat compartit és una nova versió de @pokedex/contracts que totes les apps instal·len: un desplegament a la web, un cicle d’App Store al mòbil. Cap dels dos stacks no elimina la coordinació; tots dos la mouen a un dia diferent.

El paquet de components és el grup de control. @pokedex/detail 3.1.0 està intacte: mateixa versió, mateix fitxer, instal·lat per totes dues apps, cablejat a una acció de Zustand en una branca i a un action creator de Redux a l’altra. A una vista que renderitza el que li entreguen tant li fa quina llibreria ho va calcular. Aquest és el fruit de la regla de llibreria del post 6.

Propietat: setState des de qualsevol lloc

El límit és una regla de l’app party, i en aquesta branca viu dins d’add. Tota escriptura que passa per add respecta el límit. setState és públic a tots els stores de Zustand, i no passa per add.

La demostració sencera és al contenidor de detall de l’app list, al costat del cablejat legítim:

// apps/list/src/ListStack.tsx — a module with no business writing this state
const bypassTheCap = () =>
  data &&
  partyStore.setState(s => ({
    members: [
      ...s.members,
      { uid: `bypass-${s.members.length}`, id: data.id, name: data.name, spriteUri: data.spriteUri },
    ],
  }));

Sense error de tipus. Sense avís. Res que l’equip propietari pugui revisar. L’equip s’omple fins a sis, el botó d’afegir es desactiva sol, i una pulsació llarga a la mateixa pantalla fica un setè Pokémon en un equip de sis buits:

El bypass del límit a iOS: la pestanya Party mostra sis de sis, una pulsació llarga en una pantalla de detall on el botó d'afegir diu Party is full executa partyStore.setState, i la pestanya Party passa a llegir set de sis

La pulsació llarga només existeix per poder gravar el bypass. La crida és el que importa.

Mira la capçalera. La graella de l’equip són sis buits, així que el setè membre no té on aparèixer, i el comptador que marca 7/6 és l’única superfície que ho mostra. La interfície mateixa de l’equip propietari no pot mostrar l’estat que un mòdul aliè acaba d’escriure.

Redux també permet escriptures alienes: qualsevol mòdul pot despatxar qualsevol cosa. El que canvia és que un slice declara totes les escriptures que el propietari atendrà, així que una acció que el propietari mai no va declarar no canvia res, i les mutacions legítimes es llegeixen en un sol fitxer. Un store de Zustand exposa l’estat en si, i una regla dins d’una funció només obliga els qui criden aquella funció.

Dins d’un sol equip això no és cap problema. Tothom sap que setState hi és, la revisió de codi ho caça, i la convenció se sosté perquè els qui la sostenen seuen junts. En el moment en què qui escriu i qui n’és propietari són equips diferents amb calendaris de release diferents, una convenció és el que tens en lloc d’una frontera.

Triar

No hi ha guanyador absolut. El factor que decideix és qui té permís per escriure què.

  • Un equip és propietari de tot l’estat. TanStack Query i Zustand. Menys conceptes, menys cerimònia, res a injectar, i setState és una comoditat i no un forat.
  • Equips independents, cache de servidor compartit, sense estat de client compartit. TanStack també. Comparteix el client com a singleton, posa la fàbrica de claus en un contracte versionat, i tens la coordinació que necessites.
  • Equips independents escrivint l’estat de client d’altres, amb regles que s’han de complir. Aquest és el cas per al qual existeix el pes de Redux Toolkit: un store que creix en runtime i un conjunt tancat d’escriptures per slice.
  • Mòbil, amb un cicle de release lent. Sospesa la forma del canvi. A la branca de Zustand cada nova peça d’estat compartit és una release del contracte, i al mòbil una release espera la revisió de la botiga.

El senyal que cal vigilar és el dia en què un segon equip necessita escriure estat del qual el primer és propietari. A partir d’aquell dia, la pregunta és si prefereixes coordinar-te amb un tipus o amb una convenció.

El bessó és en una branca pròpia, creada a partir del tag del post 8, a post-09-tanstack-zustand. És una bifurcació, no un pas endavant: la sèrie continua des del post 8 a main.

A continuació, l’altra meitat de la comparació. L’stack es queda igual i el backend es parteix en dos: REST per a la llista, GraphQL per a les insígnies de tipus, un client, un cache, dos equips.

Fonts

Warren de Leon
Warren de Leon

Software Engineering Manager. Recentment he liderat l'equip de Mobile Platform a Hargreaves Lansdown. Escric sobre lideratge tècnic, React Native i com construir bons equips.

Veure perfil