Shared State Management
Redux Toolkit Slices for Micro Frontend

Published: May 13, 2026 · 12 min read

Redux Toolkit Slices for Micro Frontend Architecture

A growing micro frontend hits a wall around slice number five. The cartSlice started with three fields and now carries items, totals, wishlist, savedForLater, deliveryAddress, deliveryWindow, and paymentMethod. The Wishlist remote re-renders on every cart total change because selectWishlist runs through the entire cart state. A new developer adds a setUser action to the auth slice, names it updateUserProfile instead, and breaks every remote that still imports setUser from @myapp/store. These are not Redux problems — they are redux toolkit micro frontend slice-design problems. Slices in an MFE are the contract between independent remotes; every reducer name, every selector name, and every initial-state field is a public string that every remote depends on. In the previous article on sharing a Redux store across micro frontends, you wired a singleton federated store with configureStore, slices, and <Provider>. This article zooms into the slices themselves — how to design them so they survive growth, splits, and coordinated deploys without breaking remotes.

This guide walks the slice patterns that hold up at scale: the five-section file layout, naming conventions for collision-free remotes, the rule for when one slice should become two, createSelector for cross-slice derivations, and the semver versioning strategy that ties slice changes to coordinated releases of the federated @myapp/store package.

In this guide, you will:

  • Understand why slice design matters more in an MFE than in a single SPA — every name is a public contract
  • Learn the five-section slice file layout (imports, initialState, createSlice, selectors, exports)
  • Apply naming conventions that prevent silent collisions across remotes (selectXxx, setXxx, addXxx, slice name = reducer key)
  • Decide when to split a slice into two with the "two dispatch surfaces + half-state selector" rule
  • Use createSelector for memoized cross-slice derivations that keep referential stability across dispatches
  • Re-export every slice through the single @myapp/store entry point that every remote imports
  • Apply a semver versioning strategy to slice changes — PATCH adds a field, MAJOR renames one
  • Lock the contract with ESLint rules so contract drift fails CI instead of failing in production

Redux toolkit micro frontend slice architecture diagram showing federated store with userSlice cartSlice ticketSlice chatSlice owned by different remotes

Why Slice Design Is Different in a Micro Frontend

In a single-SPA Redux project, slices are an internal organization concern — rename setUser to updateUser and the only people affected are the developers in the same repo. In a federated MFE, the same rename is a breaking change for every remote that ships independently. The slice file is the contract between teams that may not coordinate deploys.

Slices are the contract between remotes
# Slices Are the Contract Between Remotes
# ─────────────────────────────────────────
#
#   ┌───────────────────────────────────────────────────────────┐
#   │  @myapp/store@1.0.0 (federated singleton)                 │
#   │                                                           │
#   │   ┌─────────────┐  ┌─────────────┐  ┌─────────────┐       │
#   │   │  userSlice  │  │  cartSlice  │  │ ticketSlice │  ...  │
#   │   └─────────────┘  └─────────────┘  └─────────────┘       │
#   │        ▲ ▼              ▲ ▼              ▲ ▼              │
#   └────────┼─┼──────────────┼─┼──────────────┼─┼──────────────┘
#            │ │              │ │              │ │
#       ┌────┘ │              │ │              │ └────┐
#       │      │              │ │              │      │
#   ┌───────┐  │  ┌──────────┐│ │┌────────┐    │  ┌────────┐
#   │ Auth  │  │  │ Products ││ ││ Cart   │    │  │Support │
#   │remote │  │  │ remote   ││ ││ remote │    │  │ remote │
#   └───────┘  │  │ (Next.js)││ │└────────┘    │  └────────┘
#              │  └──────────┘│ │              │
#              ▼              ▼ ▼              ▼
#         dispatch        dispatch        dispatch
#         setUser        addToCart       addTicket
#
#   THE CONTRACT:
#     - Slice name ('user', 'cart', 'tickets') is a public string.
#     - Each reducer (setUser, addToCart, addTicket) is a public action type.
#     - Each selector (selectUser, selectTotalCartItems) is a public read API.
#     - Initial-state field NAMES (firstName, lastName, email, items, qty)
#       are part of the contract — every remote reads them by name.
#
#   BREAK ANY OF THESE AND EVERY DOWNSTREAM REMOTE BREAKS:
#     - Rename a reducer → action creators in remotes throw 'is not a function'
#     - Rename a selector → useSelector returns undefined in remotes
#     - Rename an initial-state field → selectors return stale undefined
#     - Change a slice name → state[oldName] disappears, every selector breaks

The contract surface is bigger than it looks. Every reducer becomes an action creator with the type 'sliceName/reducerName'. Every selector is a function imported by name. Every initial-state field is read by every selector. A rename in any of these breaks every downstream remote that depends on the previous name — and because remotes deploy independently, you can ship the new contract from the host while old remote bundles in the CDN still reference the old names.

