Shared State Management
Custom Redux Hooks for Micro Frontends

Published: May 14, 2026 · 13 min read

Custom Redux Hooks for Micro Frontends Complete Guide

The first hint a federated Redux setup needs custom hooks micro frontend react patterns is a grep result. Run git grep -n "selectIsLoggedIn" in a five-remote workspace and the count comes back somewhere around 80 — every header, every protected route, every checkout step calls useSelector(selectIsLoggedIn) directly. Now imagine renaming isLoggedIn to isAuthenticated in the user slice. Eighty edits across five remotes, each owned by a different team, all needing to ship in one coordinated release. The slices and selectors from the Redux Toolkit slices article already minimized the surface, but raw useSelector calls still leak the slice names into every consumer. Custom hooks fix the leak. One hook per concern — useCurrentUser, useCart, useHeaderViewModel — and every remote imports the hook instead of the raw selectors and actions. When selectIsLoggedIn renames, only the hook's implementation updates; the eighty consumer files stay untouched.

This guide walks the custom-hook patterns that hold up at scale: the foundation hooks (useAppDispatch/useAppSelector), the three legitimate hook shapes (read-only, read+write, view-model), the folder layout inside the federated store package, the two common performance traps that ship to production, and unit-testing the hooks with renderHook.

In this guide, you will:

  • See why raw useSelector calls leak the slice contract into every remote and how custom hooks fix it
  • Build the foundation hooks (useAppDispatch/useAppSelector) that every other hook composes on top of
  • Implement a read+write hook (useCurrentUser) that wraps seven selectors and four actions into one API
  • Implement a view-model hook (useHeaderViewModel) that composes three slices with createSelector memoization
  • Decide between read-only, read+write, and view-model hook shapes per concern with a clear rule
  • Avoid the two re-render traps (selector defined inside the hook, returning a new object literal) that look correct at code review
  • Lay out the hook folder inside packages/core/store/src/hooks/ so every remote imports from one entry point
  • Unit test custom hooks with @testing-library/react's renderHook and a fresh makeStore per test

Custom hooks micro frontend react architecture diagram showing useCurrentUser useCart useHeaderViewModel exported from federated store package consumed by Auth Cart Products Account remotes

Why Raw useSelector Leaks the Contract

In a single SPA, importing useSelector from react-redux and reading three slice fields inline is fine — the slice file and the component live in the same repo and refactor together. In a federated MFE, the slice file lives in a workspace package consumed by every remote, and every remote is a separately deployed bundle. A useSelector(selectUserName) call site is a permanent dependency on the name selectUserName in the user slice's public surface.

The 'every remote re-imports six things' problem
# The "Every Remote Re-Imports Six Things" Problem
# ──────────────────────────────────────────────────
#
#   BEFORE — every component in every remote does this:
#
#   ┌──────────────────────────────────────────────────────────┐
#   │  apps/Cart/src/components/CartHeader.jsx                 │
#   │                                                          │
#   │  import { useSelector, useDispatch } from '@myapp/store' │
#   │  import {                                                │
#   │    selectUser, selectIsLoggedIn, selectUserName,         │
#   │    selectAccessToken, selectAddress, setUser,            │
#   │    setIsLoggedIn, setAddress, clearUser,                 │
#   │  } from '@myapp/store';                                  │
#   │                                                          │
#   │  function CartHeader() {                                 │
#   │    const isLoggedIn = useSelector(selectIsLoggedIn);     │
#   │    const name       = useSelector(selectUserName);       │
#   │    const address    = useSelector(selectAddress);        │
#   │    const dispatch   = useDispatch();                     │
#   │    // ... 30 lines of business logic                     │
#   │  }                                                       │
#   └──────────────────────────────────────────────────────────┘
#
#   PROBLEMS THIS CAUSES IN AN MFE:
#     - Every remote (Cart, Products, Orders, Account, Support)
#       writes the SAME six lines of import + useSelector boilerplate
#     - When userSlice renames selectUserName → selectName,
#       SIX remotes need to update imports in one coordinated PR
#     - Junior devs hand-wire selectors and miss createSelector,
#       causing re-renders on unrelated state changes
#     - A new field added to the user surface (e.g. avatarUrl)
#       requires every consumer to add another useSelector call
#
#   THE FIX (this article):
#     One custom hook per concern, exported from @myapp/store:
#       useCurrentUser()        → { isLoggedIn, name, email, address, login, logout, updateAddress }
#       useCart()               → { items, totals, addItem, removeItem, updateQuantity }
#       useHeaderViewModel()    → { displayName, cartBadge, openTickets }
#     Every remote imports ONE hook per concern. Slice renames
#     change the hook implementation; consumers stay untouched.

The cost is two-sided. Day-to-day, every junior dev re-discovers the same selector imports, often missing createSelector and wiring a non-memoized filter that re-renders on every dispatch. During refactors, a single slice rename becomes a coordinated PR across the entire workspace.

Custom hooks turn the slice surface into an internal implementation detail. Remotes import useCurrentUser and trust that everything they need — isLoggedIn, name, login, logout — comes back. A slice rename becomes a one-file change inside the hook; the consumers stay untouched. The federated singleton mechanics covered in Module Federation shared dependencies make this safe at runtime, but the discipline that makes it sustainable is the hook abstraction itself.

