Post 8 ended by promising this one: the same app rebuilt on TanStack Query and Zustand, the stack most React Native teams reach for first. That build now exists, on a branch cut from post 8’s tag.
The rules I set myself: same three apps, same installed detail package, same feature set (the Pokédex list, the detail with its capped Add button, the party grid with remove, the live “My Party 3/6” counter), and no change the state stack did not force. The finished twin looks the same on the simulator, screen for screen, which is the control this comparison needs.
The verdict, before the proof. Both stacks build this app well. The TanStack and Zustand version has fewer moving parts, and for adding server state from a remote it is the more federation-friendly of the two: nothing to register, nothing to inject. Redux Toolkit buys two narrow things: a store that grows at runtime, and a closed set of writes. They matter only when separately deployed teams share live client state with rules that have to hold. If your app never reaches that point, the lighter stack wins and most of this argument will not apply.
The arrows run into the contract on the Redux side and out of it on the twin: Redux lets the remotes add to the seam at runtime, and this branch has them consume what the seam already ships. @pokedex/detail sits in both halves at the same version, because it is the same package, unchanged.
Server state: nothing to inject
TanStack Query’s model is decentralised, and that is its best argument here. There is no central API object, no registration step, no build-time list of what exists. Any component in any bundle calls useQuery with any key against the shared client. A remote shipped a year after the shell can add server state to the running app by calling a hook.
Post 8 needed baseApi.injectEndpoints, one shared instance, and the middleware that runs its cache. The list app’s data access on this branch is a fetch and a 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 is imported from the contract and is byte-identical to post 8’s. The Zod parsing, the model, the shapes: all of it survives the swap, because the data model belongs to the domain rather than to whichever library is holding the cache.
Sharing one client across separately built bundles is supported, with a caveat. The v5 migration guide’s answer for micro-frontends is to pass a queryClient to a hook or a provider, replacing v4’s custom context. That is a paragraph in a migration guide rather than a chapter on federation. The failure modes in React Native are the singleton ones this series has met before: two copies of @tanstack/react-query give you “No QueryClient set”, two copies of React give you null hooks.
The cost lands somewhere else. RTK’s injected endpoints extend one typed surface, so a team adding an endpoint sees the whole API and the tag graph it shares. TanStack’s coordination is a string array. My answer is the contract package again: the key factory ships beside the client, so a rename is a version bump every consumer sees.
// 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,
};
That is a recommendation, not documented practice. Key factories are the documented answer to key sprawl at scale; nothing in the docs addresses two teams owning keys under one prefix.
Post 8’s endpoint-name collision has a quieter mirror here. On RTK, two apps injecting an endpoint called getPokemonDetail share whichever definition loaded first: a console error in development, silence in production. On TanStack, two apps calling useQuery with pokemonKeys.detail(1) share one cache entry, because keys compare by value. Nothing is registered, so nothing collides. Open Bulbasaur in the Pokédex and open it again from the Party tab, and the second screen renders from cache with no request. The bad version of that is two teams whose queryFns have drifted, where the data depends on which screen the user opened first.
One small thing the shell gave up. Post 8’s baseApi carried the base URL for every consumer. Here each queryFn fetches whatever it likes, and the host has no say in where a remote’s data comes from.
Invalidation: a prefix is a promise
The host’s Refresh button is host chrome that reaches into a remote’s data without importing any of it. Post 8 dispatched invalidateTags(['PokemonList']). The twin:
<Pressable onPress={() => queryClient.invalidateQueries({ queryKey: pokemonKeys.all })}>
Query filters match by prefix unless you ask for exact, so everything the factory builds under ['pokemon'] goes stale at once and any mounted query refetches. The prefix is the whole agreement.
I compared these two models in tags vs query keys, and that argument holds here. Federation adds one thing: the two sides are separately deployed bundles that never sat in the same build. A tag is a value declared in a shared list; a key prefix is string equality across code that ships on different days. Putting the factory in the versioned contract turns the convention back into something a compiler and a release process can see.
Client state: the store moves back to the shell
This is where the two designs differ most.
In post 8 the party app created the party’s state, owned it, and injected it into a store the host had already built. combineSlices exists for exactly that, and the slice arrived at runtime from a bundle the shell knew nothing about.
Zustand has no equivalent. Nothing adds a store to a running application after creation, and I do not read that as an oversight: Zustand’s natural federated answer is several independent stores, one per remote, with no machinery at all. That answer works until a piece of state has to cross a module boundary, which is the case this whole series is about.
So the store ships in the contract package:
// 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 from zustand/vanilla, because the contract holds no React. Consumers subscribe with useStore(partyStore, selector).
The ownership inversion is the honest cost. The party app no longer writes the party’s rules; it reads state that arrives in a package it installs. Its partySlice.ts is deleted, and so is the ./partySlice entry in its Module Federation config, so it exposes one module again instead of two. On the host side, src/store.ts is gone, the boot import that loaded the party’s state module is gone, and the ambient declaration that typed it went with them.
Those deletions are the trade. Post 8 spent a section on a dispatch that vanishes because the slice’s owner had not loaded yet, and the shell’s boot import exists to stop that happening. Here the failure cannot occur: the state exists as soon as anything imports the contract. The price was paid earlier, as a release. Every new piece of shared state is a new version of @pokedex/contracts that every app installs: a deploy on the web, an App Store cycle on mobile. Neither stack removes the coordination; they move it to a different day.
The component package is the control group. @pokedex/detail 3.1.0 is untouched: same version, same file, installed by both apps, wired to a Zustand action on one branch and a Redux action creator on the other. A view that renders what it is handed does not care which library computed it. That is the payoff of the library rule from post 6.
Ownership: setState from anywhere
The cap is a rule the party owns, and on this branch it lives inside add. Every write that goes through add is capped. setState is public on every Zustand store, and it does not go through add.
The whole demonstration sits in the list app’s detail container, next to the legitimate wiring:
// 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 },
],
}));
No type error. No warning. Nothing the owning team can review. The party fills to six, the Add button disables itself, and a long-press on the same screen puts a seventh Pokémon in a six-slot party:
The long-press is only there so the bypass can be filmed. The call is the point.
Watch the header. The party’s grid renders six slots, so the seventh member has nowhere to appear, and the counter reading 7/6 is the only surface that shows it. The owning team’s own UI cannot display the state a foreign module just wrote.
Redux permits foreign writes too: any module can dispatch anything. What differs is that a slice declares every write the owner will honour, so an action the owner never declared changes nothing, and the legitimate mutations are readable in one file. A Zustand store exposes the state itself, and a rule inside a function binds only the callers who use that function.
Inside one team this is a non-issue. Everyone knows setState is there, code review catches it, and the convention holds because the people holding it sit together. The moment the writer and the owner are different teams on different release schedules, a convention is what you have in place of a boundary.
Choosing
There is no absolute winner. The deciding factor is who is allowed to write what.
- One team owns all the state. TanStack Query and Zustand. Fewer concepts, less ceremony, nothing to inject, and
setStateis a convenience rather than a hole. - Independent teams, shared server cache, no shared client state. Still TanStack. Share the client as a singleton, put the key factory in a versioned contract, and you have the coordination you need.
- Independent teams writing each other’s client state, with rules that must hold. This is the case Redux Toolkit’s weight is for: a store that grows at runtime and a closed set of writes per slice.
- Mobile, with a slow release cycle. Weigh the shape of the change. On the Zustand branch every new piece of shared state is a contract release, and on mobile a release waits on app-store review.
The signal to watch for is the day a second team needs to write state the first team owns. After that day, the question is whether you would rather coordinate through a type or through a convention.
The twin is on its own branch, cut from post 8’s tag, at post-09-tanstack-zustand. It is a fork, not a step forward: the series carries on from post 8 on main.
Next, the other half of the comparison. The stack stays the same and the backend splits in two: REST for the list, GraphQL for the type badges, one client, one cache, two teams.
Sources
- TanStack Query: migrating to v5 — the micro-frontend answer, passing a
queryClientin place of a custom context - TanStack Query: query filters — prefix matching, and
exact - TanStack Query: query keys and effective query keys — key factories at scale
- Zustand:
createStoreanduseStore— the vanilla store and its React subscription - Redux Toolkit:
combineSlicesand RTK Query code splitting — what the other branch relies on - react-native-module-federation — the companion repo, at the tag
post-09-tanstack-zustand