The slice rules below are stricter than what redux-toolkit.js.org recommends for a single SPA. That is intentional — single-SPA conventions optimize for refactor speed. MFE conventions optimize for cross-remote contract stability. Both are correct; they apply to different contexts. The federated singleton mechanics that make these contracts load-bearing are covered in Module Federation shared dependencies.

The Five-Section Slice File Layout

Every slice file in the federated store follows the same five-section layout. Consistency matters more than cleverness — a developer opening any slice file in any remote should know exactly where to find the initial state, the reducers, the selectors, and the exports without scrolling.

packages/core/store/src/slices/userSlice.js
// packages/core/store/src/slices/userSlice.js
// ────────────────────────────────────────────
// Standard MFE slice file layout — five sections, in this order:
//   1. Imports
//   2. initialState  (the public schema)
//   3. createSlice   (name, reducers)
//   4. Selectors     (the public read API)
//   5. Exports       (actions + reducer)

import { createSlice } from '@reduxjs/toolkit';

// ── Section 2: initialState ──────────────────────────────────
// Every field name here is part of the contract that every
// remote depends on. Adding fields = safe. Renaming fields =
// requires a major version bump of @myapp/store.
const initialState = {
  firstName: '',
  lastName: '',
  name: '',
  email: '',
  phoneNumber: null,
  avatarUrl: null,
  at: null,                 // access token
  isLoggedIn: false,
  profileUpdating: false,
  address: null,
};

// ── Section 3: createSlice ───────────────────────────────────
// 'name' is the key under which this slice lives in the global
// state tree. It MUST be unique across all slices in the store.
// Every remote reads state.user.* — changing 'user' to 'auth'
// here would break every selector in every remote.
export const userSlice = createSlice({
  name: 'user',
  initialState,
  reducers: {
    setUser: (state, action) => {
      state.firstName   = action.payload.firstName;
      state.lastName    = action.payload.lastName;
      state.name        = action.payload.name;
      state.email       = action.payload.email;
      state.phoneNumber = action.payload.phoneNumber || null;
      state.avatarUrl   = action.payload.avatarUrl   || null;
    },
    setAt:           (state, action) => { state.at         = action.payload.at; },
    setIsLoggedIn:   (state, action) => { state.isLoggedIn = action.payload.isLoggedIn; },
    setAddress:      (state, action) => { state.address    = action.payload.address; },
    clearUser: (state) => {
      // Reset to initial — used on logout. Reducers should NEVER
      // call into other slices' state. Cross-slice resets belong
      // in a root reducer that handles a top-level RESET_ALL action.
      Object.assign(state, initialState);
    },
  },
});

// ── Section 4: selectors (the public read API) ───────────────
// Naming convention: selectXxx — every selector has a 'select'
// prefix so 'import { selectUser } from "@myapp/store"' is
// instantly recognizable as a read, not a write.
export const selectUser         = (state) => state.user;
export const selectUserName     = (state) => state.user.name;
export const selectEmail        = (state) => state.user.email;
export const selectPhoneNumber  = (state) => state.user.phoneNumber;
export const selectAccessToken  = (state) => state.user.at;
export const selectIsLoggedIn   = (state) => state.user.isLoggedIn;
export const selectAddress      = (state) => state.user.address;

// ── Section 5: exports ───────────────────────────────────────
// Action creators are destructured from .actions and named-
// exported. The reducer is the default export so the store
// file can import it as 'userReducer'.
export const {
  setUser, setAt, setIsLoggedIn, setAddress, clearUser,
} = userSlice.actions;

export default userSlice.reducer;

The five sections in order: imports, initialState (the public schema), createSlice (the reducer logic), selectors (the public read API), exports (actions + reducer). Cart, ticket, and chat slices follow exactly the same five-section template — open any of them and the structure is identical.

packages/core/store/src/slices/cartSlice.js
// packages/core/store/src/slices/cartSlice.js
import { createSlice } from '@reduxjs/toolkit';

const initialState = {
  items: [],
  selectedItems: [],
  totalItems: 0,
  totalMRP: 0,
  totalDiscount: 0,
  totalAmount: 0,
  appliedCoupon: null,
  couponDiscount: 0,
};

export const cartSlice = createSlice({
  name: 'cart',
  initialState,
  reducers: {
    addToCart: (state, action) => {
      const existing = state.items.find(i => i.id === action.payload.id);
      if (existing) {
        existing.qty += 1;
      } else {
        state.items.push({ ...action.payload, qty: 1, selected: true });
      }
      state.totalItems = state.items.reduce((s, i) => s + i.qty, 0);
      recalcTotals(state);
    },

    removeFromCart: (state, action) => {
      state.items         = state.items.filter(i => i.id !== action.payload);
      state.selectedItems = state.selectedItems.filter(i => i.id !== action.payload);
      state.totalItems    = state.items.reduce((s, i) => s + i.qty, 0);
      recalcTotals(state);
    },

    updateQuantity: (state, action) => {
      const { id, qty } = action.payload;
      const item = state.items.find(i => i.id === id);
      if (item) item.qty = qty;
      state.totalItems = state.items.reduce((s, i) => s + i.qty, 0);
      recalcTotals(state);
    },

    applyCoupon: (state, action) => {
      state.appliedCoupon  = action.payload.code;
      state.couponDiscount = action.payload.discount;
      recalcTotals(state);
    },

    clearCart: () => initialState,
  },
});