The Foundation Hooks — useAppDispatch and useAppSelector

Every custom hook in the federated store builds on top of two thin aliases. The aliases exist for the same reason the package re-exports Provider and useSelector from react-redux: they keep every remote on the singleton import path.

packages/core/store/src/hooks.js
// File: packages/core/store/src/hooks.js
// The two foundation hooks. Every other custom hook in the
// federated store package builds on top of these — never on
// react-redux directly.
import { useDispatch, useSelector } from 'react-redux';

// useAppDispatch — a typed wrapper around useDispatch.
// In JavaScript it is a thin pass-through; in TypeScript it
// returns AppDispatch (the typed version of dispatch).
// Wrapping useDispatch is what gives the @myapp/store package
// the freedom to change Redux internals later (e.g. migrate to
// a custom middleware stack) without forcing every remote to
// update its imports.
export const useAppDispatch = () => useDispatch();

// useAppSelector — same idea, typed against RootState.
// Every selector in every remote runs through this single
// alias so a future change to the store shape only touches
// this file — not the 200+ useSelector call sites scattered
// across the workspace.
export const useAppSelector = useSelector;

// WHY NOT IMPORT react-redux DIRECTLY?
//   - In a federated workspace, react-redux is declared as a
//     singleton in every remote's webpack.config.js shared block.
//     Importing it through @myapp/store keeps every remote on the
//     SAME copy at runtime.
//   - A remote that does 'import { useSelector } from "react-redux"'
//     accidentally pulls in its own bundled copy if the singleton
//     is misconfigured — the resulting hook reads from a different
//     React context and returns stale state.

The pattern is simple. useAppDispatch is () => useDispatch() and useAppSelector is a re-export of useSelector. In TypeScript the value is bigger — useAppDispatch returns AppDispatch and useAppSelector is typed against RootState, so consumers get autocomplete without passing generics — but even in JavaScript the indirection pays for itself the first time the Redux internals need to change. Migrate from react-redux to a custom middleware stack, switch to useSyncExternalStore directly, add a Redux DevTools wrapper — none of it touches consumer code.

Every remote in the workspace MUST import from @myapp/store, never from react-redux directly. A misconfigured Module Federation shared block can silently load a second copy of react-redux into a remote, and useSelector from the second copy reads from a different React context — the hook returns stale state with no error. The federated package is the singleton boundary; imports outside it are how the singleton breaks.

Pattern One — Read+Write Hook (useCurrentUser)

The most common hook shape is one feature's full surface — every selector that reads the feature plus every action that writes to it, returned as one object. useCurrentUser is the canonical example because authentication state is read by every remote and written by exactly one (Auth).

packages/core/store/src/hooks/useCurrentUser.js
// File: packages/core/store/src/hooks/useCurrentUser.js
// One hook, complete user surface. Every remote that needs
// the current user imports useCurrentUser ONLY — not the eight
// individual selectors and four individual actions.
import { useCallback } from 'react';
import { useAppDispatch, useAppSelector } from '../hooks';
import {
  selectUser,
  selectIsLoggedIn,
  selectUserName,
  selectEmail,
  selectPhoneNumber,
  selectAccessToken,
  selectAddress,
} from '../slices/userSlice';
import {
  setUser,
  setIsLoggedIn,
  setAddress,
  clearUser,
} from '../slices/userSlice';

export const useCurrentUser = () => {
  const dispatch = useAppDispatch();

  // ── Reads ─────────────────────────────────────────────────
  // Each useAppSelector subscribes independently. React-Redux
  // bails out of re-renders when the returned reference is
  // unchanged, so reading seven primitive fields costs less
  // than reading one bulk selectUser() object.
  const user        = useAppSelector(selectUser);
  const isLoggedIn  = useAppSelector(selectIsLoggedIn);
  const name        = useAppSelector(selectUserName);
  const email       = useAppSelector(selectEmail);
  const phoneNumber = useAppSelector(selectPhoneNumber);
  const accessToken = useAppSelector(selectAccessToken);
  const address     = useAppSelector(selectAddress);

  // ── Writes ────────────────────────────────────────────────
  // Wrapped in useCallback so the returned functions keep a
  // stable reference across renders. Without useCallback, a
  // parent passing { login } down as a prop would trigger a
  // re-render in every child on every state change.
  const login = useCallback((payload) => {
    dispatch(setUser(payload));
    dispatch(setIsLoggedIn({ isLoggedIn: true }));
  }, [dispatch]);

  const logout = useCallback(() => {
    dispatch(clearUser());
  }, [dispatch]);

  const updateAddress = useCallback((address) => {
    dispatch(setAddress({ address }));
  }, [dispatch]);

  // The returned object is the public contract of the hook.
  // EVERY remote in the workspace destructures from this
  // shape — adding a field is a MINOR semver bump, renaming
  // one is a MAJOR.
  return {
    // data
    user, isLoggedIn, name, email, phoneNumber, accessToken, address,
    // actions
    login, logout, updateAddress,
  };
};

