Redux communications
This package provides a toolset to simplify working with redux and saga libs. It allows to:
- reduce a large amount of code by specifying only the values should to be changed instead of using templated code
- unify and standardize the store structure
- simplify store management
- simplify async action management
Installation
npm i @axmit/redux-communications
or yarn add @axmit/redux-communications
Online DEMO
TypeScript
https://codesandbox.io/s/misty-fast-pzjno
JavaScript
https://codesandbox.io/s/wispy-silence-qpn0r
Quick start
To create a new communication you should do the following:
JavaScript
import { taskTransport } from './task.transport';
import { CRUDStrategy, buildCommunication, StoreBranch } from '@axmit/redux-communications';
const namespace = 'task';
const strategy = new CRUDStrategy({
namespace,
transport: taskTransport
});
export const taskCommunication = buildCommunication(strategy);
TypeScript
import { taskTransport } from './task.transport';
import { CRUDStrategy, buildCommunication, StoreBranch } from '@axmit/redux-communications';
export interface INewTask {
title: string;
}
export interface ITaskModel {
id: number;
title: string;
}
export interface ITaskConnectedProps {
taskModel: StoreBranch<ITaskModel>;
taskCollection: StoreBranch<ITaskModel[]>;
addTaskModel(params: INewTask): void;
getTaskModel(id: number): void;
updateTaskModel(params: { id: number; data: INewTask }): void;
deleteTaskModel(params: number): void;
getTaskCollection(): void;
}
const namespace = 'task';
const strategy = new CRUDStrategy({
namespace,
transport: taskTransport
});
export const taskCommunication = buildCommunication<ITaskConnectedProps>(strategy);
After that you will have a communication that contains the base set of reducers, actions. You can easily setup it as usual.
//Sagas setup
export default function* rootSaga(): any {
yield all([
...communicationLine.sagas
]);
}
//Reducers setup
const reducers = {
...communicationLine.reducers
};
Communication also contains injector
for adding generated dispatchers and store branches to components
import React from 'react';
import { ILinesConnectedProps } from './linesCommunication';
class TestComponent extends React.Component<ILinesConnectedProps> {
//Component logic goes here
}
communicationLine.injector(TestComponent);
Troubleshooting
- Make sure the following dependencies are synced between lib and your project:
- "react"
- "react-redux"
- "redux"
- "redux-saga"
Terms
The main entity of this package is StoreBranch. StoreBranch is required to store a part of application state.
Namespace - group of StoreBranches.
ApiProvider - tool to link some external data source to a StoreBranch.
Strategy - defines behavior for creating communication.
Communication - it's a generated combination sagas, reducers and actions that can be injected in a component.
Communication factory - function to build communication based on selected strategy
Detailed explanation
The idea of this package is to provide a simple way (toolset) of generating event publishers and subscribers.
!!! IMPORTANT !!!
If you want to understand this module completely, you should be familiar with publisher-subscriber pattern (https://en.wikipedia.org/wiki/Publish%E2%80%93subscribe_pattern). > Because in fact this pattern underlies the Redux/Saga communication.
In fact we have:
- event publisher
- actions - common example for generating events
- event subscriber reducer
- reducer - common example for handling events and updating the store
- saga - handlers that can be used for side effects and generating new event
Actually each async action consists of 3 actions:
- Start action - triggered to start execution of async call
- Success action - called if action finished with success
- Fail action - called if action finished with failure
We can use reducers to update the store depending on action type. Sagas are used to generate some actions depending on results of other actions.
For example, we can handle USER_MODEL_GET_SUCCESS action and after that generate action TASK_COLLECTION_GET_START to start getting collection of tasks for current user.
Store structure:
StoreBranch
The store consists of namespaces that are divided into branches. Each branch contains 4 fields:
- Data - data for this branch
- Loading - status of data loading of this branch
- Errors - errors for branch if there are any
- Params - params that are provided in the start action
For more details see implementation in ./src/buildReducer.ts
Example:
Namespace - user
Branches - model, collection
store = {
user: {
model: {
data: {
userInfo: {
id: 1,
firstName: 'Ivan',
lastName: 'Tershukov'
},
userMeta: {
lastLoginAt: '01.01.19 12:00'
}
},
params: 1, // id of current model
loading: false,
errors: null
},
collection: {
data: {
data: [
{
id: 1,
firstName: 'Ivan',
lastName: 'Tershukov'
},
{
id: 2,
firstName: 'Bender',
lastName: 'Rodríguez'
},
{
id: 3,
firstName: 'Philip',
lastName: 'Fry'
}
],
meta: {
limit: 100,
offset: 0,
q: ''
}
},
params: {
order: 'asc',
limit: 10,
offset: 50
},
loading: false,
errors: null
}
}
};
Inner structure
Strategies:
The strategy determines which part of communication logic will be generated.
All strategy receives config as constructor param. Every strategy has unique params set but there is common config params for all of them:
- namespace: string (required) - name of store namespace to be generated
- actions: Array (optional) - array of custom actions
- reducers: Array (optional) - array of custom reducers
- sagas: Array (optional) - array of custom sagas
BaseStrategy
This strategy requires direct approach in communication generation. You must specify at least namespace
and branches
to get communication.
Available BaseStrategy
methods:
constructor(config)
Inits strategy with passed configuration. Available fields
.buildReducers()
Method to build reducers based on strategy config
.buildInjector()
Method to build injector based on strategy config
.buildSagas()
Method to build sagas based on strategy config
CRUDStrategy
This strategy predefines 2 namespace branches in Redux
:
- Model
- Collection
It extends BaseStrategy
so methods are similar
constructor(config)
Inits strategy with passed configuration. Available fields
- modelApiProviders: APIProvider - array of APIProviders connected to model store branch
- collectionApiProviders: APIProvider - array of APIProviders connected to collection store branch
- modelInitialState: StoreBranch (optional) - initial state for model branch
- collectionInitialState: StoreBranch (optional) - initial state for collection branch
- transport: ICRUDTransport (optional) - transport that can be used to automatically generate API providers
- branches: Branch - array of Branches that can be used to extend base behaviour of CRUD strategy
SimpleStrategy
This strategy can be used for keeping UI data.
This strategy predefines only one namespace branch model
in Redux
:
It extends BaseStrategy
so methods are similar.
constructor(config)
Inits strategy with passed configuration. Available fields
- apiProvider: APIProvider - array of APIProviders connected to model store branch
- initialState: StoreBranch (optional) - initial state for model branch
- branches: Branch - array of Branches that can be user to extend base behaviour ot CRUD strategy
Class Helpers
There is an amount of classes to help you build your communication
APIProvider
This class contains provider for your API logic (request and so on)
NOTE! For each API provider you defined a saga will be generated
!!! IMPORTANT !!!
By default before each async call of APIProvider the store will be cleared. To prevent this behavior use:
preRequestDataMapper
constructor(type, handler, hooks)
-
type: string - string literal indicating type of performing action (can be any string you like)
-
handler: Function - function containing provider logic (ex. API call)
-
hooks (optional) - object containing different hooks for APIProvider each hook is function with following signature (response, payload, branchState, fullState) => any:
- response - handler call result
- payload - action payload
- branchState - current branch state
- fullState- full application state
Available hooks:
- onStart - occur before API call
- onSuccesss - occur after success action is dispatched
- onFail - occur after fail action is dispatched
- mapSuccess - occur before success handler call
- mapFail - occur before fail handler call
- clearParams (
boolean
flag) - if true clear StoreBranch params after success - mapParams - allow you to map params to be passed into API call
- preRequestDataMapper - allow you to map data that can be passed into store before API call
- throwOnFail - if true, error in transport will be thrown
- hydrateTo - allow hydrate response to an object
Example
new APIProvider('get', () => axios.get('/test'), {
mapSuccess: (response, payload, branchState, fullState) => response.map(item => response.test)
});
This APIProvider
will generate a saga which sends GET /test and apply mapSuccess
to response
Branch
This class is responsible for namespace branches in store
Available Branch
methods
constructor(name, apiProviders, initialState)
- name: string - branch name
- apiProviders: APIProvider[] | APIProvider - array of APIProviders connected to branch
- initialState: StoreBranch - initial state of branch
.buildBranchReducers(namespace: string)
Builds reducers for store branch
.buildBranchDispatchers(namespace: string)
Builds dispatchers for store branch
.buildBranchSagas(namespace: string)
Builds sagas for store branch
StoreBranch
Helper class for defining store branches, every store branch is instance of StoreBranch
Contains the following fields:
- data - main branch data
- params - params for dispatchers
- errors - branch errors
- loading - progress of current async operation
constructor(data, params, errors, loading)
Communication factory
This factory contains functions for communications build:
buildCommunication
Build communication based on strategy provided. Return object containing:
- namespace: string - namespace
- branches: Branches[] - store branches
- reducers - builded reducers (must be includes into application reducers)
- sagas - builded sagas (must be included into application sagas)
- injector - react HOC for injecting props into components (connect())
NOTE! To make injector
work correctly you must define interface describing injected props and pass it into buildCommunication
Example
import { APIProvider, Branch, BaseStrategy, buildCommunication, StoreBranch } from '@axmit/redux-communications';
export interface ITestConnectedProps {
testNames: StoreBranch<string[], string>;
getTestNames(searchStr?: string): void;
clearTestNames(): void;
}
const apiProvider = new APIProvider('get', () => axios.get('/test/names'));
const branches = [new Branch('names', apiProvider, new StoreBranch([]))];
const strategy = new BaseStrategy({
namespace: 'test',
branches
});
const testCommunication = buildCommunication<ITestConnectedProps>(strategy);
Async dispatching
If you need to check that action has finished in a component you can check that loading state was changed OR use async dispatcher.
Example
import { APIProvider, Branch, BaseStrategy, buildCommunication, StoreBranch } from '@axmit/redux-communications';
export interface ITestConnectedProps {
testNames: StoreBranch<string[], string>;
getTestNames(searchStr?: string): Promise<string[]>;
clearTestNames(): void;
}
const apiProvider = new APIProvider('get', () => axios.get('/test/names'));
const branches = [new Branch('names', apiProvider, new StoreBranch([]))];
const strategy = new BaseStrategy({
namespace: 'test',
branches
});
const testCommunication = buildCommunication<ITestConnectedProps>(strategy);
and then in a component:
...
async componentDidMount(){
try{
const result = await this.props.getTestNames();
} catch(e){
// Do something
}
}
...
In this example ITestConnectedProps
passed as generic param into buildCommunication
for right types in injector;
!!!IMPORTANT Rules of IConnectedProps interfaces
Connected props interface contains 2 parts:
-
state branches - generates amount you pass into strategy based on
Branch
, for each branch injector will add property by following format[namespace][BranchName]
.-
Example if namespace is
lines
and there is 2 branches:[new Branch('model'), new Branch('collection')]
then there will be 2 properties injected by generated injector:linesModel
andlinesCollection
-
Example if namespace is
-
dispatchers - generates based on
APIProviders
amount plusclear
dispatcher for each branch is generated automatically. For eachAPIProvider
will be generated dispatcher by following format[APIProviderType][Namespace][BranchName]
.-
Example if namespace is
lines
, branch isnew Branch('names', apiProviders)
andapiProviders
are
typescript const apiProviders = [ new APIProvider('get', handler), new APIProvider('update', handler2) ]
Dispatchers to be generated will be
getLinesNames clearLinesNames updateLinesNames
-
Example if namespace is
Toolset
This package also contains a toolset that simplifies your life.
For example you can easy generate types for actions:
getStartType('users', 'model', 'get') => USERS_MODEL_GET_TRY
getSuccessType('users', 'model', 'get') => USERS_MODEL_GET_SUCCESS
getFailType('users', 'model', 'get') => USERS_MODEL_GET_FAIL
getUpdateParamsType('users', 'model') => USERS_MODEL_UPDATE_PARAMS
Also you can easily generate names for API provider methods
getAPIMethodName('users', 'model', 'GET') => getUsersModel
More examples see in: ./src/helpers.spec.ts
Types of actions
actionsTypes - it's enum with base set of default actions.
At at the moment it supports init, clear, add, get, update and delete types.
Sagas combinations
If you want to add sagas you can just add them directly in communication sagas array or in the root saga.
function* getEmptyCollection(action: any) {
// Here you can use put to generate action or call to call any async funtions ...}
function* getEmptyCollectionSaga() {
const successActionType = getSuccessType(namespace, 'collection', actionsTypes.get);
yield takeEvery(successActionType, getEmptyCollection);
}
and then
communicationLine.sagas.push(getEmptyCollectionSaga());
or
export default function* rootSaga(): any {
yield all([ getEmptyCollectionSaga(), ... ]);
}
Full example
import { APIProvider, Branch, BaseStrategy, buildCommunication, StoreBranch, actionsTypes } from '@axmit/redux-communications';
const namespace = 'line';
export interface ILineModel {
id: number;
name: string;
}
export interface ILineCollectionParams {
limit: number;
offset: number;
}
export interface ILinesConnectedProps {
lineCollection: StoreBranch<ILineModel[]>;
lineModel: StoreBranch<ILineModel>;
getLineCollection(params: ILineCollectionParams): void;
clearLineCollection(): void;
getLineModel(id: number): void;
updateLineModel(id: number, data: ILineModel): void;
clearLineModel(): void;
}
const collectionApiProvider = new APIProvider(
actionsTypes.get,
(params: ILineCollectionParams): Promise<ILineModel[]> => axios.get(`/lines`)
);
const modelApiProvider = [
new APIProvider(actionsTypes.get, (id: number): Promise<ILineModel> => axios.get(`/lines/${id}`), {
mapSuccess: (response, payload, branchState, fullState) => response.map(model => model),
onSuccess: (response, payload, branchState, fullState) => console.log('post success'),
onFail: (response, payload, branchState, fullState) => console.log('post fail')
}),
new APIProvider(actionsTypes.update, (id: number, data: ILineModel): Promise<ILineModel> => axios.put(`/lines/${id}`, data))
];
const branches = [new Branch('collection', collectionApiProvider, new StoreBranch([])), new Branch('model', modelApiProvider)];
const strategy = new BaseStrategy({
namespace,
branches
});
export const communicationLine = buildCommunication<ILinesConnectedProps>(strategy);
Builders full example
import { put } from 'redux-saga/effects';
import { StoreBranch } from '../models/StoreBranch';
import { getStartType } from '../helpers';
import { actionsTypes } from '../enums';
import { CommunicationBuilder } from './CommunicationBuilder';
import { APIProviderBuilder } from './APIProviderBuilder';
import { APIProviderGroup } from './APIProviderGroup';
import { APIProvider } from '../models/APIProvider';
export interface IAccountConnectedProps {
accountExternalCollection: StoreBranch<AccountWidgetCollectionModel>;
accountItem: StoreBranch<AccountModel>;
getAccountExternalCollection(collection: AccountWidgetCollectionModel): void;
clearAccountExternalCollection(): void;
getAccountItem(id: number, collections?: string[]): void;
updateAccountItem(data: IAccountUpdateInput): void;
clearAccountItem(): void;
accountCollection: StoreBranch<AccountListCollection>;
getAccountCollection(collection: AccountListCollection): void;
initAccountCollection(): void;
accountDashboard: StoreBranch<DashboardAccount[]>;
getAccountDashboard(entityId: number): void;
accountExternalItem: StoreBranch<PendingExternalAccount>;
getAccountExternalItem(id: number): void;
approveAccountExternalItem(id: number): void;
declineAccountExternalItem(id: number): void;
clearAccountExternalItem(): void;
approveAccountFiles(paths: string[]): void;
}
function* redirect() {
yield put(push('/#pending-external-accounts'));
}
function* updateExternalItem(response: any, payload: any, branchState: any, fullState: any) {
yield put({ type: getStartType('account', 'externalItem', actionsTypes.get), payload: fullState.account.externalItem.params });
}
const externalItemAPIProviders = APIProviderGroup.create()
.afterSuccess(redirect)
.add(builder =>
builder
.setType('approve')
.setHandler(accountTransport.approveExternalAccount)
.hydrateTo(Test)
.build()
)
.add(builder =>
builder
.setType('decline')
.setHandler(accountTransport.declineExternalAccount)
.build()
)
.build();
const collectionAPIProviders = APIProviderGroup.create()
.add(builder =>
builder
.setType(actionsTypes.get)
.setHandler(accountTransport.requestList)
.clearBranchParams()
.build()
)
.add(builder =>
builder
.setType('actionsTypes.init')
.setHandler(async () => new AccountListCollection())
.build()
)
.build();
const itemAPIProviders = APIProviderGroup.create()
.add(builder =>
builder
.setType(actionsTypes.get)
.setHandler(accountTransport.requestItem)
.build()
)
.add(builder =>
builder
.setType(actionsTypes.update)
.setHandler(accountTransport.updateItem)
.build()
)
.build();
const { reducers: accountReducers, sagas: accountSagas, injector: accountInjector } = new CommunicationBuilder()
.setNamespace('account')
.addBranch(
'externalCollection',
APIProviderBuilder.create()
.setType(actionsTypes.get)
.setHandler(accountTransport.requestExternalAccountsCollection)
.clearBranchParams()
.build(),
new AccountWidgetCollectionModel(new AccountWidgetFilterModel(true))
)
.addBranch('externalItem', [
...externalItemAPIProviders,
new APIProvider(actionsTypes.get, accountTransport.requestPendingExternalItem)
])
.addBranch(
'files',
APIProviderBuilder.create()
.setType('approve')
.setHandler(accountTransport.approveFiles)
.afterSuccess(updateExternalItem)
.build()
)
.addBranch('item', itemAPIProviders)
.addBranch('collection', collectionAPIProviders, new AccountListCollection())
.addBranch('dashboard', new APIProvider(actionsTypes.get, accountTransport.getDashboardAccounts), [])
.addSaga(accountChange())
.addSaga(accountApplicationChange())
.build<IAccountConnectedProps>();
export { accountReducers, accountSagas, accountInjector };