// Internal helper — NOT exported. Lives next to the reducer
// because it touches Immer's draft state directly.
const recalcTotals = (state) => {
  let mrp = 0, discount = 0;
  state.selectedItems.forEach(i => {
    mrp      += i.originalPrice * i.qty;
    discount += (i.originalPrice - i.price) * i.qty;
  });
  const fee = 20;
  state.totalMRP      = mrp;
  state.totalDiscount = discount;
  state.totalAmount   = mrp - discount - state.couponDiscount + fee;
};

// Selectors — single-field reads stay inline, derived reads use
// createSelector (next section).
export const selectCart           = (state) => state.cart;
export const selectCartItems      = (state) => state.cart.items;
export const selectTotalCartItems = (state) => state.cart.totalItems;
export const selectAppliedCoupon  = (state) => state.cart.appliedCoupon;

export const {
  addToCart, removeFromCart, updateQuantity, applyCoupon, clearCart,
} = cartSlice.actions;

export default cartSlice.reducer;

The cart slice is owned by both the Cart and Products remotes (Products dispatches addToCart, Cart dispatches updateQuantity). The ticket slice is owned by the Support remote but read by Account and Header for badge counts. The chat slice is intentionally tiny — once chat grows attachments, typing indicators, and unread counts, it will split (the next section explains the trigger).

Naming Conventions for Collision-Free Remotes

Every name in a slice file becomes a string that every remote imports. Pick the convention once, write it down, and enforce it through ESLint. Drift between remotes is how the silent "selector returns undefined" bugs ship to production.

Slice naming conventions across the workspace
# Slice Naming Conventions for Collision-Free Remotes
# ─────────────────────────────────────────────────────
#
#   Every name below ends up as a public string that EVERY
#   remote in the workspace imports. Pick the convention once
#   and enforce it through ESLint — drift between remotes is
#   how the 'slice not found' bugs ship to production.
#
#   ┌──────────────────┬──────────────────────────┬────────────────────────────┐
#   │ Item             │ Convention               │ Example                    │
#   ├──────────────────┼──────────────────────────┼────────────────────────────┤
#   │ Slice file       │ <feature>Slice.js        │ userSlice.js, cartSlice.js │
#   │ Slice 'name'     │ singular, camelCase      │ 'user', 'cart', 'ticket'   │
#   │ Slice export     │ <feature>Slice           │ userSlice, cartSlice       │
#   │ Reducer fn name  │ verb + noun (camelCase)  │ setUser, addToCart         │
#   │ Action creator   │ same as reducer          │ setUser, addToCart         │
#   │ Selector         │ select<Noun>             │ selectUser, selectCart     │
#   │ Boolean selector │ select<Is/Has><Noun>     │ selectIsLoggedIn           │
#   │ List selector    │ select<Plural>           │ selectCartItems            │
#   │ Derived selector │ select<Computed>         │ selectTotalCartItems       │
#   │ Reducer export   │ default export           │ export default reducer     │
#   │ Initial state    │ const initialState       │ const initialState = {...} │
#   └──────────────────┴──────────────────────────┴────────────────────────────┘
#
#   ANTI-PATTERNS (banned in code review):
#     - getX, fetchX selectors        → reads MUST be selectX
#     - 'auth' AND 'user' both used   → pick ONE name and stick to it
#     - Slice key 'Cart' (capital C)  → state.Cart vs state.cart silent break
#     - Reducer 'updateUserAddress'   → too verbose, prefer 'setAddress'
#     - Two slices with same 'name'   → second one silently overwrites first

The single most important rule is slice name = reducer key. The slice's name: 'cart' field, the reducer key cart: cartSlice.reducer in configureStore, and every selector's state.cart.* access path must all use the same string. Mixing 'Cart' and 'cart' (or 'auth' and 'user') creates two parallel paths through the state tree where one of them silently returns undefined.

Redux toolkit slice naming conventions chart for micro frontend showing selectXxx selector prefix setXxx reducer pattern slice name equals reducer key rule

Selectors get the strictest rule because they are imported by name from across the workspace. Every selector starts with select. Booleans use selectIsXxx or selectHasXxx. Lists use the plural form (selectCartItems). Derived values describe the result (selectTotalCartItems, selectFreeShippingItems). When you see import { setUser, selectUser } from '@myapp/store' in a remote, the prefix tells you immediately which one is the action and which one is the read.

Wiring Slices Into the Federated Store

The configureStore call registers every slice's reducer under a key that must equal the slice's name string. Mismatches between the two break every selector silently — state.user is undefined because the reducer is registered as state.User.