Three details are load-bearing. First, every read calls useAppSelector with a module-scoped selector reference — never an arrow function defined inside the hook body. React-Redux's referential equality bailout depends on the selector returning the same value reference; an inline arrow recreates the function each render and breaks the bailout. Second, every write is wrapped in useCallback with [dispatch] as the dependency. dispatch is stable across renders by react-redux's guarantee, so the callback memoization is permanent — the returned login, logout, and updateAddress keep the same reference for the lifetime of the component. Third, the returned object has a fixed shape that every remote depends on. Adding a field is a MINOR semver bump on @myapp/store; renaming or removing one is MAJOR.

The consumer side compresses by an order of magnitude.

apps/Cart/src/components/CartHeader.jsx
// File: apps/Cart/src/components/CartHeader.jsx
// AFTER — the same component using the custom hook.
// One import. Three destructured fields. No raw selector wiring.
import { useCurrentUser } from '@myapp/store';

export default function CartHeader() {
  const { isLoggedIn, name, logout } = useCurrentUser();

  if (!isLoggedIn) {
    return <a href="/login">Sign in to view your bag</a>;
  }

  return (
    <header className="cart-header">
      <h2>Hi, {name}</h2>
      <button onClick={logout}>Sign out</button>
    </header>
  );
}

// COMPARE WITH THE BEFORE VERSION:
//   - Imports went from 5 → 1
//   - useSelector calls went from 3 → 0 (hidden inside the hook)
//   - useDispatch + dispatch(clearUser()) → onClick={logout}
//   - When userSlice renames selectUserName → selectName,
//     this file does NOT change. Only useCurrentUser updates.

Five imports become one. Three useSelector calls become zero. The component no longer knows clearUser exists, no longer reads from state.user.*, and no longer breaks when the user slice renames any of its fields.

useCart follows the same shape — one hook wrapping every cart selector and every cart action plus two computed booleans (isEmpty, hasCoupon) memoized with useMemo.

packages/core/store/src/hooks/useCart.js
// File: packages/core/store/src/hooks/useCart.js
// Read + write surface for the cart. Owned by Cart + Products
// remotes; read by Header (item count badge) and Checkout
// (totals). Every consumer imports useCart — never the raw
// selectors or action creators.
import { useCallback, useMemo } from 'react';
import { useAppDispatch, useAppSelector } from '../hooks';
import {
  addToCart,
  removeFromCart,
  updateQuantity,
  selectItem,
  deselectItem,
  selectAllItems,
  deselectAllItems,
  applyCoupon,
  removeCoupon,
  clearCart,
} from '../slices/cartSlice';

// Single-field selectors live next to the slice. Defining them
// inline here would create new function references on every
// render and bust useAppSelector's referential equality bailout.
const selectItems         = (state) => state.cart.items;
const selectSelectedItems = (state) => state.cart.selectedItems;
const selectTotals        = (state) => ({
  totalItems:    state.cart.totalItems,
  totalMRP:      state.cart.totalMRP,
  totalDiscount: state.cart.totalDiscount,
  totalAmount:   state.cart.totalAmount,
});
const selectAppliedCoupon = (state) => state.cart.appliedCoupon;

export const useCart = () => {
  const dispatch = useAppDispatch();

  const items         = useAppSelector(selectItems);
  const selectedItems = useAppSelector(selectSelectedItems);
  const totals        = useAppSelector(selectTotals);
  const appliedCoupon = useAppSelector(selectAppliedCoupon);

  // Derived values stay inside useMemo so the same reference
  // survives renders when the inputs do not change. Without
  // useMemo, isEmpty / hasCoupon would be recomputed every
  // render (cheap), but any object/array derivation would
  // cause downstream useEffect dependencies to re-fire.
  const isEmpty    = useMemo(() => items.length === 0, [items]);
  const hasCoupon  = useMemo(() => appliedCoupon !== null, [appliedCoupon]);

  // ── Actions ──────────────────────────────────────────────
  const addItem      = useCallback((product) => dispatch(addToCart(product)),     [dispatch]);
  const removeItem   = useCallback((id)      => dispatch(removeFromCart(id)),     [dispatch]);
  const setQuantity  = useCallback((id, qty) => dispatch(updateQuantity({ id, qty })), [dispatch]);
  const toggleItem   = useCallback((id, on)  =>
    dispatch(on ? selectItem(id) : deselectItem(id)),                              [dispatch]);
  const selectAll    = useCallback((on)      =>
    dispatch(on ? selectAllItems() : deselectAllItems()),                          [dispatch]);
  const applyCode    = useCallback((code, discount) =>
    dispatch(applyCoupon({ code, discount })),                                     [dispatch]);
  const removeCode   = useCallback(()        => dispatch(removeCoupon()),          [dispatch]);
  const emptyCart    = useCallback(()        => dispatch(clearCart()),             [dispatch]);

  return {
    // data
    items, selectedItems, totals, appliedCoupon,
    // computed
    isEmpty, hasCoupon,
    // actions
    addItem, removeItem, setQuantity, toggleItem, selectAll,
    applyCode, removeCode, emptyCart,
  };
};

The useMemo wrap around isEmpty and hasCoupon is intentional. The values themselves are primitives that React would diff cheaply anyway, but consumers that pass them into useEffect dependency arrays benefit from referential stability across renders.

Pattern Two — View-Model Hook (useHeaderViewModel)

