Manage async redux actions sanely

Overview

redux-await

NPM version Build status Test coverage Downloads

Manage async redux actions sanely

Breaking Changes!!

redux-await now takes control of a branch of your state/reducer tree similar to redux-form, and also like redux-form you need to use this module's version of connect and not react-redux's

Install

npm install --save redux-await

Usage

This module exposes a middleware, reducer, and connector to take care of async state in a redux app. You'll need to:

  1. Apply the middleware:

    import { middleware as awaitMiddleware } from 'redux-await';
    let createStoreWithMiddleware = applyMiddleware(
      awaitMiddleware
    )(createStore);
  2. Install the reducer into the await path of your combineReducers

    import reducers from './reducers';
    
    // old code
    // const store = applyMiddleware(thunk)(createStore)(reducers);
    
    // new code
    import { reducer as awaitReducer } from 'redux-await';
    const store = applyMiddleware(thunk, awaitMiddleware)(createStore)({
      ...reducers,
      await: awaitReducer,
    });
  3. Use the connect function from this module and not react-redux's

    // old code
    // import { connect } from 'react-redux';
    
    // new code
    import { connect } from 'redux-await';
    
    class FooPage extends Component {
      render() { /* ... */ }
    }
    
    export default connect(state => state.foo)(FooPage)

Now your action payloads can contain promises, you just need to add AWAIT_MARKER to the action like this:

// old code
//export const getTodos = () => ({
//  type: GET_TODOS,
//  payload: {
//    loadedTodos: localStorage.todos,
//  },
//});
//export const addTodo = todo => ({
//  type: ADD_TODO,
//  payload: {
//    savedTodo: todo,
//  },
//});

// new code
import { AWAIT_MARKER } from 'redux-await';
export const getTodos = () => ({
  type: GET_TODOS,
  AWAIT_MARKER,
  payload: {
    loadedTodos: api.getTodos(), // returns promise
  },
});
export const addTodo = todo => ({
  type: ADD_TODO,
  AWAIT_MARKER,
  payload: {
    savedTodo: api.saveTodo(todo), // returns promise
  },
});

Now your containers barely need to change:

class Container extends Component {
  render() {
    const { todos, statuses, errors } = this.props;

    // old code
    //return <div>
    //  <MyList data={todos} />
    //</div>;

    // new code
    return <div>
      { statuses.loadedTodos === 'pending' && <div>Loading...</div> }
      { statuses.loadedTodos === 'success' && <MyList data={loadedTodos} /> }
      { statuses.loadedTodos.status === 'failure' && <div>Oops: {errors.loadedTodos.message}</div> }
      { statuses.savedTodo === 'pending' && <div>Saving new savedTodo</div> }
      { statuses.savedTodo === 'failure' && <div>There was an error saving</div> }
    </div>;
  }
}

//old code
// import { connect } from 'react-redux';

// new code
import { connect } from 'redux-await'; // it just spreads state.await on props

export default connect(state => state.todos)(Container)

Why

Redux is mostly concerned about how to manage state in a synchronous setting. Async apps create challenges like keeping track of the async status and dealing with async errors. While it is possible to build an app this way using redux-thunk and/or redux-promise it tends to bloat the app and it makes unit testing needlessly verbose

redux-await tries to solve all of these problems by keeping track of async payloads by means of a middleware and a reducer keeping track of payload properties statuses. Let's walk through the development of a TODO app (App 1) that starts without any async and then needs to start converting action from sync to async. We'll first try only using redux-thunk to solve this (App 2), and then see how to solve this with redux-await (App 3)

For the first version of the app we're going to store the todos in localStorage. Here's a simple way we would do it:

App1 demo

