Skip to content
Sidney Nemzer edited this page Jul 16, 2024 · 18 revisions

Async Actions

There is often the need to have complex or asynchronous action handling in apps. This package splits action generation and action handling into two separate pieces of the application (UI Components and the background page), making async actions a little tricky. The alias middleware aims to solve this complexity.

alias

alias is a simple middleware which can map actions to new actions. This is useful for asynchronous actions, where the async action handling needs to take place in the background page, but is kicked off by an action from a UI Component.

For example, let's say you want to get the current session in your UI components when your component mounts. Let's start by firing off a simple action GET_SESSION.

// popover/App.jsx

import React, {Component} from 'react';
import {connect} from 'react-redux';

// the mock action
const getSession = () => {
  const data = {
    type: GET_SESSION,
    payload: {}
  };

  return data;
};

class App extends Component {
  constructor(props) {
    super(props);
  }

  componentDidMount() {
    this.props.dispatch(getSession());
  }

  render() {
    return (
      <div>
        {this.props.session && this.props.users[this.props.session.userId].name}
      </div>
    );
  }
}

const mapStateToProps = (state) => {
  return {
    session: state.session,
    users: state.users
  };
};

export default connect(mapStateToProps)(App);

All of our session logic lives in our background page (such as fetching from local storage or making a web request). If we leave this as is, our Redux store would get an action of {type: GET_SESSION}, which is not very useful to our reducers considering we don't have any session data yet.

Using an alias, we can intercept the original GET_SESSION and replace it with a new action that we generate on the background page. We start by writing the intercept function, which will receive the original action and produce a new action, and then export it under the name of the action we will intercept:

// aliases.js

const getSession = (orginalAction) => {
  // return a thunk/promise/etc
};

export default {
  'GET_SESSION': getSession // the action to proxy and the new action to call
};

Then you just include it in your middleware for Redux:

import {alias} from 'webext-redux';

import aliases from '../aliases';

const middleware = [
  alias(aliases),  // this should always be the first middleware
  // whatever middleware you want (like redux-thunk)
];

// createStoreWithMiddleware... you know the drill

Now, when your Redux store sees an action of GET_SESSION, it will run our getSession() alias in our aliases file and return the result as the new action. Note: When generating new actions in your alias, you should use a different action type than the one you are aliasing! Using the same type will result in an infinite loop as every aliased action will just be re-caught by your alias.

The nice thing about doing this through a middleware is that you can run it with other packages such as redux-thunk. Your alias can return a function to run instead of an action object, and everything will proceed as normal.

Action Responses

It's a common practice to have Redux dispatches return promises with something like redux-thunk. This package provides a way to simulate this behavior by providing a dispatchResponder in the store wrapper for the background page.

dispatchResponder

When an action is dispatched from a UI Component, it will return a promise that will resolve/reject with a response from the background page:

// the mock action
const getSession = () => {
  const data = {
    type: ACTION_GET_SESSION,
    payload: {} //payload is the data/object that is resolved by the promise
  };

  return data;
};

class App extends Component {
  constructor(props) {}

  componentDidMount() {
    // promise returned from `dispatch()`
    this.props.dispatch(getSession())
      .then((data) => {
        // the response data
      })
      .catch((err) => {
        // something broke in the background store
      });
  }

  render() {}
}

This is really nice for making UI updates for error/success messages or pending states. By default, this is done by calling a simple Promise.resolve() on the dispatch result in the background and responding with an object of {error, value} based on rejection or resolution. Your aliases file would look like this:

// aliases.js

const getSession = (originalAction) => {
    return (dispatch, getState) => {
        originalAction.payload = getState().session[originalAction._sender.tab.id];
        return originalAction;
    }
};

export default {
  'GET_SESSION': getSession // the action to proxy and the new action to call
};

What if you don't return promises from your dispatches? Or what if those promises are hidden away somewhere in the payload? You can pass in a dispatchResponder to your store wrapper when calling wrapStore to take care of that:

// redux-promise-middleware returns promises under `promise` field in the payload

/**
 * Respond to action based on `redux-promise-middleware` result
 * @param  {object} dispatchResult The resulting object from `store.dispatch()`
 * @param  {func}   send           func to be called when sending response. Should be in form {value, error}
 */
const reduxPromiseResponder = (dispatchResult, send) => {
  Promise
    .resolve(dispatchResult.payload.promise) // pull out the promise
    .then((res) => {
      // if success then respond with value
      send({
        error: null,
        value: res
      });
    })
    .catch((err) => {
      // if error then respond with error
      send({
        error: err,
        value: null
      });
    });
};

// ...

// Add responder to store
wrapStore(store, {
  channelName: 'MY_APP',
  dispatchResponder: reduxPromiseResponder
});

How does this work under the hood?

The action dispatched to the proxy store in the content script or popup is dispatched to the background store using the following function:

dispatch(data) {
    return new Promise((resolve, reject) => {
      this.serializedMessageSender(
        this.extensionId,
        {
          type: DISPATCH_TYPE,
          channelName: this.channelName,
          payload: data
        }, null, (resp) => {
          const {error, value} = resp;

          if (error) {
            const bgErr = new Error(`${backgroundErrPrefix}${error}`);

            reject(assignIn(bgErr, error));
          } else {
            resolve(value && value.payload);
          }
        });
    });
  }

The proxy store sends a message to the original store through the chrome.runtime.sendMessage api (details). The response from the background store has to be a promise which resolves to an {error, value} object. If the value is not null, it has to have a payload property which is finally used to resolve the promise returned by the dispatch method of the proxy store. In cases where the dispatch method of your original store returns a promise which resolves to an object with a different form or it simply returns an object synchronously, you have to use the reduxPromiseResponder method as described above to ensure that the final response send to the proxy store is a promise of the form described above. See an example below:

// dispatchResult is the return value of the dispatch method in the background store
export default (dispatchResult, send) => {
  return Promise
          .resolve(dispatchResult)
          .then(res => {
            // resolve(value && value.payload); is used by the promise in the content script store dispatch
            return send({ error: null, value: {payload: res} });
          })
          .catch(error => send({error, value: null}))
}

In the proxy store, the dispatch function would return a promise which resolves with res or rejects with error.

Initializing UI Components

The Proxy Store state starts as an empty object ({}). This can lead to issues if you are expecting your UI Component to have the same state as your background page on load. The load process is:

  1. Initialize Proxy Store
  2. Create long-lived connection
  3. Receive background state
  4. Proxy Store now in-sync (same state as background)

To avoid this, use the store.ready() function:

store.ready().then(() => {
  // The store implements the same interface as Redux's store
  // so you can use tools like `react-redux` no problem!
  render(
    <Provider store={store}>
      <App/>
    </Provider>
    , document.getElementById('app'));
});

Want to get into the nitty gritty? Head over to the API docs.