packages/core/store/src/store.js
// packages/core/store/src/store.js
// All slices register here. The keys MUST match the 'name'
// field of each createSlice call — that's how state.user maps
// to userSlice.reducer.
import { configureStore } from '@reduxjs/toolkit';
import { userSlice }   from './slices/userSlice';
import { cartSlice }   from './slices/cartSlice';
import { ticketSlice } from './slices/ticketSlice';
import { chatSlice }   from './slices/chatSlice';

export const makeStore = () =>
  configureStore({
    reducer: {
      user:    userSlice.reducer,   // <- key 'user'    matches name 'user'
      cart:    cartSlice.reducer,   // <- key 'cart'    matches name 'cart'
      tickets: ticketSlice.reducer, // <- key 'tickets' matches name 'tickets'
      chat:    chatSlice.reducer,   // <- key 'chat'    matches name 'chat'
    },
    middleware: (getDefaultMiddleware) =>
      getDefaultMiddleware({ serializableCheck: false }),
  });

export const store = makeStore();

// MISMATCH WARNING:
//   - reducer key 'user'   + slice name 'auth'   = state.user works,
//                                                  but action types
//                                                  show as 'auth/setUser'
//   - reducer key 'Cart'   + slice name 'cart'   = state.Cart but every
//                                                  selector reads state.cart
//   The convention: keep them IDENTICAL. ESLint rule
//   no-mismatched-slice-keys catches this in CI.

The pattern that matters: one folder for slices, one file for the store. The store file imports every slice by name and registers each reducer under a key matching the slice's name field. Future slices follow the same line: import → register under the matching key → re-export from index.js.

The single public entry point — index.js — is the only file remotes import from. Every named export here becomes part of the @myapp/store contract that survives across deploys.

packages/core/store/index.js — single public entry point
// packages/core/store/index.js
// Single public entry point. Every remote imports ONLY from
// '@myapp/store' — never from '@myapp/store/src/slices/...'.
// Re-exporting gives one stable surface even if the internal
// folder layout moves.

// ── Store + factories ────────────────────────────────────────
export { store, makeStore } from './src/store';

// ── react-redux primitives (re-exported intentionally) ───────
// Importing from '@myapp/store' instead of 'react-redux' lets
// Module Federation's singleton catch any duplicate copy.
export { Provider, useSelector, useDispatch } from 'react-redux';
export { useAppSelector, useAppDispatch }     from './src/hooks';

// ── userSlice surface ────────────────────────────────────────
export {
  userSlice,
  // actions
  setUser, setAt, setIsLoggedIn, setAddress, clearUser,
  // selectors
  selectUser, selectUserName, selectEmail, selectPhoneNumber,
  selectAccessToken, selectIsLoggedIn, selectAddress,
} from './src/slices/userSlice';

// ── cartSlice surface ────────────────────────────────────────
export {
  cartSlice,
  addToCart, removeFromCart, updateQuantity, applyCoupon, clearCart,
  selectCart, selectCartItems, selectTotalCartItems, selectAppliedCoupon,
} from './src/slices/cartSlice';

// ── ticketSlice surface ──────────────────────────────────────
export {
  ticketSlice,
  addTicket, updateTicket, setCurrentTicketId,
  setSearchTerm, setStatusFilter, clearFilters,
  setTicketLoading, setTicketError, initializeTickets,
  selectTickets, selectTicketLoading, selectTicketFilters,
} from './src/slices/ticketSlice';

// ── chatSlice surface ────────────────────────────────────────
export {
  chatSlice,
  addMessage, resetChat, setShouldEscalate,
  setChatLoading, setChatError, clearMessages,
  selectChatMessages, selectShouldEscalate,
} from './src/slices/chatSlice';

// WHY THIS MATTERS:
//   Every named export becomes part of the @myapp/store contract.
//   Removing a name without bumping the package's MAJOR version
//   breaks every remote that referenced it. Adding a name is
//   backwards-compatible. CI should diff this file against the
//   previous release and FAIL the build on any deletion.

CI should diff this file against the previous release on every PR and fail the build on any deletion. Adding a new export is backwards-compatible (a MINOR semver bump). Removing one is a breaking change that requires a MAJOR bump and coordinated rebuild of every remote — covered in the versioning section below.

When Should One Slice Become Two?

Slices grow. The first version of cartSlice had items and totalItems. Six months later it has wishlist data, delivery preferences, and payment methods. The split rule is concrete: split when (a) two distinct features dispatch to the slice AND (b) at least one selector touches only half of the slice's state.

