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 it, 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:
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?
Three kinds of state now live in this app, and each has a different owner.
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. Nobody owns a cache entry; PokéAPI does.
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 seam 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 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.
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 sabotage at the end of this post 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. Publish:
cd packages/contracts
npm install && npm publish
+ @pokedex/contracts@3.1.0
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.0, and install honours the lockfile even though the caret would accept 3.1.0. 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, usefully, on camera. 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'.
Three majors of the detail package have happened around this app while it wasn’t looking. 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 eight-line container the list app wrote in post 6. A container needs data, and that becomes a wall of its own two sections from now. 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, and the match works across separately built apps because both sides hold the same action creator object. Contracts is a singleton, so party/add is one creator, not two that happen to share a string. 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 two things import it: the party screen (which also imports remove), and nothing else. 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, one line at module scope:
// Screens load on demand; state modules load at boot.
import('partyApp/partySlice').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 result: 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. State loads at boot, because state has to exist before the first dispatch aimed at it.
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, 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 count = useSelector((s: PartySliceShape) => s.party?.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}
addLabel={full ? 'Party is full' : 'Add to party'}
/>
);
}
Read what that tap does. A screen owned by the Pokédex team dispatches a creator owned by the contract, and the 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. 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: every consumer of a shared package moves in lockstep with its releases, so the Pokédex team’s endpoint changes would now gate the party team’s ship dates. 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`
The second injection is skipped: loudly in development, silently in production, where that guard compiles out. Both apps share whichever definition loaded first, and with it that definition’s cache entries: open Bulbasaur from the Pokédex and then from the party, and the second open is a cache hit, one fetch total. Identical definitions make the skip harmless; here it is close to the point. The trap is drift: if the two copies ever diverge, whichever loads first silently wins for both, and the dev-console noise you learned to ignore was the only warning. The rule, then: inject an identical definition and let the skip deduplicate, pass overrideExisting: true only as a deliberate act, or name your endpoint something of your own.
Now break it
The claim to attack: the shell’s boot import is what makes the party slice reliable, rather than merely polite. Comment it out:
// import('partyApp/partySlice').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 fell into a void that used to be an error.
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. That class of bug is why the pattern is a boot import and not a hope: screens load on demand, state modules load at boot, and the difference is whether the store’s shape depends on the user’s route history.
Restore the line, relaunch, and the same tap ticks the counter to 1/6.
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 already reading 0/6: the boot import at work before any tab of the party’s has rendered. Add from a detail, watch the counter tick, and find the member sitting in the Party tab:
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, injected at boot into a store the host wired around the contract’s rootReducer. 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