When one remote needs a precise projection of multiple slices, a view-model hook is the right shape. The Header remote reads login state from the user slice, the cart count from the cart slice, and the open-ticket count from the tickets slice — three subscriptions with no shared lifecycle. A view-model hook collapses them into one memoized selector.

packages/core/store/src/hooks/useHeaderViewModel.js
// File: packages/core/store/src/hooks/useHeaderViewModel.js
// VIEW-MODEL HOOK — composes data from THREE slices (user, cart,
// tickets) into the exact shape the Header remote needs. This is
// the most useful hook shape in an MFE because it isolates the
// Header from changes in any of the three underlying slices.
import { useAppSelector } from '../hooks';
import { selectHeaderViewModel } from '../selectors/header';

export const useHeaderViewModel = () => {
  // ONE selector. The selectHeaderViewModel is built with
  // createSelector — see the redux-toolkit-slices article
  // for the full implementation. The memoization is what makes
  // this hook safe to call on every render in the Header remote.
  return useAppSelector(selectHeaderViewModel);
};

// The returned shape (defined in selectors/header.js):
//   {
//     isLoggedIn:     boolean,
//     displayName:    'Sign in' | <user.name>,
//     cartBadge:      string,         // '99+' when count > 99
//     openTickets:    number,
//     showSupportDot: boolean,
//   }
//
// USAGE IN THE HEADER REMOTE:
//   const vm = useHeaderViewModel();
//   <CartBadge>{vm.cartBadge}</CartBadge>
//   <NotificationDot show={vm.showSupportDot} />
//
// WHY THIS BEATS THREE useSelector CALLS:
//   - One subscription to the store, not three
//   - createSelector memoization → no re-render when ticket data
//     changes but user/cart stay the same
//   - The Header remote is decoupled from userSlice, cartSlice,
//     and ticketSlice — it depends only on the view-model shape
//   - Refactoring any of the three slices does not touch Header

selectHeaderViewModel is the createSelector from the slices article's cross-slice selector section — it lives in selectors/header.js and composes user, cart, and ticket inputs into the shape the Header needs. The hook is one line because all the work is already done in the selector; the hook just exposes it through the package's public API.

The benefit is decoupling. The Header remote no longer depends on userSlice, cartSlice, and ticketSlice individually. It depends on the view-model shape. Refactoring any of the three slices does not touch the Header as long as the view-model output stays compatible. View-model hooks are read-only by design — writes belong in the per-feature hooks above. Mixing reads from three slices with writes for three slices in one hook creates a god-object that re-renders on every dispatch anywhere in the store.

View-model hook composition diagram showing useHeaderViewModel collecting user cart ticket data through createSelector memoization into single subscription for header remote

Pattern Three — Read-Only Hook (useIsLoggedIn)

The third shape is the smallest — a hook that returns a single primitive value. Wrapping a one-line selector in a hook is only justified when the field is read in many places across the workspace; for a field read in two components, the indirection is cost without payoff.

packages/core/store/src/hooks/useIsLoggedIn.js
// File: packages/core/store/src/hooks/useIsLoggedIn.js
// READ-ONLY HOOK — the smallest possible shape. Returns the
// single boolean every gated route, header, and protected
// component reads. Defining a hook for one field is justified
// when the field is read in 10+ places across the workspace.

import { useAppSelector } from '../hooks';
import { selectIsLoggedIn } from '../slices/userSlice';

export const useIsLoggedIn = () => useAppSelector(selectIsLoggedIn);

// Usage everywhere:
//   const isLoggedIn = useIsLoggedIn();
//   if (!isLoggedIn) return <Redirect to="/login" />;
//
// WHY NOT JUST useSelector(selectIsLoggedIn) DIRECTLY?
//   1. ONE place to change if the slice field renames
//      (isLoggedIn → isAuthenticated → loggedInAt).
//   2. The hook name reads better at the call site than
//      the selector reference — useIsLoggedIn() is self-
//      documenting, useSelector(selectIsLoggedIn) requires
//      readers to know what selectIsLoggedIn returns.
//   3. Federated remotes import ONE thing instead of two
//      (the hook and the selector). Smaller surface = fewer
//      breaking-change vectors.

The justification is concrete: isLoggedIn is read by every protected route, every header variant, every checkout guard, and several remote-level redirects. That is enough places that a future rename — isLoggedInisAuthenticatedloggedInAt (a timestamp instead of a boolean) — would otherwise touch every one of them.

Picking the Right Hook Shape

The three shapes cover every legitimate use case. The decision is mechanical, not stylistic.