When to split a slice — symptoms and the migration shape
# When Should One Slice Become Two?
# ──────────────────────────────────
#
#   RULE: split when (a) two distinct features dispatch to it,
#         AND (b) at least one selector touches only HALF of it.
#
#   ┌──────────────────────────────────────────────────────────┐
#   │  BEFORE — one giant cartSlice                            │
#   │                                                          │
#   │   { items, selectedItems, totalItems, totals,            │
#   │     wishlist, savedForLater, recentlyViewed,             │
#   │     deliveryAddress, deliveryWindow, paymentMethod }     │
#   │                                                          │
#   │  Symptoms that trigger a split:                          │
#   │    - selectWishlist re-runs on every cart total change   │
#   │    - addToWishlist + addToCart both grew separate APIs   │
#   │    - WishlistMFE re-renders when cart totals update      │
#   └──────────────────────────────────────────────────────────┘
#                            │
#                            ▼
#   ┌──────────────────────────────────────────────────────────┐
#   │  AFTER — split into three single-purpose slices          │
#   │                                                          │
#   │  cartSlice ─────── { items, totals, coupon }             │
#   │       owned by Cart remote                               │
#   │                                                          │
#   │  wishlistSlice ─── { items, savedForLater }              │
#   │       owned by Wishlist remote                           │
#   │                                                          │
#   │  checkoutSlice ─── { address, deliveryWindow, payment }  │
#   │       owned by Checkout remote                           │
#   └──────────────────────────────────────────────────────────┘
#
#   WHY THE SPLIT IS WORTH THE MIGRATION COST:
#     - Each slice is owned by one remote (one team, one PR queue)
#     - useSelector(selectWishlist) no longer recomputes on cart updates
#     - The federated package's TypeScript definition shrinks per slice
#     - A breaking change to checkoutSlice does NOT force Cart to
#       redeploy — only the wishlist contract bumps a major version

The symptoms come first: selectWishlist re-runs on every cart total change because both live in the same slice; addToWishlist and addToCart grow separate validation rules but share an action namespace; the Wishlist remote re-renders when cart totals update. The fix is splitting into three single-purpose slices, each owned by one remote.

AspectBefore — one giant sliceAfter — three single-purpose slices
Owner remotesCart + Wishlist + Checkout (shared)Cart owns cart, Wishlist owns wishlist, Checkout owns checkout
useSelector(selectWishlist) recomputes when...Cart totals changeOnly when wishlist items change
useSelector(selectCart) recomputes when...Wishlist items changeOnly when cart items change
Breaking change blast radiusTouches 3 teams' deploysTouches 1 team's deploy
index.d.ts sizeOne large CartState typeThree focused types
Onboarding costRead a 400-line slice fileRead three 100-line slice files

Splitting is not free — every consumer that imports selectAddress from the old cartSlice needs to update to importing from checkoutSlice. That is a MAJOR semver bump on @myapp/store and a coordinated rebuild of every remote (covered below). The migration cost is the price of getting an isolated, single-purpose slice that one team owns end-to-end.

⚠️

Do not split prematurely. Three fields in one slice with one consumer remote is fine — it stays one slice. The trigger is real production friction (re-renders, conflicting dispatches, growing breakage radius), not theoretical purity. Premature splits create more files than necessary and make the cross-slice selector folder grow without payoff.

Memoize Derived Reads with createSelector

A naive derived selector re-runs and returns a brand-new reference on every dispatch — even if the input data did not change. In a single SPA that wastes a few microseconds. In an MFE where every federated remote subscribes to the same store, a non-memoized selector runs once per subscribed remote on every state change.

cartSlice.js — naive derived selector
// packages/core/store/src/slices/cartSlice.js
// BEFORE — naive derived selector: recomputes on EVERY render
// because it returns a brand-new array reference every time.

export const selectFreeShippingItems = (state) =>
  state.cart.items.filter(item => item.qty * item.price > 500);

// Symptoms in the Cart remote:
//   - <ShippingBadge /> re-renders even when the cart did not change
//   - useEffect([freeShipping]) fires on EVERY parent re-render
//   - React DevTools profiler shows a 4ms hot path on a quiet page

createSelector is re-exported from @reduxjs/toolkit (it wraps the underlying reselect (opens in a new tab) library). The signature is createSelector([inputSelectors], outputCalculator). The output calculator runs only when at least one input selector returns a different reference than last time. Every subsequent call with unchanged inputs returns the cached output — same array reference, no useSelector re-render in any subscribed remote.

The pattern shines for cross-slice composition: a Header view-model that needs login state, cart count, and ticket data should be one memoized selector, not three separate useSelector calls.

packages/core/store/src/selectors/header.js
// packages/core/store/src/selectors/header.js
// Cross-SLICE derivation — composes three different slices.
// Lives in selectors/, NOT in any single slice file, because it
// does not belong to any one feature.
import { createSelector } from '@reduxjs/toolkit';
import { selectIsLoggedIn, selectUserName } from '../slices/userSlice';
import { selectTotalCartItems }             from '../slices/cartSlice';
import { selectTickets }                    from '../slices/ticketSlice';

