Thunkless middleware for Redux


Keywords
redux, middleware, async, asynchronous, redux-thunk, redux-middleware
License
MIT
Install
npm install thunkless@1.0.0-beta.8

Documentation

thunkless

Simple Redux middleware for async actions without thunks.

NPM Version NPM Downloads Build Status Coverage Status License: MIT

$ npm install thunkless

What is it

thunkless is a lightweight Redux middleware library for writing async actions with a simple declarative API. It is targeted at Redux apps that use the standard async action flow: dispatch start action to signal beginning of an event, wait till the promise is resolved or rejected, optionally dispatch more actions, i.e. side effects, finally dispatch a success or a failure action. thunkless supports blocking actions - preventing an action from being dispatched until another action is completed.

Motivation (aka What's wrong with Thunk)

Redux Thunk provides a dead simple approach to asynchronous actions. Its simplicity (11 lines of code) makes it a very nice solution for small-sized apps. Its 100% imperative API, however, results in a fair amount of custom logic inside of thunks, and that becomes a pain point for bigger-sized apps (>10-15 async actions). To ensure bug-free behavior, all of these actions need to be tested for every scenario, and debugging them can be tedious when logic becomes more complex. The key insight about this is that in most cases these actions follow a similar pattern: dispatch a start action -> resolve a promise -> dispatch side effects -> dispatch a success action, or catch an error and dispatch actions for failure scenarios. This results in a lot of logic duplication. thunkless abstracts this logic away and provides a simple declarative API for this common asynchronous pattern.

Usage

The simplest (not the most useful) usage example:

// Action
const confirmIdentity = name => ({
  promise: Promise.resolve('Valar dohaeris'),
  type: CONFIRM_IDENTITY,
  payload: 'Valar morghulis',
  meta: { name },
});

// Reducer
const reducer = (state, action) => {
  if (action.type !== CONFIRM_IDENTITY) return state;

  const { payload, meta: { name } } = action;
  if (payload === 'Valar morghulis') {
    return {
      ...state,
      [name]: { status: `Confirming ${name}'s identity...` },
    };
  } else if (payload === 'Valar dohaeris') {
    return {
      ...state,
      [name]: { status: `${name}'s identity has been confirmed.` },
    };
  } else {
    return {
      ...state,
      [name]: { status: `${name} is an impostor.` },
    };
  }
}

thunkless will first send CONFIRM_IDENTITY action with payload 'Valar morghulis', wait until the promise resolves and send another CONFIRM_IDENTITY action with the result of the promise ('Valar dohaeris'). Note that the actions will be sent down the middleware chain via next() function call instead of getting dispatched, so any middleware placed before thunkless WILL NOT process the action.

A more real-world example - authentication flow:

// Action
const login = (email, password) => ({
  /**
   * If the value of promise is an async function (or a function that returns a promise)
   * and statusSelector is supplied, the promise will only be created if statusSelector
   * does not return false or thunkless.ActionStatus.BUSY. Otherwise, the action will be blocked.
   */
  promise: () => sendLoginRequest(email, password),
  type: [ // Separate action type for start, success, and failure, is a good common practice.
    START_AUTH,
    AUTH_SUCCESS,
    AUTH_FAILURE,
  ],
  // statusSelector is required if a duplicate action should be blocked.
  statusSelector: state => state.auth.loginStatus,
  /**
   * Actions in chain will be dispatched if the promise is successfully resolved.
   * If one of them results in error, AUTH_FAILURE will be sent. Otherwise,
   * thunkless will send AUTH_SUCCESS after dispatching chain actions.
   */
  chain: ({ userData, isReturningUser }) => [
    { type: INIT_USER, payload: userData },
    isReturningUser && { type: SHOW_MESSAGE, payload: 'Welcome back!' },
  ],
  /**
   * Action with type SHOW_ERROR will be dispatched on error with error object and
   * this login action instance in its payload.
   */
  dispatchOnError: SHOW_ERROR,
  meta: { email },
});

// Reducers
import { ActionStatus } from 'thunkless';

const { BUSY, SUCCESS, FAILURE } = ActionStatus;

const authReducer = (state, action) => {
  switch (action.type) {
    case START_AUTH: return {
      ...state,
      loginStatus: BUSY
    }

    case AUTH_SUCCESS: return {
      ...state,
      loginStatus: SUCCESS,
      username: action.payload.userData.username,
    }

    case AUTH_FAILURE: return {
      ...state,
      loginStatus: FAILURE,
    }

    default: return state;
  }
}

const userReducer = (state, action) => {
  if (action.type !== INIT_USER) return state;

  const { payload: { userData } } = action;
  return {
    ...state,
    [userData.username]: userData,
  };
}

const errorReducer = (state, action) => {
  if (action.type !== SHOW_ERROR) return state;

  const { payload: { error, origin } } = action;

  if (error.message === 'Email Not Found') {
    const { meta: { email } } = origin;
    return {
      ...state,
      message: `User with email ${email} does not exist.`,
    }
  }

  return {
    ...state,
    message: error.message
  };
}

When to choose thunkless

thunkless is useful for medium- and big-sized apps that have many asynchronous actions that follow the same common pattern.

thunkless allows to block an action from being dispatched when another action is in progress. It does not, however, restore the state after an action has failed (e.g. an asynchronous action in the chain of another action). A library like redux-optimistic-ui can be used together with thunkless to enable that functionality.

thunkless is not a suitable solution for apps that need advanced action queueing capabilities or complex side effect patterns (yet even big-sized apps don't typically need them). If those are a must, Redux-Saga is an excellent choice.

Installation

npm install thunkless

Use applyMiddleware() to enable:

import { createStore, applyMiddleware } from 'redux';
import { middleware } from 'thunkless';
import rootReducer from './reducers/index';

const store = createStore(
  rootReducer,
  applyMiddleware(middleware)
);

TypeScript

createThunklessAction is just an identity function that helps to strongly type action objects in TypeScript.

import { createThunklessAction } from 'thunkless';

const login = (email, password) => createThunklessAction({
  promise: sendLoginRequest(email, password),
  type: [
    LOGIN_REQUEST,
    LOGIN_SUCCESS,
    LOGIN_FAILURE,
  ] as const,
  /**
   * Chain function parameter type will be inferred
   * from the promise result type.
   */
  chain: ({ userData, isReturningUser }) => [
    { type: INIT_USER, payload: userData },
    isReturningUser && { type: SHOW_MESSAGE, payload: 'Welcome back!' },
  ],
});

ReducibleThunklessAction type helper can be used to resolve action type properly in the reducer. Example usage:

import type { ReducibleThunklessAction } from 'thunkless';
import type { login, signup } from '../actions/auth';

const authReducer = (state, action: ReducibleThunklessAction<typeof login>|ReducibleThunklessAction<typeof signup>) => {
  switch (action.type) {
    case AUTH_SUCCESS: return {
      ...state,
      // username type will be inferred correctly
      username: action.payload.userData.username,
    }
  }
}

License

MIT