Hook shapeReturnsWhen to use
Read-only (useIsLoggedIn)A primitive valueOne field read in 10+ places across the workspace
Read+write (useCurrentUser, useCart){ ...data, ...actions }One feature with both reads and writes that travel together
View-model (useHeaderViewModel){ ...projected }One remote needs a precise projection of multiple slices
Don't use a hookA field read in 1–2 places — inline useAppSelector is fine
Three hook shapes — pick the right one per concern
# Three Hook Shapes — Pick the Right One Per Concern
# ────────────────────────────────────────────────────
#
#   ┌─────────────────────┬──────────────────────────────────┐
#   │ Hook shape          │ When to reach for it             │
#   ├─────────────────────┼──────────────────────────────────┤
#   │ READ-ONLY HOOK      │ Multiple components need the     │
#   │ useIsLoggedIn()     │ same single read.                │
#   │ → boolean           │ Returns a primitive or shallow   │
#   │                     │ value. Zero actions.             │
#   ├─────────────────────┼──────────────────────────────────┤
#   │ READ + WRITE HOOK   │ One feature has BOTH reads and   │
#   │ useCurrentUser()    │ writes that always travel        │
#   │ useCart()           │ together. Returns { data,        │
#   │ → { data, actions } │ actions } in one object.         │
#   ├─────────────────────┼──────────────────────────────────┤
#   │ VIEW-MODEL HOOK     │ A single remote needs a precise  │
#   │ useHeaderViewModel()│ projection of multiple slices.   │
#   │ → { displayName,    │ Wraps a createSelector that      │
#   │     cartBadge,      │ composes 2-N slices into one     │
#   │     openTickets }   │ shape. Read-only — writes belong │
#   │                     │ in the per-slice hooks above.    │
#   └─────────────────────┴──────────────────────────────────┘
#
#   ANTI-PATTERNS:
#     - useState wrappers that re-derive Redux state outside Redux
#     - Hooks that fetch network data — that belongs in RTK Query
#       or a dedicated useXxxQuery hook, not the slice hooks
#     - Hooks returning a brand-new object literal every render
#       without useMemo → breaks every downstream useEffect dep
#     - One mega hook (useStore) that returns the entire state tree
#       → defeats the purpose; consumers re-render on every dispatch

The anti-patterns matter as much as the patterns. Hooks that fetch network data belong in RTK Query (opens in a new tab) or a dedicated useXxxQuery hook — slice hooks read from and write to the store, nothing else. A mega useStore hook that returns the whole state tree re-renders the consumer on every dispatch and defeats every memoization in the store package. useState wrappers that re-derive Redux state into local component state create a second source of truth that drifts.

Folder Layout in the Federated Store Package

Custom hooks share the same folder discipline as slices and selectors — one file per hook, no barrels inside hooks/, all hooks re-exported through the package's single index.js.

packages/core/store/ folder layout with hooks
# Custom Hooks Folder Layout in the Federated Store Package
# ──────────────────────────────────────────────────────────
#
#   packages/core/store/
#   ├── package.json
#   ├── index.js                 ← public re-exports (read by remotes)
#   ├── index.d.ts
#   └── src/
#       ├── store.js
#       ├── hooks.js             ← useAppDispatch / useAppSelector (foundation)
#       ├── slices/
#       │   ├── userSlice.js
#       │   ├── cartSlice.js
#       │   ├── ticketSlice.js
#       │   └── chatSlice.js
#       ├── selectors/
#       │   ├── header.js        ← cross-slice memoized selectors
#       │   ├── checkout.js
#       │   └── notifications.js
#       └── hooks/
#           ├── useCurrentUser.js    ← user reads + writes
#           ├── useIsLoggedIn.js     ← single-field read
#           ├── useCart.js           ← cart reads + writes
#           ├── useTickets.js        ← ticket reads + writes
#           ├── useChat.js           ← chat reads + writes
#           ├── useHeaderViewModel.js   ← cross-slice view model
#           ├── useCheckoutViewModel.js ← cross-slice view model
#           └── index.js             ← re-exports every hook
#
#   THREE FOLDER RULES:
#     1. ONE hook file per hook. No mega-files. A file named
#        useCart.js holds ONLY useCart and its private helpers.
#     2. Hooks NEVER import from another hook file. Each hook
#        composes from selectors + actions directly. Hook-to-hook
#        composition belongs INSIDE the consuming component.
#     3. View-model hooks live next to read+write hooks, not in
#        a separate folder. A flat folder is easier to grep when
#        the package grows to 30+ hooks.

Three folder rules make the convention enforceable. One hook file per hookuseCart.js holds only useCart and its private helpers, never useCart plus useCartItem plus useCheckout. Hooks never import from other hook files — composition belongs inside the consuming component, not inside the store package, because hook-to-hook composition makes the dependency graph cyclic and breaks test isolation. View-model hooks live next to read+write hooks — a flat folder is grep-friendly when the workspace grows to 30+ hooks across the federated store package.

The Public Surface in index.js

Every remote in the workspace imports from one path: @myapp/store. The package's index.js re-exports every hook, every action, every selector, and the store itself. The hooks section is the preferred consumer API; the raw slice surfaces stay exported for advanced consumers (tests, dev tools) but application code defaults to the hooks.

packages/core/store/index.js — single public entry point with hooks
// File: packages/core/store/index.js
// PUBLIC SURFACE — every remote imports from here only.
// The hooks section grew alongside the slices. Re-exporting
// from one entry point means a remote does:
//
//   import { useCurrentUser, useCart, useHeaderViewModel } from '@myapp/store';
//
// instead of the more brittle:
//
//   import { useCurrentUser }     from '@myapp/store/src/hooks/useCurrentUser';
//   import { useCart }            from '@myapp/store/src/hooks/useCart';
//   import { useHeaderViewModel } from '@myapp/store/src/hooks/useHeaderViewModel';

