📚 React Native Module Federation series — read it in full on warrendeleon.com, where new parts land first.
Post 6 ended on an honest limitation: close the app and reopen it, and nothing you did survives. No party, no favourites, no memory. Everything crossing the seam so far has been server state: data PokéAPI owns, held in a cache every module shares. The app itself owns nothing yet.
This post gives it something to own: the party of six. Client state has no server behind it and no cache to refetch. It lives in a Redux slice, and under federation a slice raises the question this whole series circles: who owns it, and how do other features touch it without touching its owner? The ownership essay answered in principle. This post answers in code.
The shape we're building:
📊 Diagram: view it on warrendeleon.com
Hold one idea in your head: everyone shares the cache, but a client-state slice has exactly one owner. The party app owns the party. Everything another module needs (one action, one read shape, one cap) crosses through the contract, and near the end we dispatch into the slice before its owner has loaded, on purpose, to watch what Redux does with an action nobody is listening for.
Carry on from your own post 6 code if you built along. Otherwise, start from its finished state:
git clone https://github.com/warrendeleon/react-native-module-federation
cd react-native-module-federation
git checkout post-06-shared-store
Who owns client state?
Two kinds of state now live in this app, each with a different owner, and between them sits the interaction that crosses.
The server cache is everyone's. Post 6 built it: one baseApi in the contract, one store in the host, endpoints injected by whoever owns the data. No app owns a cache entry. The data belongs to PokéAPI and the cache is only holding a copy, which is why any module may read it.
Private state has one owner and stays inside it. Which members the party holds, how removal works, what the cap means: all of that is the party app's business, in a slice nothing else imports.
The crossing interaction is the narrow strip between them. The Pokédex needs to add a member to a party it does not own, and its header wants to show "My Party 3/6" for state it cannot see. Those crossings get typed, named, versioned and put in the contract, because the rule from the ownership essay holds here too: apps never depend on each other. Apps depend on contracts.
That taxonomy decides everything else in this post. What follows is just each row of it, built.
The wall, and the reducer moves
The party's slice has to join the running store, and Redux Toolkit (RTK) has an API for exactly this: combineSlices builds a reducer with an inject method, and a slice injected at runtime starts reducing from that moment on. So the party app needs to call inject on the reducer object the host's store actually wired in.
Post 6's host built that reducer inline:
// apps/host/src/store.ts, in post 6
export const store = configureStore({
reducer: combineSlices(baseApi),
middleware: getDefaultMiddleware => getDefaultMiddleware().concat(baseApi.middleware),
});
There is nothing to reach. The reducer is an expression inside the host's source, never exported, and even an export would not help: apps don't import apps. A copy is worse than useless: combineSlices(baseApi) in the party app builds a second reducer that no store runs, and injecting into it changes nothing on screen.
This is the identity argument from post 6, third time around. baseApi moved to the contract because a consumer can only inject endpoints into the same instance the store wired. The root reducer moves for the same reason, one file over. packages/contracts/src/store.ts:
import { combineSlices } from '@reduxjs/toolkit';
import { baseApi } from './api';
export const rootReducer = combineSlices(baseApi);
And the host's store slims to an 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),
});
The host still owns the store: the middleware, the Provider, the wiring. The contract package now owns the reducer the same way it owns the api instance. Because contracts is a federation singleton, rootReducer is one object in the whole runtime, and a slice injected by an app built months after the shell lands in the reducer the shell is already running.
The contract carries the crossing
Now the taxonomy's middle row. The interactions that cross an app boundary go in a new packages/contracts/src/party.ts, and it is short on purpose:
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 const partyStateReady = createAction('party/stateReady');
export interface PartySliceShape {
party?: { members: PartyMember[] };
}
addToParty is the one action anything outside the party dispatches. The contract owns the action's shape; the party's reducer owns what it means. The prepare callback stamps each member with a nanoid() (shipped inside RTK, no new dependency), so the reducer stays pure and two copies of the same Pokémon stay distinguishable. Duplicates are allowed by design: a party of six Magikarp is a valid life choice, and the uid is what tells them apart when one gets removed.
MAX_PARTY sits at the seam because every surface that reflects the rule has to read the same number: a disabled button in one app, a counter in another, the guard in the owner's reducer.
partyStateReady is the odd one out: no reducer case handles it, and nothing about the party changes when it is dispatched. It exists for the boot sequence, and the section on loading state modules shows the one useful thing it does.
PartySliceShape is the read side, and the optional marker is the design, not defensiveness. The slice is injected at runtime by a module the reader does not control, so at the moment a foreign module reads, state.party may not exist yet. Readers write s.party?.members ?? [] and render something honest either way. Typed alternatives exist (RTK's withLazyLoadedSlices can thread the possibly-absent slice through declaration merging), but the tolerant shape teaches the situation rather than hiding it, and the Now break it sabotage depends on you understanding it.
Notice what is absent. The party will also have a remove action, and it appears in no contract, because nobody else dispatches it. The contract carries what crosses, nothing more; an entry nobody consumes is a liability with a version number.
Both new files are additive, so the version is a minor; the tag carries 3.1.3, the minor plus the same hardening patches the 3.0.x line took. Publish:
cd packages/contracts
npm install && npm publish
+ @pokedex/contracts@3.1.3
The host and the list both sit on ^3.0.0, and here the lockfile matters more than the caret. A plain npm install changes nothing: the lockfile pins 3.0.3, and install honours the lockfile even though the caret would accept 3.1.3. Walking a caret forward is its own command:
( cd apps/host && npm update @pokedex/contracts )
No package.json edit, one lockfile line moved. The pin only gets touched when a consumer crosses a major. One consumer is about to.
The owner
The party app has been the series' control group: contract on ^1.0.0, detail on ^1.0.0, no store access, a static grid of six empty slots. Growing a slice ends that on every front, and the adoption is a deliberate edit because two majors is a real distance. 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, and the build breaks immediately, in the compiler rather than at runtime. Party's stack still mounts the detail screen the 1.0.0 way: import PokemonDetailScreen from '@pokedex/detail', straight into component=. The 3.x package has no default export any more, so that import now resolves to the package's namespace object, and the mount stops type-checking:
error TS2322: Type 'typeof import(".../@pokedex/detail/dist/index")' is not
assignable to type 'ScreenComponentType<PartyParamList, "PokemonDetail"> | undefined'.
The detail package has crossed two majors around this app while it wasn't looking, 1.x to 3.x. What 3.x exports is the named PokemonDetailView, a view that demands data as props, so the compile error forces party to write the same four-line container the list app wrote in post 6. A container needs data, and that becomes a wall of its own in The party needs data of its own. Chains like this are what version lag actually costs: not a crash in production, a stack of walls on the day you finally adopt.
The slice itself is the whole point of the post, and it fits on a screen. 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);
Two things carry the design. The crossing action binds through extraReducers: the party matches the contract's addToParty. Be precise about why that works across separately built apps, because it is easy to credit the wrong thing. builder.addCase reads actionCreator.type and keys its reducer by that string, so what has to agree is party/add, not the identity of the creator object. Two copies of the contract would still match. Defining the string, the cap and the read shape once instead of retyping them per side is the versioned package's work: publishing the contract is what prevents that drift, singleton or not. Where runtime singleton identity earns its place is the objects, rootReducer and baseApi: an injection only lands in the store the host wired if both sides hold the same instance.
And the cap guard lives here, in the owner. The contract publishes the number; only the owner enforces it. A dispatcher that forgets to disable its button still cannot push a seventh member.
The last line is the mechanism. inject takes the slice itself (createSlice defaults its reducerPath to name, so the state mounts at state.party, matching PartySliceShape), and importing the module is what performs the injection. That makes the slice a state module: a federated module whose value is its side effect. The party's bundler config exposes it alongside the stack:
exposes: {
'./PartyStack': './src/PartyStack.tsx',
'./partySlice': './src/partySlice.ts',
},
The party also joins the state trio in its shared map: @reduxjs/toolkit and react-redux with the hand-stated version every exports-map package needs in this config, @pokedex/contracts as a singleton, none of them eager, exactly as the list app declares them. Same rule as post 6: the host provides the copies, remotes consume them.
One dev-loop observation, since RTK guards against a hazard here: on paper, re-running this module hot-swaps a new function identity into an existing reducerPath, and RTK's inject refuses to replace it (a console error in development; the original reducer stays live). In this Re.Pack setup the hazard stays theoretical: an edit to the slice file reloads the app rather than hot-swapping the module, so you get a fresh store instead of a double inject. Worth knowing which of the two your stack does before you trust either.
With state to render, the placeholder tab becomes a screen. The interesting lines of 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 ?? []);
Filled slots render the sprite, the name, and a remove control dispatching remove(member.uid); empty slots keep the dashed placeholder up to six; the header counts {members.length}/{MAX_PARTY}; tapping a member pushes PokemonDetail with { id }, the same DetailParams from post 5, no new fields. Note the owner reading its own state through the tolerant shape. Even here the optional earns its keep: the first render can beat the first action into the store, and injection registers the reducer but leaves state.party undefined until an action next reaches it.
State modules load at boot
The slice injects when its module is imported. So far only one thing imports it: the party screen, which also imports remove. That means the slice exists only after the user opens the Party tab, and the Pokédex is about to dispatch into it long before that. Someone has to load the state module early, and only one participant is alive at boot on every path: the shell. apps/host/App.tsx, in an effect:
// Screens load on demand; state modules load at boot.
useEffect(() => {
import('partyApp/partySlice')
.then(() => store.dispatch(partyStateReady()))
.catch(err => console.warn('party state module failed to load', err));
}, []);
The host already declares partyApp in its remotes map, so this adds no coupling it didn't have. It fires the import and holds no reference to the module's contents: the ambient declaration types the module as empty, because the host imports it for the side effect and has no business naming anything inside. Screens stay lazy, because a screen the user has not opened costs nothing to defer.
Loading at boot is a head start, not a guarantee. The chunk crosses the network, and nothing stops a fast finger reaching an Add button before it lands. A party/add dispatched into a store with no reducer for it vanishes without a trace: no warning, no error, Redux ignoring an unmatched action by design. So the resolve is made visible, and this is where partyStateReady earns its keep. inject() swaps an entry in a reducer map and rebuilds the combined reducer, but it never dispatches, so state.party stays undefined until the next action runs through the new reducer. The marker is that action: the .then dispatches it the moment the module resolves, state.party appears, and every subscriber re-renders with the slice in place.
The write side closes the loop by gating on exactly that: the container in The write arrives as a prop disables Add while s.party is undefined. A tap that cannot happen is a dispatch that cannot be lost.
The effect placement is an observation, not a diagnosis. At module scope this import produced Can't perform a React state update on a component that hasn't mounted yet on some cold starts and not others, and moving it into an effect removed the warning across repeated cold starts. What produces the update is not something this post has pinned down:
rootReducer.inject()is not the culprit, because it swaps an entry in a reducer map and rebuilds the combined reducer without ever dispatching or callingreplaceReducer, so it notifies nobody by itself. Until the update is traced to its source, treat the placement as a local observation: the effect is the arrangement that made the warning stop, and an effect is where React expects a side effect to live anyway.
Nothing awaits that import, so a party server that is down cannot block boot. That run is worth doing rather than trusting. Stop the party dev server and cold-start the app: the federation runtime reports the failed manifest fetch loudly in development ([ Federation Runtime ]: Failed to get manifest. #RUNTIME-003), and the shell carries on. The Pokédex renders, the counter reads an honest 0/6 through the tolerant shape, the Add button stays disabled because partyStateReady never fired, and the app runs without the slice, which is precisely the state PartySliceShape was designed to describe.
One detail from the observed run: the import settles without rejecting, so the .catch never actually fires for this failure; the runtime contains it and reports it on its own. The catch stays, one line guarding the rejection path so a failed load can never surface as an unhandled rejection.
The write arrives as a prop
The Pokédex side of the crossing starts in the detail view, and the detail view is an installed component library: the one place the write must not live. The ownership essay drew this line: a shared component renders what it is given; a write that crosses a domain boundary is wired by the consumer. So @pokedex/detail 3.1.0 is an additive minor with three optional props:
export interface PokemonDetailViewProps {
pokemon?: PokemonDetail;
loading: boolean;
error: boolean;
onRetry: () => void;
onAddToParty?: () => void;
addDisabled?: boolean;
addLabel?: string;
}
The view renders a button when a consumer hands it onAddToParty and renders nothing when it does not. It still imports no store, no contract, no action creator. The write crosses a domain boundary, so it arrives as a callback and leaves as a tap.
The list app's container wires all three:
function PokemonDetailRoute({ route }: { route: { params: DetailParams } }) {
const { data, isLoading, isError, refetch } = useGetPokemonDetailQuery(route.params.id);
const dispatch = useDispatch();
const members = useSelector((s: PartySliceShape) => s.party?.members);
const partyReady = members !== undefined;
const count = 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 || !partyReady}
addLabel={full ? 'Party is full' : 'Add to party'}
/>
);
}
Read what that tap does. A screen owned by the Pokédex team dispatches an action the contract owns, and that action lands in a reducer owned by the party team. Three parties, none importing another's code. The disabled state reads the same MAX_PARTY the reducer guards with, so the button and the cap can't drift. It also holds the button down until state.party exists, so the one dispatch that could be lost is the one that can never be made. And the Pokédex header gets its counter through the identical read:
const partyCount = useSelector((s: PartySliceShape) => s.party?.members.length ?? 0);
// ...
<Text style={styles.partyCount}>My Party {partyCount}/{MAX_PARTY}</Text>
The list app edits no version pin for any of this: it was on ^3.0.0 for both packages, and npm update @pokedex/contracts @pokedex/detail walks both carets to the new minors. Only the lagging app touched its package.json. That is the caret working as intended: a minor arrives on request; a major takes a deliberate edit.
The party's container is the other consumer of the same view, and it wires none of the three props. A Pokémon opened from inside the party shows no Add button at all:
One view, two consumers, one of them wiring a write. That single screenshot is the ownership essay's component rule, running.
The party needs data of its own
That party-side container is the wall promised earlier. Tapping a party member pushes PokemonDetail inside the party's stack, and the container behind it needs getPokemonDetail, which lives in the list app, which the party cannot import. Apps depend on contracts, never on each other, and this is the first time the rule costs something visible.
The tempting exit is a shared data package: @pokedex/data, owning the endpoint both apps need. It would delete the duplication, and for two features owned by one team it might even be right. The price is the one the ownership essay priced: a shared package puts the party's data access on another team's release train. Compatible releases don't force anyone to move, but the day the party needs a fix in that package, or a breaking version lands, the party's ship date waits on another team's review and release. Twenty lines of query definition do not buy that coupling. The party writes its own apps/party/src/detailApi.ts with the same endpoint name, the same queryFn and the same parsePokemonDetail from the contract, copied deliberately:
const detailApi = baseApi.injectEndpoints({
endpoints: build => ({
getPokemonDetail: build.query<PokemonDetail, number>({
// byte for byte, the list app's definition
}),
}),
});
export const { useGetPokemonDetailQuery } = detailApi;
Two apps now inject an endpoint named getPokemonDetail into one baseApi, and RTK notices. Run the app, open the Party tab, and the development console prints:
called `injectEndpoints` to override already-existing endpointName getPokemonDetail without specifying `overrideExisting: true`
Left alone, RTK skips the second injection and keeps the first: loudly in development, silently in production, where that guard compiles out. This build declares the duplicate instead: both apps pass overrideExisting: true, the console error goes away, and the last injection wins. Either way load order decides, and neither app controls load order. That is the honest reason the two definitions have to stay identical rather than merely similar.
The cache does not change: one endpoint name means one set of entries, so opening Bulbasaur from the Pokédex and then from the party costs one fetch total. The trap is drift: declared or not, whichever copy load order favours silently wins for both, and once overrideExisting has silenced the console there is no warning left to ignore. The rule, then: keep the copies byte for byte identical, declare the collision so it is visibly deliberate, or name your endpoint something of your own.
Now break it
The claim to attack: the gate on state.party is what stands between a slow chunk and a lost tap. First remove the gate (change the container's addDisabled back to plain full), then comment out the boot import:
// useEffect(() => {
// import('partyApp/partySlice')
// .then(() => store.dispatch(partyStateReady()))
// .catch(err => console.warn('party state module failed to load', err));
// }, []);
Relaunch fresh, and do not open the Party tab. That matters: PartyScreen imports remove from the slice file, so visiting the tab injects the slice as a side effect and hides the bug. A user who goes straight to the Pokédex is how you reproduce it.
Tap Bulbasaur. Tap Add to party. The tap lands, the counter reads 0/6, and nothing else happens. No warning, no error, no console line. Post 6's break-it at least hung a spinner you could stare at. Here the dispatch reached the store, the store found no reducer registered for party/add, and Redux did what Redux does with an unmatched action: nothing, by design. The user's tap reached a store with nothing registered to answer it, and Redux is behaving exactly as documented.
Now say the sharper thing out loud. With the boot import gone, the slice still exists eventually: one visit to the Party tab injects it, and every add after that works. So the failure has a narrower and nastier shape: every add made before the user happens to open the owner's tab is lost in silence, which reproduces for some users and never for others, depending on tab order. And the boot import alone only shrinks the window, from "until the user opens the Party tab" down to "until the chunk lands". It cannot close it, because the network owes you nothing.
Closing it is the gate's job: put the gate back, leave the import commented out, and the same cold start shows an Add button that is simply disabled. Ugly, visible, and honest: a tap that cannot happen instead of a tap that lies.
Restore the import, relaunch, and the button wakes the moment partyStateReady fires; the same tap ticks the counter to 1/6. The pattern is all three pieces: the boot import for the head start, the marker to surface the slice, and the gate for the window neither can close.
Land exactly on this post's finished state. The walkthrough prints the load-bearing files; manifests, configs, tests and the smaller edits live in the tag. To finish with a tree byte-equal to the tag, sweep the reference copy over yours. A file you typed correctly is overwritten with itself, and the sweep fills in what the prose did not print:
npx degit@3.8.0 --force warrendeleon/react-native-module-federation#post-08-client-state /tmp/pokedex-ref-08
cp -R /tmp/pokedex-ref-08/. .
The sweep is also how the behaviour gets its proof: it carries the two regression tests this post's design earned (the party store walked through the vanish-inject-marker-add window, and the list container's Add gate held down until readiness), the mocks and Jest wiring they run on, and the ambient federation declarations the walkthrough summarised rather than printed.
Run it
Packages published and installed, so the run is three dev servers and a simulator build:
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
Cold start lands on the Pokédex with the counter reading 0/6. Do not read that as proof the boot import worked: the tolerant selector returns 0/6 whether the slice is registered or still missing, which is the whole point of it. The proof is the Add button being enabled at all, since it only wakes once partyStateReady has surfaced the slice. Add from a detail, watch the counter tick, and find the member sitting in the Party tab:
🎞️ Animated demo: watch it on warrendeleon.com
Keep adding to six and the cap arrives from both sides at once: the sixth add flips the open detail's button to a disabled state live, because the same selector that counts for the header counts for addDisabled:
Remove a member in the Party tab and the freed slot goes dashed again, the counter drops everywhere at once, and the next add works. One slice, one owner, three surfaces agreeing because they all read the same state through the same shape.
What you built, and what's next
The app now owns something. The party lives in a slice the party app alone owns, registered at boot into a store the host wired around the contract's rootReducer. Registered, not populated: state.party stays absent until the next action reaches the combined reducer. The partyStateReady marker is that action, and the tolerant read shape covers every moment before it fires. The one interaction that crosses (addToParty, its cap, its read shape) ships versioned in the contract, the write reaches the shared detail view as a prop its consumer wires, and the party fetches its own data rather than borrow another team's release schedule. You have also watched the failure this design exists to prevent: a dispatch into a slice whose owner never loaded, dropped without a sound.
One honest limitation stands: restart the app and the party is gone. The slice lives in memory, persistence is a different post's problem, and pretending otherwise would be the kind of quiet scope creep this series tries to avoid.
The sharper question is the one this stack keeps raising. The party is six items and one rule, and it took a store, a contract minor, an injected reducer and a boot import to cross the seam politely. RTK made that crossing possible; it did not make it small. Next, the series rebuilds this exact app on TanStack Query and Zustand, the stack most React Native teams reach for first, and watches what federation does to that choice.
Sources
-
Redux Toolkit:
combineSlices— the injectable root reducer andinject -
Redux Toolkit:
createAction— prepare callbacks, andnanoidshipping with RTK -
RTK Query: code splitting —
injectEndpointsand theoverrideExistingguard - PokéAPI — the free REST API the app fetches from
-
react-native-module-federation — the companion repo, at the tag
post-08-client-state


Top comments (0)