// Header MFE reads ONE selector and gets every field it needs.
// Memoization prevents a Header re-render when ticket data changes
// but the user/cart counts stay the same.
export const selectHeaderViewModel = createSelector(
  [selectIsLoggedIn, selectUserName, selectTotalCartItems, selectTickets],
  (isLoggedIn, name, cartCount, tickets) => ({
    isLoggedIn,
    displayName:    isLoggedIn ? name : 'Sign in',
    cartBadge:      cartCount > 99 ? '99+' : String(cartCount),
    openTickets:    tickets.filter(t => t.status === 'open').length,
    showSupportDot: tickets.some(t => t.unread === true),
  }),
);

// Usage in the Header (which lives in the React host):
//   const vm = useAppSelector(selectHeaderViewModel);
//   <CartBadge>{vm.cartBadge}</CartBadge>
//
// When ANY input changes, only the affected output fields get
// recomputed and ONE diff is sent down through one useSelector
// subscription — instead of three separate useSelector calls
// each running on every state mutation.

selectHeaderViewModel lives in selectors/, not in any single slice file. The rule is: single-slice selectors live in the slice file; cross-slice selectors live in selectors/. Putting selectHeaderViewModel inside cartSlice.js would make cartSlice import from userSlice and ticketSlice, breaking the rule that slice files only depend on @reduxjs/toolkit.

createSelector cross-slice derivation flow showing selectHeaderViewModel memoizing user cart and ticket inputs into one stable view-model object for the Header remote

Slice Folder Layout for a Real MFE

The folder layout makes the rules visible. Every slice file is a sibling under slices/. Every cross-slice selector is a sibling under selectors/. There is no barrel index.js inside slices/ — barrels confuse Module Federation tree-shaking (opens in a new tab) and force the entire slices folder into every remote's bundle.

packages/core/store/ folder layout
# Slice Folder Layout for a Real MFE
# ─────────────────────────────────────
#
#   packages/core/store/
#   ├── package.json             ← version 1.0.0 (the contract)
#   ├── index.js                 ← public re-exports (read by remotes)
#   ├── index.d.ts               ← TypeScript surface for consumers
#   └── src/
#       ├── store.js             ← configureStore + makeStore
#       ├── hooks.js             ← useAppDispatch / useAppSelector
#       ├── data/
#       │   └── mockData.js      ← seed data for ticketSlice in dev
#       ├── slices/
#       │   ├── userSlice.js     ← owned by Auth remote
#       │   ├── cartSlice.js     ← owned by Cart + Products remotes
#       │   ├── ticketSlice.js   ← owned by Support remote
#       │   ├── chatSlice.js     ← owned by Support remote
#       │   ├── orderSlice.js    ← owned by Orders remote
#       │   └── accountSlice.js  ← owned by Account remote
#       └── selectors/
#           ├── header.js        ← cross-slice: user + cart + tickets
#           ├── checkout.js      ← cross-slice: cart + user.address
#           └── notifications.js ← cross-slice: chat + tickets + orders
#
#   THREE FOLDER RULES:
#     1. One slice file per slice — no slices/index.js barrel.
#        Barrels confuse Module Federation tree-shaking.
#     2. Single-slice selectors live IN the slice file.
#        Cross-slice selectors live in selectors/.
#     3. No business logic outside slices/ and selectors/.
#        Action creators are the ONLY way state mutates;
#        selectors are the ONLY way state is read.

The three folder rules: one slice file per slice, single-slice selectors live in the slice file, cross-slice selectors live in selectors/. Every action creator is the only way state mutates; every selector is the only way state is read. Business logic does not live anywhere else.

Slice Versioning — Semver Bound to the Federated Package

Slice changes are package changes. Every modification to a slice file translates to a semver bump on @myapp/store, and Module Federation enforces the bump through the shared block's strictVersion: true flag.

Slice versioning strategy
# Slice Versioning Strategy
# ─────────────────────────
#
#   Slices live inside @myapp/store@1.0.0. Any change to a
#   slice's PUBLIC SURFACE bumps the package version.
#
#   ┌────────────────────────────────┬──────────────┬──────────┐
#   │ Change                         │ Version Bump │ Remotes  │
#   ├────────────────────────────────┼──────────────┼──────────┤
#   │ Add a new field to initialState│ PATCH 1.0.1  │ no rebuild
#   │ Add a new reducer / action     │ MINOR 1.1.0  │ no rebuild
#   │ Add a new selector             │ MINOR 1.1.0  │ no rebuild
#   │ Add a new slice                │ MINOR 1.1.0  │ no rebuild
#   │ Rename a field                 │ MAJOR 2.0.0  │ rebuild ALL
#   │ Rename a reducer / action      │ MAJOR 2.0.0  │ rebuild ALL
#   │ Rename a selector              │ MAJOR 2.0.0  │ rebuild ALL
#   │ Rename a slice (its 'name' key)│ MAJOR 2.0.0  │ rebuild ALL
#   │ Remove a field / action / sel. │ MAJOR 2.0.0  │ rebuild ALL
#   │ Change initialState shape      │ MAJOR 2.0.0  │ rebuild ALL
#   └────────────────────────────────┴──────────────┴──────────┘
#
#   WHY MAJOR FORCES REBUILD:
#     The shared block in every webpack.config.js declares
#     '@myapp/store': { singleton: true, strictVersion: true,
#                       requiredVersion: '1.0.0' }
#     Bumping to 2.0.0 makes Module Federation REFUSE to load
#     any remote that still ships the 1.0.0 contract — the
#     deploy fails loudly instead of silently shipping stale
#     selectors that return undefined.
#
#   THE PATCH/MINOR ESCAPE HATCH:
#     Adding fields and selectors keeps the contract backwards-
#     compatible. Old remotes ignore the new field. New remotes
#     start using it. No coordinated deploy required.