// ── Store + foundation hooks ─────────────────────────────────
export { store, makeStore }                from './src/store';
export { useAppDispatch, useAppSelector }  from './src/hooks';

// ── Slice surfaces (actions + raw selectors) ─────────────────
// Kept for advanced consumers and tests. Application code
// should prefer the custom hooks below.
export * from './src/slices/userSlice';
export * from './src/slices/cartSlice';
export * from './src/slices/ticketSlice';
export * from './src/slices/chatSlice';

// ── Custom hooks (the preferred consumer API) ────────────────
export { useCurrentUser }      from './src/hooks/useCurrentUser';
export { useIsLoggedIn }       from './src/hooks/useIsLoggedIn';
export { useCart }             from './src/hooks/useCart';
export { useTickets }          from './src/hooks/useTickets';
export { useChat }             from './src/hooks/useChat';
export { useHeaderViewModel }  from './src/hooks/useHeaderViewModel';
export { useCheckoutViewModel }from './src/hooks/useCheckoutViewModel';

// ── react-redux primitives (singleton-safe re-exports) ───────
// Components SHOULD prefer the custom hooks. These two are
// here as escape hatches for tests and one-off selector reads.
export { Provider, useSelector, useDispatch } from 'react-redux';

CI should diff this file against the previous release on every PR and fail the build on any deletion. Adding a new hook is a backwards-compatible MINOR semver bump. Removing one is a breaking change that requires a MAJOR bump and a coordinated rebuild of every remote — the same rule that applies to slices in the previous article.

Two Performance Traps That Ship to Production

Custom hooks make the consumer API cleaner, but the hooks themselves still have to play by react-redux's referential-equality rules. Two specific mistakes have shipped to production in real federated stores and both look correct at code review.

packages/core/store/src/hooks/useCart.js — two re-render traps
// File: packages/core/store/src/hooks/useCart.js
// TWO SUBTLE PERFORMANCE TRAPS — both have shipped to production
// in real federated stores. Both look correct at code review.
//
// TRAP 1 — selector defined INSIDE the hook
// ─────────────────────────────────────────
// Looks innocent. The selector returns a new array reference on
// every call because the arrow function itself is recreated
// inside the hook on every render. useAppSelector compares the
// previous and current return values with shallow equality, sees
// "different reference", and re-renders the consumer EVERY time.
//
// export const useCart = () => {
//   const items = useAppSelector((state) =>
//     state.cart.items.filter(i => i.selected)   // <- BAD
//   );
//   return { items };
// };
//
// FIX — define the selector once at module scope, OR wrap it in
// createSelector so the result reference stays stable.
//
// const selectSelectedItems = createSelector(
//   [(state) => state.cart.items],
//   (items) => items.filter(i => i.selected),
// );
//
// export const useCart = () => {
//   const items = useAppSelector(selectSelectedItems);
//   return { items };
// };
//
//
// TRAP 2 — returning a new object literal without useMemo
// ──────────────────────────────────────────────────────
// The hook returns { items, totals, ...actions } as a new object
// on every render. A consumer that does
//
//   const cart = useCart();
//   useEffect(() => { /* ... */ }, [cart]);
//
// sees 'cart' change on every render and the effect re-fires.
// Destructuring at the consumer ({ items, totals } = useCart())
// is the simple fix — primitive references stay stable. For hooks
// that MUST return one object and are read into useEffect deps,
// wrap the return in useMemo of the data fields only.

The first trap is a selector defined inline with an arrow function inside the hook body. The arrow is recreated on every render, returns a new array reference on every call, and breaks useAppSelector's bailout — every consumer re-renders on every dispatch. The fix is to define selectors at module scope (for cheap reads) or wrap them in createSelector (for any selector that filters, maps, or reduces).

The second trap is more subtle. A hook that returns { data, actions } returns a new object literal on every render. A consumer doing const cart = useCart(); useEffect(..., [cart]) sees cart change on every render and the effect re-fires. The fix is conventional — encourage destructuring at the consumer (const { items, addItem } = useCart()) so the dependency array contains primitive references that stay stable. For hooks that must return one object that's used in useEffect deps, wrap the return in useMemo of the data fields only.

TrapSymptomFix
Selector defined inside the hookConsumer re-renders on every dispatchMove selector to module scope or use createSelector
Hook returns new object literalConsumer's useEffect([cart]) re-fires on every renderDestructure at consumer or wrap return in useMemo
Missing useCallback on actionsChild re-renders when callback is passed as propWrap every dispatch callback in useCallback with [dispatch]
useSelector inside useEffectStale closure readsRead with useAppSelector at hook scope, not inside effects

Before / After — One Component, Two Worlds

The cleanest way to show the payoff is the same component written both ways. The view-model hook flattens five imports and four hook calls into two imports and two hook calls — and the second hook is only there for the logout action.

Before and after comparison of custom Redux hooks in micro frontend showing five imports collapsed into one useHeaderViewModel hook call with reduced boilerplate

