reactjsreduxredux-toolkitrtk-querywebpack-module-federation

redux toolkit + RTK query dynamic slice Injection using combineSlices


We are creating an application on the React + Redux Toolkit + RTK Query + Webpack Module Federation + Nx stack. The idea is to split a large application into modules, create a global store and fill it with reducers as modules are lazily loaded.

The problem is that when loading a module, the slice is not added to the rootReucer.

Folder structure:

apps/
|--shell/
|--moneyback/
libs/
|--store/
|--auth/

libs/store

This is a global store, it is assumed that when the application starts it will be empty.

export interface LazyLoadedSlices {}

export const rootReducer =
  combineSlices(staticSlice).withLazyLoadedSlices<LazyLoadedSlices>();

export const dynamicMiddleware = createDynamicMiddleware();

export const store = configureStore({
  devTools: process.env.ENV !== 'production',
  reducer: rootReducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({ serializableCheck: false }).concat([
      dynamicMiddleware.middleware,
    ]),
});

libs/auth

import { rootReducer } from '@core-backoffice/store';


export const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {
    addTokens: (state, { payload }: PayloadAction<PayloadLogin>) => {
      const { token, refresh } = payload;
      state.token = token;
      state.refresh = refresh;
    },
    addUserData: (state, { payload }: PayloadAction<IUser>) => {
      const { email, fullName, roles, userId, workspaces } = payload;
      state.email = email;
      state.fullName = fullName;
      state.roles = roles;
      state.userId = userId;
      state.workspaces = workspaces;
    },
    logout: () => initialState,
  },
  selectors: {
    selectToken: (state) => state.token,
    selectRefreshToken: (state) => state.refresh,
  },
});

declare module '@core-backoffice/store' {
  export interface LazyLoadedSlices extends WithSlice<typeof authSlice> {}
}

const injectedSlice = authSlice.injectInto(rootReducer);

export const { addTokens, logout, addUserData } = injectedSlice.actions;
export const { selectRefreshToken, selectToken } = injectedSlice.selectors;

apps/shell

useDispatch, useSelector and authApi are used in useAuth and useCheckAuth hooks

export function App() {
  useCheckAuth();

  const location = useLocation();
  const rootPath = useRootPath('/');

  const token = useAppSelector((store) => store.auth?.token);

  const { logout } = useAuth();

  return (
    <React.Suspense fallback={null}>
      <Routes>
        <Route path="/" element={<NxWelcome title="shell" />} />
        <Route path="/moneyback/*" element={<MoneybackApp />} />
        <Route path="/login" element={<LoginPage />} />
      </Routes>
    </React.Suspense>
  );
}

export default App;

apps/moneyback/slice

The apps/moneyback/app.tsx also uses useDispatch, useSelector and servicesApi hooks and also uses useAuth and useCheckAuth

import { rootReducer } from '@core-backoffice/store';
import { createSlice, WithSlice } from '@reduxjs/toolkit';

const initialState: { services: string[] } = {
  services: [],
};

export const servicesSlice = createSlice({
  name: 'servicesSlice',
  initialState,
  reducers: {
    addService: (state) => {
      state.services = [String(Math.random())];
    },
  },
  selectors: {
    selectServices: (state) => state.services,
  },
});

declare module '@core-backoffice/store' {
  export interface LazyLoadedSlices extends WithSlice<typeof servicesSlice> {}
}

const injectedSlice = servicesSlice.injectInto(rootReducer);
export const { addService } = injectedSlice.actions;
export const { selectServices } = injectedSlice.selectors;

(image) authSlice in global store

After launching the application, authSlcie and authApi were added to the store.

(image) in the picture you can see that dispatch(addServices) is called, but there is no slice in the store

All code was written according to the documentation redux toolkit code splitting

I expect that after switching to loclhost/moneyback, the moneyback module will be lazily loaded and servicesApi and servicesSlice will be added to the store. However, this does not happen.

UPD SOLUTION

Big thanks to @phry for solving the issue. Here's what we got in the end

libs/store

export interface LazyLoadedSlices {}

const keyReduxStore = Symbol.for('reduxStore') as unknown as number;
const keyRootReducer = Symbol.for('rootReducer') as unknown as number;
const keyDynamicMiddleware = Symbol.for(
  'dynamicMiddleware'
) as unknown as number;

const combineReducers = () => {
  return combineSlices({}).withLazyLoadedSlices<LazyLoadedSlices>();
};
window[keyRootReducer] = window[keyRootReducer] || combineReducers();
export const rootReducer = window[keyRootReducer] as unknown as ReturnType<
  typeof combineReducers
>;

window[keyDynamicMiddleware] =
  window[keyDynamicMiddleware] || createDynamicMiddleware();
export const dynamicMiddleware = window[
  keyDynamicMiddleware
] as unknown as DynamicMiddlewareInstance<any, Dispatch<UnknownAction>>;

const setupStore = () => {
  return configureStore({
    devTools: process.env.ENV !== 'production',
    reducer: rootReducer,
    middleware: (getDefaultMiddleware) =>
      getDefaultMiddleware().concat(dynamicMiddleware.middleware),
  });
};
window[keyReduxStore] = window[keyReduxStore] || setupStore();
export const store = window[keyReduxStore] as unknown as AppStore;

export type RootState = ReturnType<typeof rootReducer>;
export type AppStore = ReturnType<typeof setupStore>;
export type AppDispatch = AppStore['dispatch'];

Solution

  • This could happen if your bundler somehow bundles libs/store twice in different bundles.

    If that happens, you could get around that by doing something like

    window[Symbol.for("ReduxStore")] = window[Symbol.for("ReduxStore")] || configureStore(...)
    
    export const store = window[Symbol.for("ReduxStore")]
    

    in your libs/store