The pattern that keeps the workflow sustainable:

ChangeSemverRebuild every remote?Why
Add a field to initialStatePATCH 1.0.1NoOld remotes ignore the new field
Add a new reducer / actionMINOR 1.1.0NoOld remotes never call it
Add a new selectorMINOR 1.1.0NoOld remotes never import it
Add a new sliceMINOR 1.1.0NoOld remotes do not register it
Rename a fieldMAJOR 2.0.0YesSelectors that read by name return undefined
Rename a reducer / actionMAJOR 2.0.0YesOld action creator becomes "is not a function"
Rename a selectorMAJOR 2.0.0YesImports of the old name fail at build time
Rename a slice (its name)MAJOR 2.0.0Yesstate[oldName] disappears, every selector breaks
Remove a field / action / selectorMAJOR 2.0.0YesOld consumers throw or return undefined

PATCH and MINOR are the common case — they ship without coordination. MAJOR bumps belong in scheduled release windows because every remote in the workspace has to be rebuilt and redeployed in the same window. Module Federation's strictVersion: true catches half-deployed states by refusing to load any remote that ships a different requiredVersion — the deploy fails loudly instead of silently shipping stale selectors.

Lock the Contract with ESLint Rules

The contract is enforceable. A handful of ESLint rules in the store package catch the common drift before it ships. The rules below are written as project-specific rules; equivalent published rules exist for some of them, but the workspace owns the slice convention so it owns the linter that enforces it.

packages/core/store/.eslintrc.js — slice-discipline rules
// .eslintrc.js — slice-discipline rules for the store package
module.exports = {
  rules: {
    // 1. No imports from 'react-redux' inside any slice file.
    //    Slices must remain framework-free — easier to test, no
    //    accidental hook calls during dispatch.
    'no-restricted-imports': ['error', {
      paths: [{
        name: 'react-redux',
        message: 'Slices must not import react-redux. Use selectors only.',
      }],
    }],

    // 2. Selectors MUST start with 'select'.
    //    Caught: 'getUser', 'fetchCart', 'cartItems' (no prefix).
    'sd/selector-naming': ['error', { prefix: 'select' }],

    // 3. Reducer keys in configureStore MUST equal the slice's name.
    //    Caught: reducer: { Cart: cartSlice.reducer } when name is 'cart'.
    'sd/reducer-key-matches-slice-name': 'error',

    // 4. NEVER export a slice's full state shape from index.js
    //    (forces remotes to use selectors, not reach into raw state).
    'sd/no-state-shape-export': 'error',

    // 5. Slice files MUST default-export the reducer.
    //    Caught: missing 'export default xxxSlice.reducer'.
    'sd/slice-default-export-reducer': 'error',
  },
};

// These rules are what catch the silent contract drift before it
// ships. The most common bug they catch: a developer copies a
// new slice file from an old one and forgets to rename the slice
// name string from 'cart' to 'wishlist' — second slice silently
// overwrites the first one's reducer entry.

The most valuable rule is sd/reducer-key-matches-slice-name — it catches the silent class of bugs where a developer copies a slice file, renames the import, and forgets to rename the name string. Without the rule, the second slice silently overwrites the first one's reducer entry; with the rule, CI fails before the PR can merge. Pair the rule set with Redux's own style guide (opens in a new tab) for the rules that apply to any Redux project (immutable updates, action shape, selector colocation).

What's Next

You now have the slice patterns that hold up at scale: the five-section file layout, naming conventions enforced through ESLint, the rule for when one slice becomes two, createSelector for memoized cross-slice composition, the single-entry-point export pattern, the semver versioning strategy bound to strictVersion: true, and the folder layout that makes the conventions visible. The next article — Article 27: Custom Redux Hooks for Micro Frontends — wraps multiple selectors into a single reusable hook contract (useCurrentUser, useCart, useHeaderViewModel) so remotes consume one hook per concern instead of stitching together useSelector and useDispatch calls. After that, Article 28 closes Section 4 with permission-based routing built on top of the same federated store and slice patterns.

← Back to Sharing Redux Store Across Micro Frontends

Continue to Custom Redux Hooks for Micro Frontends →


Frequently Asked Questions

What is a Redux Toolkit slice in a micro frontend?