Before raw react-redux vs after custom hooks
# Before / After — Same Component, Two Worlds
# ──────────────────────────────────────────────
#
#   BEFORE — raw react-redux primitives (5 imports, 4 hooks):
#
#     import { useSelector, useDispatch } from '@myapp/store';
#     import {
#       selectIsLoggedIn, selectUserName, selectTotalCartItems,
#       setIsLoggedIn, clearUser,
#     } from '@myapp/store';
#
#     function Header() {
#       const isLoggedIn = useSelector(selectIsLoggedIn);
#       const name       = useSelector(selectUserName);
#       const cartCount  = useSelector(selectTotalCartItems);
#       const dispatch   = useDispatch();
#       const logout     = () => dispatch(clearUser());
#       ...
#     }
#
#   AFTER — one custom hook (1 import, 1 hook):
#
#     import { useHeaderViewModel, useCurrentUser } from '@myapp/store';
#
#     function Header() {
#       const vm = useHeaderViewModel();
#       const { logout } = useCurrentUser();
#       ...
#     }
#
#   WHAT YOU GAINED:
#     - 5 imports → 2 imports
#     - 4 hook calls → 2 hook calls
#     - One createSelector subscription instead of three
#     - Header is decoupled from userSlice + cartSlice +
#       ticketSlice — a rename anywhere does not touch Header
#     - The 'logout' button does not know clearUser exists
#     - The unit test mocks useHeaderViewModel + useCurrentUser
#       instead of mocking the entire react-redux Provider tree

Beyond the line count, the bigger win is decoupling. The Header after the refactor depends on useHeaderViewModel and useCurrentUser. It does not import from userSlice, cartSlice, or ticketSlice. A slice rename — selectUserNameselectName, selectTotalCartItemsselectCartCount — touches the hook implementation and nothing else. The Header's unit tests mock two hooks instead of mocking a full <Provider> tree with a fake store.

Unit Testing Custom Hooks

Hooks in the federated store package are the most leveraged code in the workspace — a subtle bug in useCurrentUser breaks every remote silently. They need unit tests. @testing-library/react's renderHook is the standard tool; the only MFE-specific detail is wrapping with a fresh makeStore per test so test cases stay isolated.

packages/core/store/src/hooks/__tests__/useCurrentUser.test.js
// File: packages/core/store/src/hooks/__tests__/useCurrentUser.test.js
// Custom hooks in the federated package MUST be unit tested.
// A subtle bug in useCurrentUser breaks every remote in the
// workspace silently — the test suite is the contract enforcement.
import { renderHook, act } from '@testing-library/react';
import { Provider } from 'react-redux';
import { makeStore } from '../../store';
import { useCurrentUser } from '../useCurrentUser';

const wrapWithStore = () => {
  const store = makeStore();
  const wrapper = ({ children }) => (
    <Provider store={store}>{children}</Provider>
  );
  return { store, wrapper };
};

describe('useCurrentUser', () => {
  it('returns initial state with isLoggedIn=false', () => {
    const { wrapper } = wrapWithStore();
    const { result } = renderHook(() => useCurrentUser(), { wrapper });

    expect(result.current.isLoggedIn).toBe(false);
    expect(result.current.name).toBe('');
    expect(result.current.email).toBe('');
  });

  it('login() updates user data and isLoggedIn', () => {
    const { wrapper } = wrapWithStore();
    const { result } = renderHook(() => useCurrentUser(), { wrapper });

    act(() => {
      result.current.login({
        firstName: 'Asha',
        lastName:  'Rao',
        name:      'Asha Rao',
        email:     'asha@example.com',
      });
    });

    expect(result.current.isLoggedIn).toBe(true);
    expect(result.current.name).toBe('Asha Rao');
    expect(result.current.email).toBe('asha@example.com');
  });

  it('logout() clears the user', () => {
    const { wrapper } = wrapWithStore();
    const { result } = renderHook(() => useCurrentUser(), { wrapper });

    act(() => {
      result.current.login({ name: 'X', email: 'x@y.z' });
    });
    act(() => {
      result.current.logout();
    });

    expect(result.current.isLoggedIn).toBe(false);
    expect(result.current.name).toBe('');
  });

  it('returns stable action references across re-renders', () => {
    const { wrapper } = wrapWithStore();
    const { result, rerender } = renderHook(() => useCurrentUser(), { wrapper });

    const firstLogin = result.current.login;
    rerender();
    const secondLogin = result.current.login;

    expect(firstLogin).toBe(secondLogin); // useCallback keeps it stable
  });
});

The pattern that catches the most bugs: a stability test asserting result.current.login stays the same reference across renders. Forgetting to add [dispatch] to a useCallback is the most common silent regression — the hook still works, but every consumer that passes login to a child re-renders the child on every parent render. The stability test catches the bug at PR time instead of as a perf regression three months later.

Run the test suite in CI before publishing a new version of the @myapp/store package. Any change to a hook that breaks the public contract (renames a returned field, removes an action, changes a callback signature) is a MAJOR semver bump under the Module Federation strictVersion rules (opens in a new tab) that the slices article covered — the tests are what catches the contract drift before it becomes a deploy-failure surprise.

What's Next