App 1

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { Provider, connect } from 'react-redux';
import { applyMiddleware, createStore, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import createLogger from 'redux-logger';

const GET_TODOS = 'GET_TODOS';
const ADD_TODO = 'ADD_TODO';
const SAVE_APP = 'SAVE_APP';
const actions = {
  getTodos() {
    const todos = JSON.parse(localStorage.todos || '[]');
    return { type: GET_TODOS, payload: { todos } };
  },
  addTodo(todo) {
    return { type: ADD_TODO, payload: { todo } };
  },
  saveApp() {
    return (dispatch, getState) => {
      localStorage.todos = JSON.stringify(getState().todos.todos);
      dispatch({ type: SAVE_APP });
    }
  },
};
const initialState = { isAppSynced: false, todos: [] };
const todosReducer = (state = initialState, action = {}) => {
  if (action.type === GET_TODOS) {
    return { ...state, isAppSynced: true, todos: action.payload.todos };
  }
  if (action.type === ADD_TODO) {
    return { ...state, isAppSynced: false, todos: state.todos.concat(action.payload.todo) };
  }
  if (action.type === SAVE_APP) {
    return { ...state, isAppSynced: true };
  }
  return state;
};
const reducer = combineReducers({
  todos: todosReducer,
})
const store = applyMiddleware(thunk, createLogger())(createStore)(reducer);

class App extends Component {
  componentDidMount() {
    this.props.dispatch(actions.getTodos());
  }
  render() {
    const { dispatch, todos, isAppSynced } = this.props;
    const { input } = this.refs;
    return <div>
      {isAppSynced && 'app is synced up'}
      <ul>{todos.map(todo => <li>{todo}</li>)}</ul>
      <input ref="input" type="text" onBlur={() => dispatch(actions.addTodo(input.value))} />
      <button onClick={() => dispatch(actions.saveApp())}>Sync</button>
      <br />
      <pre>{JSON.stringify(store.getState(), null, 2)}</pre>
    </div>;
  }
}
const ConnectedApp = connect(state => state.todos)(App);

ReactDOM.render(<Provider store={store}><ConnectedApp /></Provider>, document.getElementById('root'));

Looks cool (it's a POC so it's purposely minimal), but let's say you want to start using an API which is async to store the state, now your app will look something like App 2:

App2 demo

App 2

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { Provider, connect } from 'react-redux';
import { applyMiddleware, createStore, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import createLogger from 'redux-logger';

// this not an API, this is a tribute
const api = {
  save(data) {
    return new Promise(resolve => {
      setTimeout(() => {
        localStorage.todos = JSON.stringify(data);
        resolve(true);
      }, 2000);
    });
  },
  get() {
    return new Promise(resolve => {
      setTimeout(() => {
        resolve(JSON.parse(localStorage.todos || '[]'));
      }, 1000);
    });
  }
}

const GET_TODOS_PENDING = 'GET_TODOS_PENDING';
const GET_TODOS = 'GET_TODOS';
const GET_TODOS_ERROR = 'GET_TODOS_ERROR';
const ADD_TODO = 'ADD_TODO';
const SAVE_APP_PENDING = 'SAVE_APP_PENDING'
const SAVE_APP = 'SAVE_APP';
const SAVE_APP_ERROR = 'SAVE_APP_ERROR';
const actions = {
  getTodos() {
    return dispatch => {
      dispatch({ type: GET_TODOS_PENDING });
      api.get()
        .then(todos => dispatch({ type: GET_TODOS, payload: { todos } }))
        .catch(error => dispatch({ type: GET_TODOS_ERROR, payload: error, error: true }))
      ;
      ;
    }
  },
  addTodo(todo) {
    return { type: ADD_TODO, payload: { todo } };
  },
  saveApp() {
    return (dispatch, getState) => {
      dispatch({ type: SAVE_APP_PENDING });
      api.save(getState().todos.todos)
        .then(() => dispatch({ type: SAVE_APP }))
        .catch(error => dispatch({ type: SAVE_APP_ERROR, payload: error, error: true }))
      ;
    }
  },
};
const initialState = {
  isAppSynced: false,
  isFetching: false,
  fetchingError: null,
  isSaving: false,
  savingError: null,
  todos: [],
};
const todosReducer = (state = initialState, action = {}) => {
  if (action.type === GET_TODOS_PENDING) {
    return { ...state, isFetching: true, fetchingError: null };
  }
  if (action.type === GET_TODOS) {
    return {
      ...state,
      isAppSynced: true,
      isFetching: false,
      fetchingError: null,
      todos: action.payload.todos,
    };
  }
  if (action.type === GET_TODOS_ERROR) {
    return { ...state, isFetching: false, fetchingError: action.payload.message };
  }
  if (action.type === ADD_TODO) {
    return { ...state, isAppSynced: false, todos: state.todos.concat(action.payload.todo) };
  }
  if (action.type === SAVE_APP_PENDING) {
    return { ...state, isSaving: true, savingError: null };
  }
  if (action.type === SAVE_APP) {
    return { ...state, isAppSynced: true, isSaving: false, savingError: null };
  }
  if (action === SAVE_APP_ERROR) {
    return { ...state, isSaving: false, savingError: action.payload.message }
  }
  return state;
};
const reducer = combineReducers({
  todos: todosReducer,
})
const store = applyMiddleware(thunk, createLogger())(createStore)(reducer);

class App extends Component {
  componentDidMount() {
    this.props.dispatch(actions.getTodos());
  }
  render() {
    const { dispatch, todos, isAppSynced, isFetching, fetchingError, isSaving, savingError } = this.props;
    const { input } = this.refs;
    return <div>
      {isAppSynced && 'app is synced up'}
      {isFetching && 'getting todos'}
      {fetchingError && 'there was an error getting todos: ' + fetchingError}
      {isSaving && 'saving todos'}
      {savingError && 'there was an error saving todos: ' + savingError}
      <ul>{todos.map(todo => <li>{todo}</li>)}</ul>
      <input ref="input" type="text" onBlur={() => dispatch(actions.addTodo(input.value))} />
      <button onClick={() => dispatch(actions.saveApp())}>Sync</button>
      <br />
      <pre>{JSON.stringify(store.getState(), null, 2)}</pre>
    </div>;
  }
}

const ConnectedApp = connect(state => state.todos)(App);

ReactDOM.render(<Provider store={store}><ConnectedApp /></Provider>, document.getElementById('root'));

As you can see there's a lot of async logic and state we don't want to have to deal with. This is 62 more LOC than the first version. Here's how you would do it in App 3 with redux-await:

App3 demo

App 3

import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { applyMiddleware, createStore, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import createLogger from 'redux-logger';
import {
  AWAIT_MARKER,
  createReducer,
  connect,
  reducer as awaitReducer,
  middleware as awaitMiddleware,
} from 'redux-await';

// this not an API, this is a tribute
const api = {
  save(data) {
    return new Promise(resolve => {
      setTimeout(() => {
        localStorage.todos = JSON.stringify(data);
        resolve(true);
      }, 2000);
    });
  },
  get() {
    return new Promise(resolve => {
      setTimeout(() => {
        resolve(JSON.parse(localStorage.todos || '[]'));
      }, 1000);
    });
  }
}

const GET_TODOS = 'GET_TODOS';
const ADD_TODO = 'ADD_TODO';
const SAVE_APP = 'SAVE_APP';
const actions = {
  getTodos() {
    return {
      type: GET_TODOS,
      AWAIT_MARKER,
      payload: {
        todos: api.get(),
      },
    };
  },
  addTodo(todo) {
    return { type: ADD_TODO, payload: { todo } };
  },
  saveApp() {
    return (dispatch, getState) => {
      dispatch({
        type: SAVE_APP,
        AWAIT_MARKER,
        payload: {
          save: api.save(getState().todos.todos),
        },
      });
    }
  },
};
const initialState = { isAppSynced: false, todos: [] };
const todosReducer = (state = initialState, action = {}) => {
  if (action.type === GET_TODOS) {
    return { ...state, isAppSynced: true, todos: action.payload.todos };
  }
  if (action.type === ADD_TODO) {
    return { ...state, isAppSynced: false, todos: state.todos.concat(action.payload.todo) };
  }
  if (action.type === SAVE_APP) {
    return { ...state, isAppSynced: true };
  }
  return state;
};
const reducer = combineReducers({
  todos: todosReducer,
  await: awaitReducer,
})

const store = applyMiddleware(thunk, awaitMiddleware, createLogger())(createStore)(reducer);

class App extends Component {
  componentDidMount() {
    this.props.dispatch(actions.getTodos());
  }
  render() {
    const { dispatch, todos, isAppSynced, statuses, errors } = this.props;
    const { input } = this.refs;
    return <div>
      {isAppSynced && 'app is synced up'}
      {statuses.todos === 'pending' && 'getting todos'}
      {statuses.todos === 'failure' && 'there was an error getting todos: ' + errors.todos.message}
      {statuses.save === 'pending' && 'saving todos'}
      {errors.save && 'there was an error saving todos: ' + errors.save.message}
      <ul>{todos.map(todo => <li>{todo}</li>)}</ul>
      <input ref="input" type="text" onBlur={() => dispatch(actions.addTodo(input.value))} />
      <button onClick={() => dispatch(actions.saveApp())}>Sync</button>
      <br />
      <pre>{JSON.stringify(store.getState(), null, 2)}</pre>
    </div>;
  }
}


const ConnectedApp = connect(state => state.todos)(App);

ReactDOM.render(<Provider store={store}><ConnectedApp /></Provider>, document.getElementById('root'));

This version is very easy to reason about, in fact you can completely ignore the fact that the app is async at all. The todosReducer didn't need to have a single line changed! Note that this is 107 LOC compared to app2's 125 LOC

Some pitfalls to watch out for

You must either use this modules connect or manually spread the await part of the tree over mapStateToProps, you can also choose to name it something other than await and spread that yourself too.

redux-await will name the statuses and errors prop the same as the payload prop so try to be as descriptive as possible when naming payload props since any payload props collision will overwrite the statuses/errors value. For a CRUD app don't always name it something like records because when you're loading users.records the app will also think you're loading todos.records

How it works:

The middleware checks to see if the AWAIT_MARKER was set on the action and if it was then dispatches three events with a [AWAIT_META_CONTAINER] property on the meta property of the action.
The reducer listens for actions with a meta of [AWAIT_META_CONTAINER] and when found will set the await property of the state accordingly.

You might also like...
Ruthlessly simple bindings to keep react-router and redux in sync

Project Deprecated This project is no longer maintained. For your Redux - Router syncing needs with React Router 4+, please see one of these librari

The official, opinionated, batteries-included toolset for efficient Redux development

Redux Toolkit The official, opinionated, batteries-included toolset for efficient Redux development (Formerly known as "Redux Starter Kit") Installati

Thunk middleware for Redux

Redux Thunk Thunk middleware for Redux. npm install redux-thunk yarn add redux-thunk Note on 2.x Update Most tutorials today assume that you're using

Logger for Redux
Logger for Redux

Logger for Redux Now maintained by LogRocket! LogRocket is a production Redux logging tool that lets you replay problems as if they happened in your o

Selector library for Redux

Reselect Simple “selector” library for Redux (and others) inspired by getters in NuclearJS, subscriptions in re-frame and this proposal from speedskat

An alternative side effect model for Redux apps
An alternative side effect model for Redux apps

redux-saga redux-saga is a library that aims to make application side effects (i.e. asynchronous things like data fetching and impure things like acce

Declarative Side Effects for Redux
Declarative Side Effects for Redux

Redux Data FX Declarative Side Effects for Redux. It helps you keep your business logic and effectful code separate. The idea is simple: in addition o

RxJS middleware for action side effects in Redux using
RxJS middleware for action side effects in Redux using "Epics"

RxJS-based middleware for Redux. Compose and cancel async actions to create side effects and more. https://redux-observable.js.org Install This has pe

Analytics middleware for Redux

redux-analytics Analytics middleware for Redux. $ npm install --save redux-analytics Want to customise your metadata further? Check out redux-tap. Usa

Comments
  • Any way to clear statuses/errors?

    Any way to clear statuses/errors?

    If i want to bind directly to statuses['my_action'] and use that, i have issues because the status and error remains as part of the redux state until the next time i issue that request. That becomes problematic if i am doing something like:

    • Edit user 1, error on the response: errors.editUser = { msg: 'Bad Data' }, statuses.editUser = 'failure'
    • Now, i don't try editing user 1 again, but i go to edit user 2. My form shows the state of the previous edit request, failure, with the message above. Ideally, I would be able to do something in my reducer to handle LOCATION_CHANGE from react-router-redux and clear that error/status.

    Is there some way to do that with redux-await?

    opened by kjanoudi 12
  • Using with Immutable.js

    Using with Immutable.js

    I'm trying to use redux-await with Immutable.js, however it does not seem to work. The following was my code before trying Immutable.js, which works fine:

    import { createReducer } from 'redux-await';
    const initialState = {
      user: null
    }
    
    const user = (state = initialState, action) => {
      switch (action.type) {
        case 'SIGN_IN':
          const { authToken } = action.payload.response;
          return Object.assign({}, state, {
            authToken
          })
        default:
          return state
      }
    }
    
    export default createReducer(user);
    

    However trying the same thing with Immutable generates an error, saying that "undefined is not a function". Cannot figure out exactly where that is tho.

    import { createReducer } from 'redux-await';
    import { Map } from 'immutable';
    const initialState = Map({})
    
    let user = (state = initialState, action) => {
      switch (action.type) {
        case 'SIGN_IN':
          const { authToken } = action.payload.response;
          state.set('authToken', authToken);
          return state;
        default:
          return state
      }
    }
    
    export default createReducer(user);
    

    Is redux-await suppose to work with Immutable.js or is it a limitation it has?

    opened by oskarer 6
  • modified

    modified "connect" doesn't pass props

    Howdy - I notice this module's wrapper of the redux "connect()" function doesn't pass the props through as the second argument, e.g.

    // plain redux
    import { connect }  from 'react-redux';
    export default connect((state,props)=>{
      console.log('props:',props);  // has the original props being passed to the Container
      return state;
    })(Container);
    
    // redux-await
    import { connect } from 'redux-await'; // it just spreads state.await on props
    export default connect((state,props)=>{
      console.log('props:',props);  // undefined - no second arg
      return state;
    })(Container);
    

    I apologize, I wasn't totally confident in the patch I came up with, so I'm currently using the original redux function and manually adding the state.await values into the returned state.

    Thanks for making this module!

    opened by EyePulp 2
Owner
Moshe Kolodny
Moshe Kolodny
Manage the state of your React app with ease

@spyna/react-store React app state management that uses a storage Demo https://spyna.github.io/react-store/ Install npm install --save @spyna/react-st

Lorenzo Spinelli 46 Jan 19, 2021
Skeleton React App configured with Redux store along with redux-thunk, redux persist and form validation using formik and yup

Getting Started with React-Redux App Some Configrations Needed You guys need to modify the baseUrl (path to your server) in the server.js file that is

Usama Sarfraz 11 Jul 10, 2022
A Higher Order Component using react-redux to keep form state in a Redux store

redux-form You build great forms, but do you know HOW users use your forms? Find out with Form Nerd! Professional analytics from the creator of Redux

Redux Form 12.6k Jan 3, 2023
redux-immutable is used to create an equivalent function of Redux combineReducers that works with Immutable.js state.

redux-immutable redux-immutable is used to create an equivalent function of Redux combineReducers that works with Immutable.js state. When Redux creat

Gajus Kuizinas 1.9k Dec 30, 2022
A chart monitor for Redux DevTools https://www.npmjs.com/package/redux-devtools-chart-monitor

Redux DevTools Chart Monitor This package was merged into redux-devtools monorepo. Please refer to that repository for the latest updates, issues and

Redux 293 Nov 13, 2022
A simple app for study react with redux, redux saga and typescript.

React com Redux, Redux-Saga e TypeScript. ?? Uma aplicação simple para entender o funcionamento do Redux e a melhor maneira de utiliza-lo junto com o

João Marcos Belanga 1 May 24, 2022
Redux - Create forms using Redux And React

Exercício de fixação Vamos criar formulários utilizando Redux! \o/ Antes de inic

Márcio Júnior 2 Jul 21, 2022
A lightweight state management library for react inspired by redux and react-redux

A lightweight state management library for react inspired by redux and react-redux

null 2 Sep 9, 2022
Official React bindings for Redux

React Redux Official React bindings for Redux. Performant and flexible. Installation Using Create React App The recommended way to start new apps with

Redux 22.5k Jan 1, 2023
DevTools for Redux with hot reloading, action replay, and customizable UI

Redux DevTools Developer Tools to power-up Redux development workflow or any other architecture which handles the state change (see integrations). It

Redux 13.3k Jan 2, 2023