A Redux Toolkit slice is the file that owns one feature's state inside a federated store. In a micro frontend setup, the slice lives inside the shared workspace package (for example @myapp/store) so every remote — React or Next.js — imports the SAME action creators, reducers, and selectors. createSlice(name, initialState, reducers) generates the reducer plus action creators in one call. The slice's name string ('user', 'cart', 'tickets') becomes a key in the global state tree and a prefix on every action type, which is why naming conventions are stricter in an MFE than in a single SPA — every name is a public string that every remote depends on.

How do I split a Redux store into slices for multiple micro frontends?

Map slices to features, not to remotes. Each slice owns one cohesive feature (user, cart, tickets, chat) and lives in packages/core/store/src/slices/. The Auth remote dispatches into userSlice but does not own it; Cart and Products remotes both dispatch into cartSlice. Splitting by feature instead of by remote keeps the contract stable when a remote's responsibility shifts — moving the cart UI from one remote to another does not require migrating the slice. The store file (configureStore) registers every slice under a key matching its name string. The package's index.js re-exports actions, selectors, and the reducer so every remote imports from a single entry point. Add a new slice when a feature has its own dispatch surface and at least one selector that does not touch any other slice.

What naming convention should slice selectors follow in a micro frontend?

Every selector in a federated slice MUST start with the prefix selectselectUser, selectCart, selectIsLoggedIn, selectTotalCartItems. Boolean selectors use the form selectIsXxx or selectHasXxx. List selectors use the plural form (selectCartItems). Derived/computed selectors describe the result (selectTotalCartItems, selectFreeShippingItems). The select prefix makes every read instantly distinguishable from a write at the import statement: import { selectUser, setUser } from '@myapp/store' tells you setUser is an action creator and selectUser is a read function. Enforce the convention via an ESLint rule because slice files live in a workspace package consumed by every remote — manual review across many repos is unreliable.

When should a slice be split into two separate slices?

Split when (a) two distinct features dispatch into the slice AND (b) at least one selector touches only half of the slice's state. A real example: a cartSlice that grew to include items, totals, wishlist, savedForLater, deliveryAddress, deliveryWindow, and paymentMethod is doing three jobs. Symptoms of an over-grown slice include selectWishlist re-running on every cart total change, addToWishlist and addToCart developing separate dispatch APIs, and the WishlistMFE re-rendering when cart totals update. The fix is to split into cartSlice (items, totals, coupon), wishlistSlice (items, savedForLater), and checkoutSlice (address, deliveryWindow, payment). Each new slice gets its own owner remote, its own action surface, and its own selectors that no longer recompute on unrelated state changes.

Why use createSelector for derived state in a micro frontend store?

createSelector memoizes the result so the derived value keeps the same reference until its inputs actually change. In an MFE this matters MORE than in a single SPA because every federated remote subscribes to the same store — a non-memoized derived selector that recomputes on every state change runs once per subscribed remote. A naive selector returning items.filter(...) returns a brand-new array reference every call, which makes useSelector trigger a re-render in every remote on every dispatch. createSelector wraps the inputSelectors and the outputCalculator: the calculator only runs when an input reference changes, so the derived array stays referentially stable across dispatches that did not touch the cart items. Use createSelector for any selector that runs .filter, .map, .reduce, .sort, or returns a new object literal.

How do I version slices when the schema needs to change?

Treat slice changes as semver changes on the @myapp/store package. PATCH (1.0.01.0.1) covers adding a new field to initialState — old remotes simply ignore it. MINOR (1.0.01.1.0) covers adding a new reducer, action, selector, or whole new slice — old remotes still work. MAJOR (1.0.02.0.0) covers any rename or removal: rename a field, rename a reducer, rename a selector, change a slice's name string, change initialState shape, or delete anything. Because every remote's webpack shared block declares '@myapp/store': { singleton: true, strictVersion: true, requiredVersion: '1.0.0' }, bumping to 2.0.0 forces every remote to be rebuilt and redeployed in the same release — Module Federation will refuse to load a 2.0.0 host next to 1.0.0 remotes, surfacing the mismatch as a deploy failure instead of a silent runtime undefined. PATCH and MINOR bumps are the common case; MAJOR bumps belong in coordinated release windows.

Should cross-slice selectors live inside a single slice file?

No. A selector that reads from two or more slices does not belong to any one feature, so it lives in packages/core/store/src/selectors/ alongside other cross-slice selectors. Examples: selectHeaderViewModel composes user + cart + tickets data into one object the Header remote consumes; selectCheckoutViewModel composes cart + user.address. Keeping cross-slice composition in a separate folder serves three purposes. First, it keeps each slice file focused on one feature, so a developer can read userSlice.js without scrolling past unrelated cart logic. Second, it prevents accidental coupling — putting a cross-slice selector inside cartSlice.js makes cartSlice depend on the userSlice import, which fails as soon as someone tries to test cartSlice in isolation. Third, it keeps the createSelector memoization graph readable: every selector in selectors/ is intentionally cross-cutting, every selector in slices/ is intentionally local.