/redux-executor

Redux enhancer for handling side effects.

Primary LanguageTypeScriptMIT LicenseMIT

Redux Executor

Npm version Build Status Coverage Status

Redux enhancer for handling side effects.

Warning: API is not stable yet, will be from version 1.0

Table of Contents

  1. Installation
  2. Motivation
  3. Concepts
  4. Composition
  5. Narrowing
  6. Execution order
  7. API
  8. Code Splitting
  9. Typings
  10. License

Installation

Redux Executor requires Redux 3.1.0 or later.

npm install --save redux-executor

This assumes that you’re using npm package manager with a module bundler like Webpack or Browserify to consume UMD modules.

To enable Redux Executor, use createExecutorEnhancer with createStore:

import { createStore } from 'redux';
import { createExecutorEnhancer } from 'redux-executor';
import rootReducer from './reducers/index';
import rootExecutor from './executors/index';

const store = createStore(
  rootReducer,
  createExecutorEnhancer(rootExecutor)
);

Redux DevTools

To use Redux Executor with Redux DevTools, you have to be careful about enhancers order. It's because Redux Executor do not pass commands to next enhancers so it has to be placed after DevTools (to see commands).

const devToolsCompose = window && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ?
  window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ : compose;

const enhancer = compose(
  devToolsCompose(
    // ...some enhancers
    applyMiddleware(/* ...some middlewares */)
  ),
  createExecutorEnhancer(rootExecutor)
);

Motivation

There are many clever solutions to deal with side-effects in redux application like redux-thunk or redux-saga. The goal of this library is to be simpler than redux-saga, easier to test and more pure than redux-thunk.

Typical usage of executor is to fetch some external resource, for example list of posts. It can look like this:

import { handleCommand } from 'redux-executor';
import { postApi } from './api/postApi';
// import action creators
import { postsRequested, postsResolved, postsRejected } from './actions/postActions'; 

// postExecutors.js
function fetchPostsExecutor(command, dispatch, getState) {
  dispatch(postsRequested());
  
  return postApi.list(command.payload.page)
  .then((posts) => dispatch(postsResolved(posts)))
  .catch((error) => dispatch(postsRejected(error)));
}

export default handleCommand('FETCH_POSTS()', fetchPostsExecutor);

// somwhere else in code
dispatch(fetchPosts(this.state.page));

So what is the difference between executor and thunk? With executors you have separation between side-effect request and side-effect call. It means that you can omit second phase and not call side-effect (if you not bind executor to the store). With this design it's very easy to write unit tests. If you use Redux DevTools it will be also easier to debug - all commands will be logged in debugger.

I recommend to use redux-executor with redux-detector library. In this combination you can for example detect if client is on given url and dispatch fetch command. All, excluding executors, will be pure.

Concepts

An Executor

It may sounds a little bit scary but there is nothing to fear - executor is very pure and simple function.

type Executor<S> = (command: Action, dispatch: ExecutableDispatch<S>, getState: GetState<S | undefined>) => Promise<void> | void;

Like you see above, executor takes an action (called command in executors), enhanced dispatch function and state. It can return a Promise to provide custom execution flow.

A Command

Command is an action with specific type format - COMMAND_TYPE() (like function call, instead of COMMAND_TYPE). The idea behind is that it's more clean to split actions to two types: normal actions (we will call it events) that tells what has happened and commands that tells what should happen.

Events are declarative like: { type: 'USER_CLICKED_FOO' }, commands are imperative like: { type: 'FETCH_FOO()' }.

The reaction for event is state reduction (by reducer) which is pure, the reaction for command is executor call which is unpure.

Another thing is that events changes state (by reducer), commands not. Because of that command dispatch doesn't call store listeners (for example it doesn't re-render React application).

Composition

You can pass only one executor to the store, but with combineExecutors and reduceExecutors you can mix them to one executor. For example:

import { combineExecutors, reduceExecutors } from 'redux-executor';
import { fooExecutor } from './fooExecutor';
import { barExecutor } from './barExecutor';
import { anotherExecutor } from './anotherExecutor';

// our state has shape:
// {
//   foo: [],
//   bar: 1
// }
//
// We want to bind `fooExecutor` and `anotherExecutor` to `state.foo` branch (they should run in sequence)
// and also `barExecutor` to `state.bar` branch.

export default combineExecutors({
  foo: reduceExecutors(
    fooExecutor,
    anotherExecutor
  ),
  bar: barExecutor
});

Mounting

To re-use executors we can also mount them to some state branch. To do this, use mountExecutor function with state selector and executor.

import { mountExecutor } from 'redux-executor';

// our state has shape:
// {
//   foo: [1, 3],
// }
//
// We want to bind `fooExecutor` to the length of `state.foo` branch

function fooExecutor(command, dispatch, getState) {
  console.log(getState()); // > 2
}

export default mountExecutor((state) => state.foo.length, fooExecutor);

Narrowing

By default executor runs for every command. To limit executor to given command type, you can write if statement or use handleCommand/handleCommands function.

For example to limit fooExecutor to command FOO(), barExecutor to command BAR() and mix them into one executor:

import { handleCommand, handleCommands, reduceExecutors } from 'redux-executor';

function fooExecutor(command, dispatch, getState) { 
  // foo executor logic
}

function barExecutor(command, dispatch, getState) {
  // bar executor logic  
}

// OPTION 1: handleExecutor + reduceExecutors
export default reduceExecutors(
  handleCommand('FOO()', fooExecutor),
  handleCommand('BAR()', barExecutor)
);

// OPTION 2: handleCommands
export default handleCommands({
  'FOO()': fooExecutor,
  'BAR()': barExecutor
});

Execution order

