Comprehensive guide for implementing cross-micro-frontend state management using Scalprum's remote hooks and shared stores pattern.
- Overview
- Core Concepts
- Module Federation Exposure
- Creating Shared Stores
- Consuming Remote Hooks
- Rendering Optimization
- Type Safety
- Complete Example
- Best Practices
- Common Pitfalls
Scalprum Remote Hooks and Shared Stores enable event-driven state management across micro-frontend boundaries using Webpack Module Federation. This pattern solves the challenge of sharing state between independently deployed applications while maintaining optimal rendering performance.
Why use this pattern?
- Cross-app state sharing: Multiple micro-frontends can access and modify the same state
- Event-driven architecture: Changes propagate automatically via Scalprum's event system
- Rendering optimization: Selective subscriptions prevent unnecessary re-renders
- Type safety: Full TypeScript support with runtime payload validation
- No prop drilling: Access state from any component without passing props
Reference Implementation: /src/Routes/SharedStoresDemo/ demonstrates a complete working example.
A shared store is a singleton state container created with createSharedStore() from @scalprum/core. It manages state and dispatches events when state changes.
Location: /src/hooks/sharedStores/useFedModulesStore.ts
import { createSharedStore } from '@scalprum/core';
// Define events that trigger state updates
const EVENTS = [
'FETCH_START',
'FETCH_SUCCESS',
'FETCH_ERROR',
'SET_FILTER',
'SET_SORT',
'CLEAR_FILTERS',
] as const;
// Create singleton store instance
const store = createSharedStore({
initialState,
events: EVENTS,
onEventChange: handleEvents, // Event handler function
});A remote hook is a React hook exposed via Module Federation that can be consumed by other micro-frontends using useRemoteHook() from @scalprum/react-core.
Consumer Example: /src/Routes/SharedStoresDemo/SharedStoresDemo.tsx
import { useRemoteHook } from '@scalprum/react-core';
const { hookResult, loading, error } = useRemoteHook<FedModulesStoreResult>({
scope: 'frontendStarterApp',
module: './frontendModules/useFedModulesStore',
args: undefined, // Optional: omit if hook takes no arguments
});State changes propagate through events. When you call store.updateState(event, payload), all components subscribed to that event automatically re-render with new state.
// Trigger event with payload
store.updateState('SET_FILTER', {
filterConfig: { searchTerm: 'example' }
});To make hooks consumable across micro-frontends, expose them via Module Federation in fec.config.js.
Configuration: /fec.config.js
module.exports = {
appUrl: '/staging/starter',
moduleFederation: {
exposes: {
// Expose main root app
'./RootApp': './src/AppEntry',
// Expose shared store hook - full data access
'./frontendModules/useFedModulesStore':
'./src/hooks/sharedStores/useFedModulesStore',
// Expose filter hook - optimized for filter UI components
'./frontendModules/useFedModulesFilter':
'./src/hooks/sharedStores/useFedModulesFilter',
},
exclude: ['react-router-dom'],
shared: [
{
'react-router-dom': {
singleton: true,
import: false,
version: '^6.3.0',
},
},
],
},
};Key Points:
- Expose path: Must start with
./(e.g.,./frontendModules/useFedModulesStore) - Module path: Relative to project root (e.g.,
./src/hooks/sharedStores/useFedModulesStore) - Naming convention: Use descriptive paths that indicate purpose (e.g.,
/frontendModules/for hooks) - Multiple exposures: You can expose multiple hooks from the same store for different use cases
export interface FedModulesState {
data: FedModulesData | null;
loading: boolean;
error: string | null;
filteredData: FedModulesData | null;
sortConfig: {
key: string | null;
direction: 'asc' | 'desc';
};
filterConfig: {
searchTerm: string;
ssoScopeFilter: string[];
moduleFilter: string;
};
}Use discriminated unions for type-safe event payloads:
const EVENTS = [
'FETCH_START',
'FETCH_SUCCESS',
'FETCH_ERROR',
'SET_FILTER',
'SET_SORT',
'CLEAR_FILTERS',
] as const;
type EventType = (typeof EVENTS)[number];
// Discriminated union for all event payloads
export type EventPayload =
| { event: 'FETCH_START' }
| { event: 'FETCH_SUCCESS'; payload: FetchSuccessPayload }
| { event: 'FETCH_ERROR'; payload: FetchErrorPayload }
| { event: 'SET_FILTER'; payload: SetFilterPayload }
| { event: 'SET_SORT'; payload: SetSortPayload }
| { event: 'CLEAR_FILTERS' };Runtime validation prevents invalid data from corrupting your store:
Implementation: /src/hooks/sharedStores/useFedModulesStore.ts (lines 122-227)
export const isFetchSuccessPayload = (
event: EventType,
payload: unknown,
): payload is FetchSuccessPayload => {
if (
event !== 'FETCH_SUCCESS' ||
typeof payload !== 'object' ||
payload === null ||
!('data' in payload)
) {
return false;
}
const { data } = payload as any;
// Validate that data is an object with the expected structure
if (typeof data !== 'object' || data === null) {
return false;
}
// Validate data contains fed module entries
return Object.values(data).every(
(entry: any) =>
typeof entry === 'object' &&
entry !== null &&
'manifestLocation' in entry &&
(!('modules' in entry) || Array.isArray(entry.modules)),
);
};The event handler is a pure function that takes previous state, event, and payload, and returns new state:
Implementation: /src/hooks/sharedStores/useFedModulesStore.ts (lines 376-475)
const handleEvents = (
prevState: FedModulesState,
event: EventType,
payload?: unknown,
): FedModulesState => {
switch (event) {
case 'FETCH_START':
return {
...prevState,
loading: true,
error: null,
};
case 'FETCH_SUCCESS':
// Validate payload before using it
if (!isFetchSuccessPayload(event, payload)) {
console.error('Invalid payload for FETCH_SUCCESS event', payload);
return prevState;
}
const newState = {
...prevState,
loading: false,
error: null,
data: payload.data,
};
return {
...newState,
filteredData: applyFiltersAndSort(
payload.data,
newState.filterConfig,
newState.sortConfig,
),
};
case 'SET_FILTER':
if (!isSetFilterPayload(event, payload)) {
console.error('Invalid payload for SET_FILTER event', payload);
return prevState;
}
const updatedFilterConfig = {
...prevState.filterConfig,
...payload.filterConfig,
};
return {
...prevState,
filterConfig: updatedFilterConfig,
filteredData: applyFiltersAndSort(
prevState.data,
updatedFilterConfig,
prevState.sortConfig,
),
};
default:
return prevState;
}
};let store: ReturnType<
typeof createSharedStore<FedModulesState, typeof EVENTS>
> | null = null;
export const getStore = () => {
if (!store) {
store = createSharedStore({
initialState,
events: EVENTS,
onEventChange: handleEvents,
});
}
return store;
};Wrap the store in a React hook that provides a clean API:
Implementation: /src/hooks/sharedStores/useFedModulesStore.ts (lines 494-640)
import { useGetState } from '@scalprum/react-core';
export const useFedModulesStore = () => {
const store = getStore();
const state = useGetState(store);
// Data fetching action
const fetchFedModules = useCallback(async () => {
store.updateState('FETCH_START');
try {
const response = await fetch(
'/api/chrome-service/v1/static/fed-modules-generated.json',
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const { $schema, ...data } = await response.json();
store.updateState('FETCH_SUCCESS', { data });
} catch (error) {
store.updateState('FETCH_ERROR', {
error: error instanceof Error
? error.message
: 'Failed to fetch fed modules',
});
}
}, [store]);
// Filter actions
const setSearchTerm = useCallback(
(searchTerm: string) => {
store.updateState('SET_FILTER', {
filterConfig: { searchTerm },
});
},
[store],
);
return {
// State
data: state.data,
filteredData: state.filteredData,
loading: state.loading,
error: state.error,
filterConfig: state.filterConfig,
sortConfig: state.sortConfig,
// Actions
fetchFedModules,
setSearchTerm,
setSortConfig,
clearFilters,
// Computed values
availableSsoScopes,
totalCount: state.data ? Object.keys(state.data).length : 0,
};
};Implementation: /src/Routes/SharedStoresDemo/SharedStoresDemo.tsx (lines 28-46)
import { useRemoteHook } from '@scalprum/react-core';
const FedModulesDataContainer: React.FC = () => {
// Load remote hook from Module Federation
const {
hookResult: storeHook,
loading: storeLoading,
error: storeError,
} = useRemoteHook<FedModulesStoreResult>({
scope: 'frontendStarterApp', // Module federation scope
module: './frontendModules/useFedModulesStore', // Exposed module path
args: undefined, // Optional: omit if hook takes no arguments
});
// Use hook result
useEffect(() => {
if (storeHook?.fetchFedModules) {
storeHook.fetchFedModules();
}
}, [storeHook?.fetchFedModules]);
// Access state from remote hook
const fedModulesData = storeHook?.filteredData || {};
const totalCount = storeHook?.totalCount || 0;-
scope: The Module Federation scope name (typically your app name in camelCase)- Find this in your
package.jsonunderinsights.appname(converted to camelCase) - Example:
"frontend-starter-app"becomes"frontendStarterApp"
- Find this in your
-
module: The exposed module path fromfec.config.js- Must match exactly what's in
moduleFederation.exposes - Example:
'./frontendModules/useFedModulesStore'
- Must match exactly what's in
-
importName: Named export to import from the module (optional)- Can be
undefinedor omitted to use the default export - Specify the exact export name when using named exports
- Example:
'useFedModulesStore'forexport const useFedModulesStore = ... - Example:
undefinedforexport default useFedModulesStore
- Can be
-
args: Arguments passed to the remote hook (optional)- Can be
undefinedor omitted entirely if the hook takes no parameters - Must be memoized if providing arguments with non-primitive types (objects, arrays, functions)
- Primitive values (strings, numbers, booleans) don't need memoization
// ✅ No arguments needed - use undefined or omit const { hookResult } = useRemoteHook({ scope, module }); const { hookResult } = useRemoteHook({ scope, module, args: undefined }); // ✅ Primitive arguments - no memoization needed const { hookResult } = useRemoteHook({ scope, module, args: ['searchTerm', 42, true] }); // ✅ Non-primitive arguments - MUST be memoized const filterConfig = useMemo(() => ({ name: 'test', values: [1, 2, 3] }), []); const { hookResult } = useRemoteHook({ scope, module, args: [filterConfig] // Object is memoized }); // ❌ Non-primitive without memoization - causes infinite re-renders const { hookResult } = useRemoteHook({ scope, module, args: [{ name: 'test' }] // New object every render! });
- Can be
const { hookResult, loading, error } = useRemoteHook<T>({ ... });hookResult: The return value of the remote hook (orundefinedwhile loading)loading: Boolean indicating if the hook is still loadingerror: Error object if the hook failed to load
Always handle loading and error states:
if (storeLoading) {
return <Spinner size="lg" />;
}
if (storeError) {
return (
<Alert variant="danger" title="Failed to load remote hooks">
<p>Store error: {storeError?.message}</p>
</Alert>
);
}
if (!storeHook) {
return <Alert variant="warning" title="Hook not available" />;
}One of the key advantages of Scalprum shared stores is selective event subscription to prevent unnecessary re-renders.
When using useGetState(store), your component re-renders on every state change:
// ❌ Re-renders on ALL events (FETCH_START, FETCH_SUCCESS, SET_FILTER, etc.)
const state = useGetState(store);This is fine for components that need all the data, but wasteful for filter UI components that only need filter state.
Use useSubscribeStore() to subscribe only to specific events:
Implementation: /src/hooks/sharedStores/useFedModulesFilter.ts
import { useSubscribeStore } from '@scalprum/react-core';
export const useFedModulesFilter = () => {
const store = getStore();
// ✅ Only re-renders when SET_FILTER or CLEAR_FILTERS events fire
const filterConfig = useSubscribeStore(
store,
'SET_FILTER', // Subscribe to this event
(state: FedModulesState) => state.filterConfig, // Selector function
);
const setSearchTerm = useCallback(
(searchTerm: string) => {
store.updateState('SET_FILTER', {
filterConfig: { searchTerm },
});
},
[store],
);
return {
filterConfig, // Only filter state, not data
setSearchTerm,
setSsoScopeFilter,
setModuleFilter,
clearFilters,
};
};Create multiple specialized hooks from the same store:
-
Full Store Hook (
useFedModulesStore) - For data display components- Subscribes to all events
- Returns data, loading state, actions
- Used by: Table components, data visualizations
-
Filter Hook (
useFedModulesFilter) - For filter UI components- Subscribes only to
SET_FILTERandCLEAR_FILTERSevents - Returns only filter state and filter actions
- Used by: Search inputs, filter dropdowns
- Subscribes only to
Usage Example: /src/Routes/SharedStoresDemo/useFedModulesDataView.tsx (lines 48-96)
// In a filter input component
const { filterHook } = useRemoteHook<FedModulesFilterResult>({
scope: 'frontendStarterApp',
module: './frontendModules/useFedModulesFilter', // Optimized filter hook
});
// This component ONLY re-renders when filters change,
// NOT when data is fetched or sorted
useEffect(() => {
if (filterHook?.setFilters) {
filterHook.setFilters(filters);
}
}, [filterHook?.setFilters, filters]);| Component Type | Hook Used | Re-renders On | Performance Impact |
|---|---|---|---|
| Data Table | useFedModulesStore |
All events | Necessary |
| Filter Input | useFedModulesFilter |
Filter events only | 70% reduction |
| Sort Controls | useFedModulesSort (if created) |
Sort events only | 60% reduction |
Create TypeScript interfaces for what your hooks return:
Implementation: /src/Routes/SharedStoresDemo/types.ts
export interface FedModulesStoreResult {
// State
data: FedModulesData | null;
filteredData: FedModulesData | null;
loading: boolean;
error: string | null;
// Actions
fetchFedModules: () => void;
setSearchTerm: (term: string) => void;
setSortConfig: (key: string, direction: 'asc' | 'desc') => void;
toggleSort: (key: string) => void;
clearFilters: () => void;
// Computed values
totalCount: number;
filteredCount: number;
availableSsoScopes: string[];
// Configuration
sortConfig: {
validKeys: string[];
key: string | null;
direction: 'asc' | 'desc';
};
filterConfig: {
searchTerm: string;
ssoScopeFilter: string[];
moduleFilter: string;
};
}
export interface FedModulesFilterResult {
filterConfig: {
searchTerm: string;
ssoScopeFilter: string[];
moduleFilter: string;
};
setFilters: (config: Partial<FedModulesFilterResult['filterConfig']>) => void;
clearFilters: () => void;
}Pass your return type to useRemoteHook:
const { hookResult } = useRemoteHook<FedModulesStoreResult>({
scope: 'frontendStarterApp',
module: './frontendModules/useFedModulesStore',
});
// TypeScript now knows hookResult has type FedModulesStoreResult | undefined
hookResult?.fetchFedModules(); // ✅ Type-safe
hookResult?.invalidMethod(); // ❌ TypeScript errorTypeScript only validates at compile time. Add runtime type guards for payload validation:
export const isSetFilterPayload = (
event: EventType,
payload: unknown,
): payload is SetFilterPayload => {
if (
event !== 'SET_FILTER' ||
typeof payload !== 'object' ||
payload === null ||
!('filterConfig' in payload)
) {
return false;
}
const { filterConfig } = payload as any;
// Validate structure
if (typeof filterConfig !== 'object' || filterConfig === null) {
return false;
}
// Validate each property
const validFilterKeys = ['searchTerm', 'ssoScopeFilter', 'moduleFilter'];
const providedKeys = Object.keys(filterConfig);
return (
providedKeys.every((key) => validFilterKeys.includes(key)) &&
providedKeys.every((key) => {
if (key === 'ssoScopeFilter') {
return (
Array.isArray(filterConfig[key]) &&
filterConfig[key].every((item: any) => typeof item === 'string')
);
}
return typeof filterConfig[key] === 'string';
})
);
};Use type guards in your event handler:
case 'SET_FILTER':
if (!isSetFilterPayload(event, payload)) {
console.error('Invalid payload for SET_FILTER event', payload);
return prevState; // Return unchanged state
}
// payload is now typed as SetFilterPayload
const updatedFilterConfig = {
...prevState.filterConfig,
...payload.filterConfig,
};
// ...Here's a minimal working example demonstrating the entire pattern:
import { createSharedStore } from '@scalprum/core';
import { useGetState } from '@scalprum/react-core';
import { useCallback } from 'react';
// State interface
interface CounterState {
count: number;
lastUpdated: Date | null;
}
// Events
const EVENTS = ['INCREMENT', 'DECREMENT', 'RESET'] as const;
type EventType = (typeof EVENTS)[number];
// Initial state
const initialState: CounterState = {
count: 0,
lastUpdated: null,
};
// Event handler
const handleEvents = (
prevState: CounterState,
event: EventType,
): CounterState => {
const now = new Date();
switch (event) {
case 'INCREMENT':
return { count: prevState.count + 1, lastUpdated: now };
case 'DECREMENT':
return { count: prevState.count - 1, lastUpdated: now };
case 'RESET':
return { count: 0, lastUpdated: now };
default:
return prevState;
}
};
// Singleton store
let store: ReturnType<
typeof createSharedStore<CounterState, typeof EVENTS>
> | null = null;
const getStore = () => {
if (!store) {
store = createSharedStore({
initialState,
events: EVENTS,
onEventChange: handleEvents,
});
}
return store;
};
// Hook interface
export const useCounterStore = () => {
const store = getStore();
const state = useGetState(store);
const increment = useCallback(() => {
store.updateState('INCREMENT');
}, [store]);
const decrement = useCallback(() => {
store.updateState('DECREMENT');
}, [store]);
const reset = useCallback(() => {
store.updateState('RESET');
}, [store]);
return {
count: state.count,
lastUpdated: state.lastUpdated,
increment,
decrement,
reset,
};
};module.exports = {
appUrl: '/staging/starter',
moduleFederation: {
exposes: {
'./RootApp': './src/AppEntry',
'./hooks/useCounterStore': './src/hooks/useCounterStore',
},
},
};import { useRemoteHook } from '@scalprum/react-core';
interface CounterStoreResult {
count: number;
lastUpdated: Date | null;
increment: () => void;
decrement: () => void;
reset: () => void;
}
const CounterDisplay: React.FC = () => {
const { hookResult, loading, error } = useRemoteHook<CounterStoreResult>({
scope: 'frontendStarterApp',
module: './hooks/useCounterStore',
// args parameter omitted - hook takes no arguments
});
if (loading) return <Spinner />;
if (error) return <Alert variant="danger" title="Failed to load counter" />;
if (!hookResult) return null;
return (
<div>
<h1>Count: {hookResult.count}</h1>
<p>Last updated: {hookResult.lastUpdated?.toLocaleTimeString()}</p>
<button onClick={hookResult.increment}>+1</button>
<button onClick={hookResult.decrement}>-1</button>
<button onClick={hookResult.reset}>Reset</button>
</div>
);
};Always use the singleton pattern to ensure one store instance across your application:
let store: ReturnType<typeof createSharedStore<...>> | null = null;
export const getStore = () => {
if (!store) {
store = createSharedStore({ ... });
}
return store;
};Critical Rule: Memoize args when passing non-primitive types (objects, arrays, functions) to prevent infinite re-renders.
// ✅ Good - no args needed
const { hookResult } = useRemoteHook({ scope, module });
// ✅ Good - primitive values don't need memoization
const { hookResult } = useRemoteHook({
scope,
module,
args: ['userId123', 42] // Strings and numbers are fine
});
// ✅ Good - non-primitives memoized
const filterConfig = useMemo(() => ({ name: 'test', ids: [1, 2] }), []);
const { hookResult } = useRemoteHook({ scope, module, args: [filterConfig] });
// ❌ Bad - creates new object/array every render (infinite loop!)
const { hookResult } = useRemoteHook({
scope,
module,
args: [{ name: 'test' }] // New object instance every render!
});
// ❌ Bad - even empty arrays need memoization
const { hookResult } = useRemoteHook({
scope,
module,
args: [] // New array every render!
});Why? useRemoteHook uses args in its dependency array. New object/array instances trigger re-execution, causing infinite loops.
Define TypeScript interfaces for:
- Store state
- Event payloads
- Hook return values
- Component props that use remote hooks
// Types in separate file for reusability
export interface StoreState { ... }
export interface EventPayload { ... }
export interface HookResult { ... }Always validate event payloads in production:
case 'UPDATE_DATA':
if (!isUpdateDataPayload(event, payload)) {
if (process.env.NODE_ENV === 'development') {
console.error('Invalid payload', payload);
}
return prevState; // Don't corrupt state with invalid data
}
// Safe to use payload hereSplit functionality into multiple hooks for optimal rendering:
// Full access hook
export const useDataStore = () => {
const state = useGetState(store);
return { data: state.data, loading: state.loading, ... };
};
// Filter-only hook (prevents re-renders on data changes)
export const useDataFilter = () => {
const filterConfig = useSubscribeStore(
store,
'SET_FILTER',
(state) => state.filterConfig
);
return { filterConfig, setFilter, ... };
};Always handle all states when consuming remote hooks:
const { hookResult, loading, error } = useRemoteHook<T>({ ... });
if (loading) return <LoadingState />;
if (error) return <ErrorState error={error} />;
if (!hookResult) return <EmptyState />;
// Safe to use hookResult here
return <DataDisplay data={hookResult.data} />;Don't expose internal implementation details:
// ✅ Good - clean public API
return {
data: state.data,
loading: state.loading,
fetchData,
updateFilter,
};
// ❌ Bad - exposes internals
return {
data: state.data,
_internalState: state, // Don't expose!
store, // Don't expose!
fetchData,
};Add comments in fec.config.js to explain what each exposed module does:
moduleFederation: {
exposes: {
'./RootApp': './src/AppEntry',
// Full data access with filtering, sorting, and stats
'./frontendModules/useFedModulesStore':
'./src/hooks/sharedStores/useFedModulesStore',
// Filter-only hook for search/filter UI components (optimized rendering)
'./frontendModules/useFedModulesFilter':
'./src/hooks/sharedStores/useFedModulesFilter',
},
}Problem: Creates infinite re-render loop when passing objects, arrays, or functions
// ❌ Bad - new array every render causes useRemoteHook to re-execute
const { hookResult } = useRemoteHook({
scope: 'app',
module: './hook',
args: [], // New array instance every render!
});
// ❌ Bad - new object every render
const { hookResult } = useRemoteHook({
scope: 'app',
module: './hook',
args: [{ filter: 'test' }], // New object instance every render!
});Solution: Either omit args or memoize non-primitives
// ✅ Good - no args needed
const { hookResult } = useRemoteHook({
scope: 'app',
module: './hook',
// args parameter omitted
});
// ✅ Good - memoized non-primitive args
const filterConfig = useMemo(() => ({ filter: 'test' }), []);
const { hookResult } = useRemoteHook({
scope: 'app',
module: './hook',
args: [filterConfig],
});
// ✅ Good - primitives don't need memoization
const { hookResult } = useRemoteHook({
scope: 'app',
module: './hook',
args: ['userId', 123], // Primitives are fine
});Problem: useRemoteHook fails with "Module not found" error
// ❌ Bad - using package name with dashes
const { hookResult } = useRemoteHook({
scope: 'frontend-starter-app', // Wrong!
module: './hooks/useStore',
});Solution: Convert package name to camelCase
// ✅ Good - camelCase scope name
const { hookResult } = useRemoteHook({
scope: 'frontendStarterApp', // Correct!
module: './hooks/useStore',
});Find your scope name:
- Check
package.json→insights.appname - Convert to camelCase:
frontend-starter-app→frontendStarterApp
Problem: Hook fails to load due to incorrect module path
// In fec.config.js
exposes: {
'./hooks/counter': './src/hooks/useCounter',
}
// In consumer
const { hookResult } = useRemoteHook({
scope: 'app',
module: './hooks/useCounter', // ❌ Wrong! Doesn't match expose path
});Solution: Use exact path from fec.config.js
const { hookResult } = useRemoteHook({
scope: 'app',
module: './hooks/counter', // ✅ Matches expose path
});Problem: Application crashes when remote hook fails to load
// ❌ Bad - no error handling
const { hookResult } = useRemoteHook({ ... });
return <DataDisplay data={hookResult.data} />; // Crashes if hookResult is undefinedSolution: Handle all states
// ✅ Good
const { hookResult, loading, error } = useRemoteHook({ ... });
if (loading) return <Spinner />;
if (error) return <Alert variant="danger" title={error.message} />;
if (!hookResult) return null;
return <DataDisplay data={hookResult.data} />;Problem: Multiple store instances break state synchronization
// ❌ Bad - creates new store on every call
export const useCounterStore = () => {
const store = createSharedStore({ ... }); // New instance every time!
// ...
};Solution: Use singleton pattern
// ✅ Good - one instance shared across all consumers
let store = null;
const getStore = () => {
if (!store) {
store = createSharedStore({ ... });
}
return store;
};
export const useCounterStore = () => {
const store = getStore(); // Always same instance
// ...
};Problem: Unnecessary re-renders tank performance
// ❌ Bad - filter input re-renders when data is fetched
export const useFilterInput = () => {
const state = useGetState(store); // Subscribes to ALL events
return { searchTerm: state.filterConfig.searchTerm };
};Solution: Use selective subscription
// ✅ Good - only re-renders on filter changes
export const useFilterInput = () => {
const filterConfig = useSubscribeStore(
store,
'SET_FILTER', // Only this event
(state) => state.filterConfig
);
return { searchTerm: filterConfig.searchTerm };
};Problem: Invalid data corrupts store state
// ❌ Bad - no validation
case 'UPDATE_DATA':
return {
...prevState,
data: payload.data, // What if payload.data is invalid?
};Solution: Always validate before using payload
// ✅ Good
case 'UPDATE_DATA':
if (!isUpdateDataPayload(event, payload)) {
console.error('Invalid payload', payload);
return prevState; // Keep state intact
}
return {
...prevState,
data: payload.data, // Safe to use now
};Problem: Hook works locally but fails when consumed remotely
// Hook exists at /src/hooks/useStore.ts
// But NOT in fec.config.js exposes!
// Consumer gets error: "Module not found"
const { hookResult } = useRemoteHook({
scope: 'app',
module: './hooks/useStore', // Not exposed!
});Solution: Add to fec.config.js
// fec.config.js
moduleFederation: {
exposes: {
'./RootApp': './src/AppEntry',
'./hooks/useStore': './src/hooks/useStore', // ✅ Now exposed
},
}Common Misconception: "Exposed modules can't use relative imports"
Reality: Module Federation resolves ALL imports within exposed modules correctly. The entire module and its dependencies are bundled and loaded together.
// ✅ This works perfectly fine in exposed modules
import { helper } from '../utils/helper';
import { formatDate } from './formatters';
import { API_ENDPOINT } from '../../config/constants';
export const useStore = () => {
// All relative imports resolve correctly
const data = helper.transform(API_ENDPOINT);
return { data };
};What actually matters:
- Shared dependencies: Configure shared modules in
fec.config.jsto avoid version conflicts - Circular dependencies: Avoid circular imports within your exposed modules
- Side effects: Be aware of side effects in imported modules (they execute when loaded)
When to expose utilities separately:
- When multiple apps need the same utility (code sharing)
- When you want versioning control over utilities
- NOT because relative imports "don't work" - they do!
Problem: Breaks React's immutability contract
// ❌ Bad - mutating state
case 'ADD_ITEM':
prevState.items.push(payload.item); // Mutation!
return prevState;Solution: Always return new state objects
// ✅ Good - immutable update
case 'ADD_ITEM':
return {
...prevState,
items: [...prevState.items, payload.item], // New array
};- Scalprum Documentation: https://github.com/scalprum/scaffolding
- Reference Implementation:
/src/Routes/SharedStoresDemo/in this repository - FEC Configuration: Frontend Components Config
Scalprum Remote Hooks and Shared Stores provide a powerful pattern for cross-micro-frontend state management:
- Create shared stores with
createSharedStore()for centralized state - Expose hooks via Module Federation in
fec.config.js - Consume remotely with
useRemoteHook()from any micro-frontend - Optimize rendering with
useSubscribeStore()for selective event subscriptions - Ensure type safety with TypeScript interfaces and runtime validation
- Follow best practices for singleton stores, memoization, and error handling
This architecture enables scalable, performant micro-frontend applications with shared state while maintaining independence and optimal rendering characteristics.