@oimdb/redux-adapter
Version:
Redux adapter for OIMDB - Create Redux reducers from OIMDB collections and indexes
613 lines (479 loc) • 19.6 kB
Markdown
# @oimdb/redux-adapter
Production-ready Redux adapter for OIMDB that enables seamless integration between OIMDB's reactive in-memory database and Redux state management. This package allows you to gradually migrate from Redux to OIMDB or use both systems side-by-side with automatic two-way synchronization.
## 🚀 Installation
```bash
npm install @oimdb/redux-adapter @oimdb/core redux
```
## ✨ Key Features
- **🔄 Two-Way Synchronization**: Automatic sync between OIMDB and Redux in both directions
- **📦 Production Ready**: Battle-tested, optimized for large datasets with efficient change detection
- **🔄 Gradual Migration**: Integrate OIMDB into existing Redux projects without breaking changes
- **🎯 Flexible State Mapping**: Custom mappers for any Redux state structure
- **⚡ Performance Optimized**: Efficient diffing algorithms and batched updates
- **🔌 Redux Compatible**: Works seamlessly with existing Redux middleware and tools
## 🎯 Use Cases
### 1. **Replace Redux Entirely**
Use OIMDB as your primary state management with Redux as a compatibility layer for existing code.
### 2. **Gradual Migration**
Migrate from Redux to OIMDB incrementally, one collection at a time, without disrupting your application.
### 3. **Hybrid Approach**
Use OIMDB for complex relational data and Redux for simple UI state, with automatic synchronization.
## 📦 What's Included
- **OIMDBReduxAdapter**: Main adapter class for creating Redux reducers and middleware from OIMDB collections
- **Automatic Middleware**: Built-in middleware for automatic event queue flushing after Redux actions
- **Default Mappers**: RTK Entity Adapter-style mappers for collections and indexes
- **Utility Functions**: `findUpdatedInRecord` and `findUpdatedInArray` for efficient change detection
- **Type-Safe**: Full TypeScript support with comprehensive type definitions
## 🔧 Basic Usage
### Simple One-Way Sync (OIMDB → Redux)
```typescript
import { OIMDBReduxAdapter } from '@oimdb/redux-adapter';
import { OIMReactiveCollection, OIMEventQueue } from '@oimdb/core';
import { createStore, combineReducers, applyMiddleware } from 'redux';
interface User {
id: string;
name: string;
email: string;
}
// Create OIMDB collection
const queue = new OIMEventQueue();
const users = new OIMReactiveCollection<User, string>(queue, {
selectPk: (user) => user.id
});
// Create Redux adapter
const adapter = new OIMDBReduxAdapter(queue);
// Create Redux reducer from OIMDB collection
const usersReducer = adapter.createCollectionReducer(users);
// Create middleware for automatic flushing
const middleware = adapter.createMiddleware();
// Create Redux store with middleware
const store = createStore(
combineReducers({
users: usersReducer,
}),
applyMiddleware(middleware)
);
// Set store in adapter (can be done later)
adapter.setStore(store);
// OIMDB changes automatically sync to Redux
users.upsertOne({ id: '1', name: 'John', email: 'john@example.com' });
queue.flush(); // Triggers Redux update
// Redux state is automatically updated
const state = store.getState();
console.log(state.users.entities['1']); // { id: '1', name: 'John', email: 'john@example.com' }
```
### Two-Way Sync (OIMDB ↔ Redux)
Enable bidirectional synchronization by providing a child reducer. The middleware automatically flushes the queue after each action, so manual `queue.flush()` is not needed:
```typescript
import {
OIMDBAdapter,
TOIMDBReduxDefaultCollectionState,
TOIMDBReduxCollectionReducerChildOptions
} from '@oimdb/redux-adapter';
import { Action } from 'redux';
import { createStore, applyMiddleware } from 'redux';
// Child reducer handles custom Redux actions
const childReducer = (
state: TOIMDBReduxDefaultCollectionState<User, string> | undefined,
action: Action
): TOIMDBReduxDefaultCollectionState<User, string> => {
if (state === undefined) {
return { entities: {}, ids: [] };
}
if (action.type === 'UPDATE_USER_NAME') {
const { id, name } = action.payload;
return {
...state,
entities: {
...state.entities,
[id]: { ...state.entities[id], name }
}
};
}
return state;
};
const childOptions: TOIMDBReduxCollectionReducerChildOptions<User, string, TOIMDBReduxDefaultCollectionState<User, string>> = {
reducer: childReducer,
getPk: (user) => user.id,
// extractEntities is optional - default implementation handles TOIMDBReduxDefaultCollectionState
// linkedIndexes is optional - automatically updates indexes when entity fields change
};
// Create reducer with child
const usersReducer = adapter.createCollectionReducer(users, childOptions);
// Create store with middleware
const store = createStore(
usersReducer,
applyMiddleware(adapter.createMiddleware())
);
adapter.setStore(store);
// Redux actions automatically sync back to OIMDB
// Middleware automatically flushes queue after dispatch
store.dispatch({
type: 'UPDATE_USER_NAME',
payload: { id: '1', name: 'John Updated' }
});
// No manual queue.flush() needed - middleware handles it!
// OIMDB collection is automatically updated
const user = users.getOneByPk('1');
console.log(user?.name); // 'John Updated'
```
### Linked Indexes
Automatically update indexes when entity array fields change in **both directions**:
- **Redux → OIMDB**: When Redux state changes via child reducer, linked indexes are updated
- **OIMDB → Redux**: When OIMDB collection changes directly, linked indexes are updated automatically
The entity's PK (obtained via `getPk`) becomes the index key, and the array field values become the index values. Index updates are triggered when the array field changes **by reference** (`===` comparison). No need to create separate index reducers:
```typescript
import {
OIMDBAdapter,
TOIMDBReduxDefaultCollectionState,
TOIMDBReduxCollectionReducerChildOptions
} from '@oimdb/redux-adapter';
import { OIMReactiveIndexManualArrayBased } from '@oimdb/core';
interface Deck {
id: string;
cardIds: string[]; // Array of card IDs
name: string;
}
const decksCollection = new OIMReactiveCollection<Deck, string>(queue, {
selectPk: (deck) => deck.id
});
// Use ArrayBased index for full array replacements (recommended for redux-adapter)
// The adapter optimizes ArrayBased indexes by skipping diff computation
const cardsByDeckIndex = new OIMReactiveIndexManualArrayBased<string, string>(queue);
const childReducer = (
state: TOIMDBReduxDefaultCollectionState<Deck, string> | undefined,
action: Action
): TOIMDBReduxDefaultCollectionState<Deck, string> => {
if (state === undefined) {
return { entities: {}, ids: [] };
}
if (action.type === 'UPDATE_DECK_CARDS') {
const { deckId, cardIds } = action.payload;
const deck = state.entities[deckId];
if (deck) {
return {
...state,
entities: {
...state.entities,
[deckId]: { ...deck, cardIds } // Update cardIds array
}
};
}
}
return state;
};
const childOptions: TOIMDBReduxCollectionReducerChildOptions<
Deck,
string,
TOIMDBReduxDefaultCollectionState<Deck, string>
> = {
reducer: childReducer,
getPk: (deck) => deck.id,
linkedIndexes: [
{
index: cardsByDeckIndex,
fieldName: 'cardIds', // Array field containing PKs
},
],
};
const decksReducer = adapter.createCollectionReducer(
decksCollection,
childOptions
);
// When deck.cardIds changes (by reference), the index is automatically updated:
// - index[deck.id] = deck.cardIds
// - Old values removed, new values added automatically
// - Works in both directions: Redux → OIMDB and OIMDB → Redux
// No need to create a separate index reducer!
// Example: Update via Redux
store.dispatch({
type: 'UPDATE_DECK_CARDS',
payload: { deckId: 'deck1', cardIds: ['card1', 'card2', 'card3'] }
});
// Index automatically updated: cardsByDeckIndex['deck1'] = ['card1', 'card2', 'card3']
// Example: Update via OIMDB
decksCollection.upsertOne({
id: 'deck1',
cardIds: ['card4', 'card5'], // New array reference
name: 'Deck 1'
});
queue.flush(); // Triggers OIMDB_UPDATE action
// Index automatically updated: cardsByDeckIndex['deck1'] = ['card4', 'card5']
```
### Custom State Structure
Use custom mappers for any Redux state structure:
```typescript
// Array-based state
type ArrayBasedState = {
users: User[];
};
const arrayMapper = (collection: OIMReactiveCollection<User, string>) => {
return {
users: collection.getAll()
};
};
const arrayReducer = adapter.createCollectionReducer(users, undefined, arrayMapper);
// Custom extractor for array-based state
const childOptions: TOIMDBReduxCollectionReducerChildOptions<User, string, ArrayBasedState> = {
reducer: (state, action) => {
// Your custom reducer logic
return state;
},
extractEntities: (prevState, nextState, collection, getPk) => {
const prevIds = (prevState?.users ?? []).map(u => getPk(u));
const nextIds = nextState.users.map(u => getPk(u));
// Use utility function for efficient diffing
const { findUpdatedInArray } = require('@oimdb/redux-adapter');
const diff = findUpdatedInArray(prevIds, nextIds);
// Sync changes to OIMDB
if (diff.added.length > 0 || diff.updated.length > 0) {
const toUpsert = nextState.users.filter(u =>
diff.added.includes(getPk(u)) || diff.updated.includes(getPk(u))
);
collection.upsertMany(toUpsert);
}
if (diff.removed.length > 0) {
collection.removeManyByPks(diff.removed);
}
},
getPk: (user) => user.id
};
```
## 🔄 Migration Strategy
### Phase 1: Add OIMDB Alongside Redux
Start by adding OIMDB for new features while keeping existing Redux code unchanged:
```typescript
const adapter = new OIMDBReduxAdapter(queue);
const middleware = adapter.createMiddleware();
const store = createStore(
combineReducers({
// Existing Redux reducers
ui: uiReducer,
auth: authReducer,
// New OIMDB-backed reducers
users: adapter.createCollectionReducer(usersCollection),
posts: adapter.createCollectionReducer(postsCollection),
}),
applyMiddleware(middleware)
);
adapter.setStore(store);
```
### Phase 2: Migrate Existing Redux Reducers
Gradually replace Redux reducers with OIMDB collections, using child reducers to maintain compatibility:
```typescript
// Old Redux reducer
const oldUsersReducer = (state, action) => {
// ... existing logic
};
// New OIMDB-backed reducer with compatibility layer
const adapter = new OIMDBReduxAdapter(queue);
const newUsersReducer = adapter.createCollectionReducer(
usersCollection,
{
reducer: oldUsersReducer, // Reuse existing reducer logic
getPk: (user) => user.id
}
);
const store = createStore(
newUsersReducer,
applyMiddleware(adapter.createMiddleware())
);
adapter.setStore(store);
```
### Phase 3: Full OIMDB Migration
Once all collections are migrated, you can remove Redux entirely and use OIMDB directly with React hooks or other reactive patterns.
## 🛠️ Advanced Usage
### Custom Mappers
```typescript
const customMapper: TOIMDBReduxCollectionMapper<User, string, CustomState> = (
collection,
updatedKeys,
currentState
) => {
// Your custom mapping logic
// Only process entities in updatedKeys for performance
const entities: Record<string, User> = {};
const ids: string[] = [];
if (currentState) {
// Reuse existing state
Object.assign(entities, currentState.entities);
ids.push(...currentState.ids);
}
// Update only changed entities
for (const id of updatedKeys) {
const entity = collection.getOneByPk(id);
if (entity) {
entities[id] = entity;
if (!ids.includes(id)) {
ids.push(id);
}
} else {
delete entities[id];
const index = ids.indexOf(id);
if (index > -1) {
ids.splice(index, 1);
}
}
}
return { entities, ids };
};
```
### Index Reducers
#### Simple One-Way Sync (OIMDB → Redux)
```typescript
import { OIMReactiveIndexManualArrayBased } from '@oimdb/core';
// Create index (ArrayBased recommended for redux-adapter - optimized performance)
const userRolesIndex = new OIMReactiveIndexManualArrayBased<string, string>(queue);
// Create reducer for index
const adapter = new OIMDBReduxAdapter(queue);
const rolesReducer = adapter.createIndexReducer(userRolesIndex);
// Use in Redux store with middleware
const store = createStore(
combineReducers({
users: usersReducer,
userRoles: rolesReducer,
}),
applyMiddleware(adapter.createMiddleware())
);
adapter.setStore(store);
```
#### Two-Way Sync (OIMDB ↔ Redux) for Indexes
Enable bidirectional synchronization for indexes by providing a child reducer:
```typescript
import {
OIMDBAdapter,
TOIMDBReduxDefaultIndexState,
TOIMDBReduxIndexReducerChildOptions
} from '@oimdb/redux-adapter';
import { Action } from 'redux';
import { createStore, applyMiddleware } from 'redux';
// Child reducer handles custom Redux actions
const childReducer = (
state: TOIMDBReduxDefaultIndexState<string, string> | undefined,
action: Action
): TOIMDBReduxDefaultIndexState<string, string> => {
if (state === undefined) {
return { entities: {} };
}
if (action.type === 'UPDATE_INDEX_KEY') {
const { key, ids } = action.payload;
return {
...state,
entities: {
...state.entities,
[key]: { id: key, ids }
}
};
}
return state;
};
const childOptions: TOIMDBReduxIndexReducerChildOptions<
string,
string,
TOIMDBReduxDefaultIndexState<string, string>
> = {
reducer: childReducer,
// extractIndexState is optional - default implementation handles TOIMDBReduxDefaultIndexState
};
// Create reducer with child
const indexReducer = adapter.createIndexReducer(userRolesIndex, childOptions);
// Create store with middleware
const store = createStore(
indexReducer,
applyMiddleware(adapter.createMiddleware())
);
adapter.setStore(store);
// Redux actions automatically sync back to OIMDB
// Middleware automatically flushes queue after dispatch
store.dispatch({
type: 'UPDATE_INDEX_KEY',
payload: { key: 'role1', ids: ['user1', 'user2', 'user3'] }
});
// No manual queue.flush() needed - middleware handles it!
// OIMDB index is automatically updated
const pks = userRolesIndex.index.getPksByKey('role1'); // Returns TPk[] for ArrayBased
console.log(pks); // ['user1', 'user2', 'user3']
```
## 📊 Performance
The adapter is optimized for large datasets:
- **Efficient Diffing**: Uses optimized algorithms to detect changes
- **Batched Updates**: Changes are coalesced and applied in batches
- **Selective Updates**: Only changed entities are processed
- **Memory Efficient**: Reuses state objects when possible
### Index Performance Optimization
The adapter intelligently handles different index types for optimal performance:
- **ArrayBased Indexes** (using `setPks`): When linked indexes use ArrayBased indexes, the adapter directly sets the new array without computing diffs. This eliminates unnecessary array comparisons and `getPksByKey` calls, providing significant performance improvements, especially with many linked indexes.
- **SetBased Indexes** (using `addPks`/`removePks`): For SetBased indexes, the adapter computes diffs to apply incremental updates, which is more efficient than full replacements for Set-based data structures.
**Recommendation**: For redux-adapter, prefer **ArrayBased indexes** for linked indexes, as they provide the best performance when replacing entire arrays (which is the common pattern when syncing from Redux state).
**Example:**
```typescript
// ArrayBased index - direct assignment (fast, recommended for redux-adapter)
const cardsByDeckIndex = new OIMReactiveIndexManualArrayBased<string, string>(queue);
// When deck.cardIds changes, adapter simply does:
// index.setPks(deckId, newCardIds) - no diff computation needed!
// SetBased index - incremental updates (efficient for Sets)
const tagsByUserIndex = new OIMReactiveIndexManualSetBased<string, string>(queue);
// When user.tags changes, adapter computes diff and uses:
// index.addPks(userId, toAdd) and index.removePks(userId, toRemove)
```
## 🔍 Utility Functions
### `findUpdatedInRecord`
Efficiently find differences between two entity records (dictionaries):
```typescript
import { findUpdatedInRecord } from '@oimdb/redux-adapter';
const oldEntities = { '1': user1, '2': user2 };
const newEntities = { '1': user1Updated, '3': user3 };
const diff = findUpdatedInRecord(oldEntities, newEntities);
// diff.added = Set(['3'])
// diff.updated = Set(['1'])
// diff.removed = Set(['2'])
// diff.all = Set(['1', '2', '3'])
```
### `findUpdatedInArray`
Efficiently find differences between two arrays of primary keys:
```typescript
import { findUpdatedInArray } from '@oimdb/redux-adapter';
const oldIds = ['1', '2', '3'];
const newIds = ['1', '3', '4'];
const diff = findUpdatedInArray(oldIds, newIds);
// diff.added = ['4']
// diff.updated = ['1', '3']
// diff.removed = ['2']
// diff.all = ['1', '2', '3', '4']
```
## 🎨 TypeScript Support
Full type safety with comprehensive TypeScript definitions:
```typescript
import type {
TOIMDBReduxCollectionMapper,
TOIMDBReduxIndexMapper,
TOIMDBReduxDefaultCollectionState,
TOIMDBReduxDefaultIndexState,
TOIMDBReduxCollectionReducerChildOptions,
TOIMDBReduxLinkedIndex,
TOIMDBReduxIndexReducerChildOptions,
TOIMDBReduxUpdatedEntitiesResult,
TOIMDBReduxUpdatedArrayResult,
} from '@oimdb/redux-adapter';
```
## 📚 API Reference
### `OIMDBReduxAdapter`
Main adapter class for integrating OIMDB with Redux. Creates Redux reducers from OIMDB collections and provides middleware for automatic event queue flushing.
#### Methods
- `createCollectionReducer<TEntity, TPk, TState>(collection, child?, mapper?)`: Create reducer for a collection
- `createIndexReducer<TIndexKey, TPk, TState>(index, child?, mapper?)`: Create reducer for an index (supports both SetBased and ArrayBased indexes)
- `createMiddleware()`: Create Redux middleware that automatically flushes the event queue after each action
- `setStore(store)`: Set Redux store (can be called later)
- `flushSilently()`: Flush the event queue without triggering OIMDB_UPDATE dispatch (used internally by middleware)
#### Constructor Options
- `defaultCollectionMapper`: Default mapper for all collections
- `defaultIndexMapper`: Default mapper for all indexes
#### Automatic Flushing
The middleware created by `createMiddleware()` automatically calls `flushSilently()` after every Redux action. This ensures that:
- Events triggered by child reducers are processed synchronously
- No manual `queue.flush()` is needed when updating OIMDB from Redux
- OIMDB_UPDATE dispatch is not triggered unnecessarily (preventing loops)
## 🤝 Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## 📄 License
MIT