rtk-service-loader
v1.0.2
Published
Dynamic service injection for Redux Toolkit with Redux Saga support
Maintainers
Readme
🚀 RTK Service Loader
Dynamic service injection for Redux Toolkit with Redux Saga support. Built for modern React applications.
✨ Features
- 🎯 Dynamic Injection: Load reducers and sagas on-demand
- 🔄 Reference Counting: Automatic cleanup when services are no longer needed
- 📘 TypeScript First: Full type safety and IntelliSense
- ⚛️ React 18+: Compatible with modern React including StrictMode
- 🎨 Redux Toolkit: Built for RTK patterns
- 🔥 Redux Saga: First-class saga support
- 🧹 Memory Safe: Prevents memory leaks with automatic cleanup
- 🎣 Lifecycle Hooks: Before/after initialization callbacks
- 💾 Keep Alive: Optional persistent services
- 🔌 HOC Pattern:
withServiceswrapper
📦 Installation
npm install rtk-service-loaderPeer Dependencies
npm install @reduxjs/toolkit react react-redux redux redux-saga🚀 Quick Start
1. Setup Store
Create your Redux store with the reducer manager:
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import createSagaMiddleware from 'redux-saga';
import { createReducerManager, createService } from 'rtk-service-loader';
// Create saga middleware
export const sagaMiddleware = createSagaMiddleware();
// Create reducer manager
export const reducerManager = createReducerManager({});
// Configure store
export const store = configureStore({
reducer: reducerManager.reduce,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({ thunk: false }).concat(sagaMiddleware),
});
// Create service creator
export const serviceCreator = createService(
store,
reducerManager,
sagaMiddleware
);
// Export types
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;2. Create a Service
Create a feature service with reducer and saga:
// features/counter/counterService.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { put, takeLatest, delay } from 'redux-saga/effects';
import { serviceCreator } from '../../store';
export const COUNTER_SERVICE_NAME = 'counter';
// State interface
interface CounterState {
value: number;
loading: boolean;
}
// Slice
const counterSlice = createSlice({
name: COUNTER_SERVICE_NAME,
initialState: {
value: 0,
loading: false,
} as CounterState,
reducers: {
increment: (state) => {
state.value += 1;
},
decrement: (state) => {
state.value -= 1;
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload;
},
},
});
export const { increment, decrement, setLoading } = counterSlice.actions;
// Saga
function* incrementAsync(): Generator<any, void, any> {
yield put(setLoading(true));
yield delay(1000);
yield put(increment());
yield put(setLoading(false));
}
function* counterSaga() {
yield takeLatest('counter/incrementAsync', incrementAsync);
}
// Create service hook
export const useCounterService = serviceCreator({
id: COUNTER_SERVICE_NAME,
reducersMap: {
[COUNTER_SERVICE_NAME]: counterSlice.reducer,
},
sagas: [counterSaga],
before: (store) => {
console.log('🔢 Counter service initializing...');
},
after: () => {
console.log('✅ Counter service ready!');
},
});
// Export service loader for HOC pattern
export const importCounterService = () => useCounterService;
// Action creators
export const incrementAsync = () => ({ type: 'counter/incrementAsync' });🎨 Usage Patterns
Pattern 1: Direct Hook Usage (Simple)
Use the service hook directly in your component:
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { useCounterService, increment, incrementAsync } from './counterService';
import type { RootState } from '../../store';
function CounterPage() {
// Inject the service
const { isActive } = useCounterService();
const { value, loading } = useSelector(
(state: RootState) => state.counter ?? { value: 0, loading: false }
);
const dispatch = useDispatch();
if (!isActive) return <div>Loading service...</div>;
return (
<div>
<h1>Counter: {value}</h1>
<button onClick={() => dispatch(increment())}>+1</button>
<button onClick={() => dispatch(incrementAsync())} disabled={loading}>
+1 Async {loading && '(loading...)'}
</button>
</div>
);
}Pattern 2: HOC Wrapper
Wrap your pages with services using withServices HOC:
import { withServices } from 'rtk-service-loader';
import { importCounterService } from './features/counter/counterService';
import { importUserService } from './features/user/userService';
// Single service wrapper
export const CounterService: React.FC<{ children: React.ReactNode }> =
withServices([importCounterService])(({ children }) => <>{children}</>);
// Multiple services wrapper
export const AppServices: React.FC<{ children: React.ReactNode }> =
withServices([
importCounterService,
importUserService,
])(({ children }) => <>{children}</>);
// Usage in pages
function CounterPage() {
return (
<CounterService>
<CounterContent />
</CounterService>
);
}
// Or wrap the entire page
const CounterPageWithService = withServices([importCounterService])(CounterPage);Pattern 3: ServiceProvider Component
Use the declarative ServiceProvider component:
import { ServiceProvider } from 'rtk-service-loader';
import { useCounterService } from './features/counter/counterService';
import { useAuthService } from './features/auth/authService';
function DashboardPage() {
return (
<ServiceProvider services={[useAuthService, useCounterService]}>
<DashboardContent />
</ServiceProvider>
);
}Pattern 4: Route-Based Service Loading
Load services per route for code splitting:
// routes.tsx
import { lazy } from 'react';
import { withServices } from 'rtk-service-loader';
import { importCounterService } from './features/counter/counterService';
import { importFormsService } from './features/forms/formsService';
// Lazy load page components
const CounterPage = lazy(() => import('./pages/CounterPage'));
const FormsPage = lazy(() => import('./pages/FormsPage'));
// Wrap routes with their services
const CounterRoute = withServices([importCounterService])(CounterPage);
const FormsRoute = withServices([importFormsService])(FormsPage);
// Router configuration
export const routes = [
{
path: '/counter',
element: <CounterRoute />,
},
{
path: '/forms',
element: <FormsRoute />,
},
];Pattern 5: Layout-Level Services
Load common services at layout level:
import { Outlet } from 'react-router-dom';
import { withServices } from 'rtk-service-loader';
import { importAuthService } from './features/auth/authService';
import { importNotificationService } from './features/notifications/notificationService';
// Base layout component
const AppLayout: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<div className="app-layout">
<Header />
<Sidebar />
<main>{children}</main>
<Footer />
</div>
);
// Wrap layout with global services
export const AuthenticatedLayout = withServices([
importAuthService,
importNotificationService,
])(AppLayout);
// Router setup
<Routes>
<Route element={<AuthenticatedLayout />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/profile" element={<Profile />} />
<Route path="/settings" element={<Settings />} />
</Route>
</Routes>Pattern 6: Nested Services (Feature Modules)
Organize services by feature modules:
// features/forms/index.tsx
import { withServices } from 'rtk-service-loader';
import { importFormsService } from './formsService';
import { importFormsValidationService } from './validationService';
// Forms module wrapper
export const FormsModule = withServices([
importFormsService,
importFormsValidationService,
])(({ children }: { children: React.ReactNode }) => <>{children}</>);
// Usage
function FormsSection() {
return (
<FormsModule>
<FormsList />
<FormEditor />
<FormPreview />
</FormsModule>
);
}Pattern 7: Conditional Service Loading
Load services based on conditions:
import { ServiceProvider } from 'rtk-service-loader';
import { useAuthService } from './features/auth/authService';
import { useAdminService } from './features/admin/adminService';
function AdminDashboard({ isAdmin }: { isAdmin: boolean }) {
// Conditionally include admin service
const services = [
useAuthService,
...(isAdmin ? [useAdminService] : []),
];
return (
<ServiceProvider services={services}>
<DashboardContent />
</ServiceProvider>
);
}🏗️ Architecture Examples
Example 1: Feature-Based Architecture
src/
├── store/
│ └── index.ts # Store setup
├── features/
│ ├── auth/
│ │ ├── authService.ts # Service definition
│ │ ├── AuthProvider.tsx # Service wrapper
│ │ └── components/
│ │ ├── LoginForm.tsx
│ │ └── UserMenu.tsx
│ ├── forms/
│ │ ├── formsService.ts
│ │ ├── FormsProvider.tsx
│ │ └── components/
│ └── notifications/
│ ├── notificationsService.ts
│ └── NotificationsProvider.tsx
└── pages/
├── DashboardPage.tsx
└── FormsPage.tsxExample 2: Domain-Driven Architecture
// domains/user/userService.ts
export const useUserService = serviceCreator({
id: 'user',
reducersMap: { user: userReducer },
sagas: [userSaga],
});
// domains/user/UserDomain.tsx
export const UserDomain = withServices([() => useUserService])(
({ children }) => children
);
// app/App.tsx
<UserDomain>
<Router>
<Routes>
<Route path="/profile" element={<ProfilePage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Router>
</UserDomain>Example 3: Micro-Frontend Architecture
// Lazy load feature modules with their services
const FormsApp = lazy(() => import('./apps/forms/FormsApp'));
const AdminApp = lazy(() => import('./apps/admin/AdminApp'));
// Each app brings its own services
function App() {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/forms/*" element={<FormsApp />} />
<Route path="/admin/*" element={<AdminApp />} />
</Routes>
</Suspense>
);
}
// apps/forms/FormsApp.tsx
export default withServices([
importFormsService,
importFormsValidationService,
])(FormsApplication);📖 API Reference
createReducerManager(initialReducers?)
Creates a reducer manager for dynamic injection.
Parameters:
initialReducers(optional): Initial reducer map
Returns: ReducerManager
createService(store, reducerManager, sagaMiddleware)
Creates a service creator function.
Parameters:
store: Redux store instancereducerManager: Reducer manager instancesagaMiddleware: Redux Saga middleware instance
Returns: ServiceCreator
serviceCreator(config)
Creates a service hook.
Config:
interface ServiceConfig {
id: string; // Unique service identifier (required)
reducersMap?: Record<string, Reducer>; // Reducers to inject
sagas?: Array<() => Generator>; // Saga generator functions
before?: (store: Store) => void; // Pre-initialization hook
after?: (store: Store) => void; // Post-initialization hook
keepAlive?: boolean; // Prevent cleanup (default: false)
logger?: (...args: any[]) => void; // Custom logger
}Returns: Hook function () => UseServiceReturn
withServices(serviceLoaders)
Higher-order component for service injection.
Parameters:
serviceLoaders: Array of service loader functions
Returns: HOC function
ServiceProvider
Component for declarative service injection.
Props:
interface ServiceProviderProps {
services: Array<() => UseServiceReturn>;
children: React.ReactNode;
}Utility Functions
getActiveServices(): string[] Returns array of active service IDs.
isServiceActive(id: string): boolean Check if a service is currently active.
cleanupServiceById(id, store, reducerManager, logger?) Manually cleanup a specific service.
getActiveServicesMap(): Map<string, ServiceData> Returns the internal services map (for debugging).
🔥 Advanced Features
Lifecycle Hooks
const useMyService = serviceCreator({
id: 'myService',
reducersMap: { myFeature: myReducer },
before: (store) => {
console.log('Service initializing...', store.getState());
// Initialize analytics, load cached data, etc.
},
after: (store) => {
console.log('Service ready!', store.getState());
// Trigger initial data fetch
store.dispatch({ type: 'myFeature/init' });
},
});Keep Alive Services
// Global services that should never cleanup
const useAuthService = serviceCreator({
id: 'auth',
reducersMap: { auth: authReducer },
sagas: [authSaga],
keepAlive: true, // Service persists across navigation
});Custom Logger
import { createLogger } from './logger';
const useMyService = serviceCreator({
id: 'myService',
reducersMap: { feature: featureReducer },
logger: createLogger('MyService'),
});Reference Counting
Services automatically track usage across multiple components:
function ComponentA() {
useMyService(); // ref count: 1
return <div>A</div>;
}
function ComponentB() {
useMyService(); // ref count: 2 (reuses existing service)
return <div>B</div>;
}
// When ComponentA unmounts: ref count: 1
// When ComponentB unmounts: ref count: 0 → cleanup triggered🧪 Testing
Services are fully testable:
import { renderHook, act } from '@testing-library/react';
import { Provider } from 'react-redux';
import { useCounterService } from './counterService';
test('counter service loads correctly', async () => {
const { result } = renderHook(() => useCounterService(), {
wrapper: ({ children }) => <Provider store={store}>{children}</Provider>,
});
await act(async () => {
// Wait for service to initialize
});
expect(result.current.isActive).toBe(true);
expect(store.getState()).toHaveProperty('counter');
});TypeScript Support
Full TypeScript support with proper type inference:
interface RootState {
counter?: { value: number; loading: boolean };
}
const useCounterService = serviceCreator({
id: 'counter',
reducersMap: { counter: counterReducer },
});
// Type-safe selector
const counter = useSelector((state: RootState) => state.counter);💡 Best Practices
- Unique Service IDs: Always use unique, descriptive service identifiers
- Lazy Loading: Combine with React.lazy() for code splitting
- Memory Management: Avoid
keepAliveunless the service is truly global - Error Boundaries: Wrap service-loaded components in error boundaries
- Type Safety: Define proper TypeScript interfaces for your state
- Testing: Test services in isolation before integration
- Documentation: Document which services each page/feature requires
🤔 When to Use
✅ Good Use Cases:
- Large applications with many features
- Code splitting and lazy loading
- Feature-based architecture
- Micro-frontends
- Dynamic module loading
- Route-based service loading
❌ Not Recommended:
- Small applications with simple state
- When all state is needed globally
- Static, unchanging state structure
📝 License
MIT © [Your Name]
🤝 Contributing
Contributions welcome! Please read our contributing guide.
🐛 Issues
Found a bug? Create an issue