You now have the custom-hook patterns that hold up at scale: the foundation hooks (useAppDispatch/useAppSelector) that every other hook builds on, the three legitimate hook shapes (read-only, read+write, view-model), the flat folder layout in packages/core/store/src/hooks/, the single index.js entry point that every remote imports from, the two performance traps that look correct at code review, and the renderHook testing pattern that catches contract drift before it ships. The next article — Article 28: Permission-Based Routing in Micro Frontends — applies these hook patterns to authorization: a usePermissions hook reads the user's role array, a <SellerPermissionRoute> component gates whole MFEs, and a permission-aware menu config hides routes the current user cannot access. The federated store, the slices, and the custom hooks together provide the foundation; permissions ride on top.

← Back to Redux Toolkit Slices for Micro Frontend

Continue to Permission-Based Routing in Micro Frontends →


Frequently Asked Questions

What are custom Redux hooks in a micro frontend?

Custom Redux hooks are React hooks defined inside the shared federated store package (for example @myapp/store) that wrap useSelector and useDispatch into a feature-specific API. Instead of every remote importing five selectors plus four action creators and stitching them together with raw useSelector/useDispatch, the remote imports one hook — useCurrentUser, useCart, useHeaderViewModel — and destructures the data and actions it needs. The hook is the public contract: slice renames change the hook's implementation, every consumer remote stays untouched. In a micro frontend, this matters more than in a single SPA because one slice rename without the abstraction layer means a coordinated PR across five or six remotes.

Where should custom Redux hooks live in a micro frontend workspace?

Custom Redux hooks belong inside the federated store package (packages/core/store/src/hooks/) — never inside a single remote. Putting useCurrentUser inside apps/Cart/ would force Products, Orders, and Account to either copy the file or import across remote boundaries, both of which break federation. The folder layout uses one file per hook (useCurrentUser.js, useCart.js, useHeaderViewModel.js), a flat hooks/ folder next to slices/ and selectors/, and re-exports every hook through the package's single index.js. Every remote imports from '@myapp/store' only — never from a slice file or hook file directly. This keeps the contract surface in one place and makes semver versioning enforceable.

What are the three custom hook shapes for a micro frontend?

Three shapes cover every legitimate use case. Read-only hooks (useIsLoggedIn) return a single primitive value and are justified when the field is read in 10+ places across the workspace. Read+write hooks (useCurrentUser, useCart) return an object with both data fields and action callbacks for one feature; this is the most common shape. View-model hooks (useHeaderViewModel, useCheckoutViewModel) wrap a createSelector that composes two or more slices into the exact shape a single remote needs and return read-only data; writes belong in the per-slice hooks above. Anti-patterns to avoid include mega-hooks that return the whole state tree, hooks that re-derive Redux state into local useState, hooks that fetch network data (use RTK Query), and hooks that return new object literals every render without useMemo.

How do I prevent unnecessary re-renders from custom Redux hooks?

Three rules eliminate most re-render problems. First, define selectors at module scope or wrap them in createSelector — never as arrow functions inside the hook body, because the arrow is recreated each render and breaks useSelector's referential equality bailout. Second, wrap action callbacks in useCallback with [dispatch] in the dependency array so the returned functions keep stable references and consumers that pass them as props do not re-render their children unnecessarily. Third, encourage destructuring at the consumer (const { items, totals } = useCart()) instead of passing the whole hook return object into useEffect dependencies — primitive references stay stable, object literals returned by the hook do not without useMemo. Together these three rules make custom hooks no more expensive than raw useSelector/useDispatch calls.

Should custom hooks return data and actions in the same object?

Yes for read+write hooks like useCurrentUser and useCart, because the data and actions travel together — a component that reads cart items almost always needs addItem, removeItem, and updateQuantity. Returning one object means the consumer writes const { items, addItem } = useCart() instead of importing two hooks. For pure read-only hooks (useIsLoggedIn) return the value directly, not an object — wrapping a boolean in { isLoggedIn } adds destructuring noise with no benefit. For view-model hooks like useHeaderViewModel the return is data-only because writes belong in the underlying per-feature hooks; mixing reads from three slices and writes for three slices into one hook creates a god-object that re-renders the consumer on every dispatch anywhere in the store.

How do I test custom Redux hooks for a micro frontend?

Use @testing-library/react's renderHook with a Provider wrapper that supplies a fresh makeStore() per test. Each test creates a new store, renders the hook through the Provider, and asserts on result.current — the hook's returned object. For read assertions, check the initial values. For write assertions, call result.current.login(...) inside act() and re-check the same fields after the dispatch settles. Always include a stability test: rerender the hook and confirm result.current.login (or any action) keeps the SAME reference across renders — this verifies useCallback is wired correctly and catches the common bug where missing dependency arrays make every render produce new functions. Custom hooks in the federated package MUST be unit tested because a subtle bug breaks every remote in the workspace silently.

What is the difference between useAppSelector and useSelector in a micro frontend?

Functionally they are identical — useAppSelector is a thin alias for useSelector exported from the federated store package. The reason every remote should import useAppSelector from @myapp/store instead of useSelector from react-redux is twofold. First, the alias gives the workspace one place to add typing (in TypeScript useAppSelector is typed against RootState; the plain useSelector returns unknown without a generic argument), and one place to change Redux internals later — a future migration to a different store library would only touch hooks.js. Second, importing through @myapp/store guarantees the singleton path: a remote that imports useSelector directly from react-redux risks pulling its own bundled copy if the Module Federation shared block is misconfigured, which causes the hook to read from a different React context and return stale state across remotes.