Sometimes we want to dispatch actions in proper order. To do this, we have to return promise from executors we want to include to our execution order. If we dispatch command, dispatch method will return action (it's redux behaviour) with additional promise field that contains promise of our side-effects. Keep in mind that this promise is the result of calling all combined or reduced executors (built-in implementations uses Promise.all, see reduceExecutors, combineExecutors). Because of that you should not rely on promise content - in fact it should be undefined.

Lets say that we want to run firstCommand and then secondCommand and thirdCommand in parallel. The easiest solution is:

// import action creators
import { firstCommand, secondCommand, thirdCommand } from './commands/exampleCommands';

function firstThenNextExecutor(command, dispatch, getState) {
  return dispatch(firstCommand()).promise
  .then(() => Promise.all([
    dispatch(secondCommand()).promise, 
    dispatch(thirdCommand()).promise
  ]));
}

export default handleCommand('FIRST_THEN_NEXT()', firstThenNextExecutor);

To be more declarative and to reduce boilerplate code, you can create generic sequenceCommandExecutor and parallelCommandExecutor.

// executionFlowExecutors.js
import { handleCommand } from 'redux-executor';

export const sequenceCommandExecutor = handleCommand(
  'SEQUENCE()',
  (command, dispatch) => command.payload.reduce(
    (promise, command) => promise.then(() => dispatch(command).promise || Promise.resolve()),
    Promise.resolve()
  )
);

export const parallelCommandExecutor = handleCommand(
  'PARALLEL()',
  (command, dispatch) => Promise.all(
    command.payload.map(command => dispatch(command).promise || Promise.resolve())
  ).then(() => undefined) // we should return Promise<void> because we should not rely on promise result
);

With these executors we can create action creator instead of executor for the previous example.

// import action creators
import { firstCommand, secondCommand, thirdCommand } from './commands/exampleCommands';
import { sequenceCommand, parallelCommand } from './commands/executionOrderCommands';

export default function firstThenNext() {
  return sequenceCommand([
    firstCommand(),
    parallelCommand([
      secondCommand(),
      thirdCommand()
    ])
  ]);
}
// it will return
// {
//   type: 'SEQUENCE()',
//   payload: [
//     { type: 'FIRST()' },
//     { 
//       type: 'PARALLEL()',
//       payload: [
//         { type: 'SECOND()' },
//         { type: 'THIRD()' }
//       ]
//     }
//   ]
// }

API

combineExecutors

type ExecutorsMap<S> = {
  [K in keyof S]: Executor<S[K]>;
};

function combineExecutors<S>(map: ExecutorsMap<S>): Executor<S>;

Binds executors to state branches and combines them to one executor. Executors will be called in sequence but promise will be resolved in parallel (by Promise.all) Useful for re-usable executors.

createExecutorEnchancer

type StoreExecutableEnhancer<S> = (next: StoreEnhancerStoreCreator<S>) => StoreEnhancerStoreExecutableCreator<S>;
type StoreEnhancerStoreExecutableCreator<S> = (reducer: Reducer<S>, preloadedState: S) => ExecutableStore<S>;

function createExecutorEnhancer<S>(executor: Executor<S>): StoreExecutableEnhancer<S>;

Creates new redux enhancer that extends redux store api (see ExecutableStore)

ExecutableDispatch

interface ExecutableDispatch<S> extends Dispatch<S> {
  <A extends Action>(action: A): A & { promise?: Promise<void> };
}

It's type of enhanced dispatch method that can add promise field to returned action if you dispatch command.

ExecutableStore

interface ExecutableStore<S> extends Store<S> {
  dispatch: ExecutableDispatch<S>;
  replaceExecutor(nextExecutor: Executor<S>): void;
}

It's type of store that has enhanced dispatch method (see ExecutableDispatch) and replaceExecutor method (like replaceReducer).

Executor

type Executor<S> = (command: Action, dispatch: ExecutableDispatch<S>, getState: GetState<S | undefined>) => Promise<void> | void;

See Concepts / An Executor

EXECUTOR_INIT

const EXECUTOR_INIT: string = '@@executor/INIT()';

Command of this type is dispatched on init and replaceExecutor call.

GetState

type GetState<S> = () => S;

Simple function to get current state (we don't provide state itself because it can change during async side-effects).

handleCommand

function handleCommand<S>(type: string, executor: Executor<S>): Executor<S>;

Limit executor to given command type (inspired by redux-actions).

handleCommands

type ExecutorPerCommandMap<S> = {
  [type: string]: Executor<S>;
};

function handleCommands<S>(map: ExecutorPerCommandMap<S>): Executor<S>;

Similar to handleCommand but works for multiple commands at once. Map is an object where key is a command type, value is an executor (inspired by redux-actions).

isCommand

function isCommand(object: any): boolean;

Checks if given object is a command (object.type ends with () string).

isCommandType

function isCommandType(type: string): boolean;

Similar to isCommand but checks only type string.

mountExecutor

function mountExecutor<S1, S2>(selector: (state: S1 | undefined) => S2, executor: Executor<S2>): Executor<S1>;

Mounts executor to some state branch. Useful for re-usable executors.

reduceExecutors

function reduceExecutors<S>(...executors: Executor<S>[]): Executor<S>;

Reduces multiple executors to one. Executors will be called in sequence but promise will be resolved in parallel (by Promise.all). Useful for re-usable executors.

Code Splitting

Redux Executor provides replaceExecutor method on ExecutableStore interface (store created by Redux Executor). It's similar to replaceReducer - it changes executor and dispatches { type: '@@executor/INIT()' }.

Typings

If you are using TypeScript, you don't have to install typings - they are provided in npm package.

License

MIT