Redux for Chrome Extensions

Bruno Antunes

React took the frontend development scene by storm a long time ago. It’s great, and made a JavaScript fan out of me. However, being just a view layer and all, it usually takes a few more parts to reach a complete solution.

If you find yourself overwhelmed with property-passing down components trees and starting to lose your grip on event handlers, then Redux, a “predictable state container for JavaScript apps”, might prove valuable for your stack.

Redux in a nutshell

A fullblown intro to Redux is a bit out of scope for this blog post, so let’s just go over the basics. To learn more, check out the official docs or LearnCode.academy’s excellent playlist on Redux which goes over React integration as well.

Redux is completely independent from React. In fact, React is not even required. Like Flux, there’s an application-wide Store object that holds all of your state. Unlike Flux, Redux uses a single global store and not one per model or concern.

Actions are triggered from your UI components and run through one or more functions called reducers. Example action1:

{
  type: 'LISTS_REFRESH_FULFILLED',
  payload: {
    data: [
      {icon: 'rss', name: 'RSS', id: 1},
    ]
  }
}

Redux stores expose four functions, and actions such as the one in the example above are dispatched using the store’s aptly-named dispatch() function. After dispatching the action from the store, your reducers take the current state and the dispatched action, and return a new state. Here’s an example of a reducer:

function listsReducer(state, action) {
  switch(action.type) {
    case 'LISTS_REFRESH_START':
      break

    case 'LISTS_REFRESH_FULFILLED':
      const records = action.payload.data.map( (item) => {
        return {
          icon: item.icon,
          name: item.name,
          id: item.id,
        }
      })
      state =  {...state, records: records, activeId: records[0].id}
      break
  }

  return state
}

Once the state update is finalized in the Store, React re-renders your components, and the cycle can begin anew.

Redux concept flow

Redux and Chrome extensions

We’ve written about how to write a Chrome extension on the blog, but again let’s go a bit deeper into background and popup pages, and how Redux can help organize your extension’s code.

A Chrome extension is composed of a manifest file, one or more HTML files (unless it is a theme extension), and optionally supporting files (JavaScript, CSS, images, etc.). Extensions can also add a button to the browser’s toolbar, and when clicked this button may display a popup under it. These popups are not long-lived, since they are created once needed and disposed of by the browser when closed. For long-lived scripts, you need a background script to be declared on your manifest.

The small example I’ll be showing on this post is for a bookmark saver extension which shows a popup allowing you to decide onto which remote list you want to save the current URL. A persistent background script will hold state in a Redux store, and the popup will sync its state with the background script’s store when clicked.

JavaScript running on a popup page can’t directly communicate with the background script, since it runs on a different context. To communicate with a persistent background script, we make use of message passing. This serves as an apt analogy of the action dispatching nature of Redux itself. A very useful NPM module that I’ll be using for this example is react-chrome-redux, which wraps the Chrome message passing API onto Redux mechanics. Its overview goes over the concept.

Getting started

Let’s start with the package.json file. You can also install the listed dependencies manually, if you prefer.

{
  "name": "bookmark-saver",
  "description": "Redux Chrome extension example",
  "repository": {
    "type": "git",
    "url": "git@github.com:user/repo.git"
  },
  "version": "0.0.1",
  "author": {
    "name": "Your Name",
    "email": "user@server.com"
  },
  "license": "MIT",
  "scripts": {
    "build": "webpack --watch --progress"
  },
  "dependencies": {
    "axios": "^0.16.2",
    "babel-plugin-transform-decorators-legacy": "^1.3.4",
    "babel-polyfill": "^6.23.0",
    "babel-preset-es2015": "^6.24.1",
    "babel-preset-stage-2": "^6.24.1",
    "css-loader": "^0.28.4",
    "file-loader": "^0.11.2",
    "react": "^15.4.1",
    "react-chrome-redux": "^1.3.3",
    "react-dom": "^15.4.1",
    "react-redux": "^4.4.6",
    "redux": "^3.6.0",
    "redux-logger": "^3.0.6",
    "redux-thunk": "^2.2.0",
    "url-loader": "^0.5.9"
  },
  "devDependencies": {
    "babel-core": "^6.25.0",
    "babel-loader": "^7.1.1",
    "babel-plugin-transform-async-to-generator": "^6.24.1",
    "babel-preset-react": "^6.24.1",
    "copy-webpack-plugin": "^4.0.1",
    "extract-text-webpack-plugin": "^3.0.0",
    "html-webpack-plugin": "^2.30.1",
    "webpack": "^3.5.3",
    "write-file-webpack-plugin": "^3.4.2"
  }
}

After installing the packages with either npm install or yarn install, let’s create the Webpack configuration file.

const path = require('path')
const CopyPlugin = require('copy-webpack-plugin')
const HtmlPlugin = require('html-webpack-plugin')
const ExtractTextPlugin = require('extract-text-webpack-plugin')

require("babel-core/register");
require("babel-polyfill");

const PAGES_PATH = './src/pages'

function generateHtmlPlugins(items) {
  return items.map( (name) => new HtmlPlugin(
    {
      filename: `./${name}.html`,
      chunks: [ name ],
    }
  ))
}

module.exports = {
  entry: {
    background: [
      'babel-polyfill',
      `${PAGES_PATH}/background`,
    ],
    popup: [
      'babel-polyfill',
      `${PAGES_PATH}/popup`,
    ]
  },
  output: {
    path: path.resolve('dist/pages'),
    filename: '[name].js'
  },
  module: {
    rules: [
      {
        test: /\.js$/,
        use: [ 'babel-loader' ]
      },
      {
        test: /\.jpe?g$|\.gif$|\.png$|\.ttf$|\.eot$|\.svg$/,
        use: 'file-loader?name=[name].[ext]?[hash]'
      },
      {
        test: /\.woff(2)?(\?v=[0-9]\.[0-9]\.[0-9])?$/,
        loader: 'url-loader?limit=10000&mimetype=application/fontwoff'
      },
    ]
  },
  plugins: [
    new ExtractTextPlugin(
      {
        filename: '[name].[contenthash].css',
      }
    ),
    new CopyPlugin(
      [
        {
          from: 'src',
          to: path.resolve('dist'),
          ignore: [ 'pages/**/*' ]
        }
      ]
    ),
    ...generateHtmlPlugins(
      [
        'background',
        'popup'
      ]
    )
  ]
}

Pretty standard setup - output will be stored in the dist/ folder, that you should exclude from git, and we’ll have two “pages”: background, which will hold the persistent Redux store, and popup - the browser popup we’ll be interacting with. Accompanying assets (CSS, icons) will be placed on the output folder as well. To run the build and keep watching for changes, run npm run build, or yarn build if you use Yarn.

Finally, before moving onto the actual source, there’s one final piece of plumbing needed, a .babelrc file to set up Babel’s transpilation behaviour:

{
  "presets": [
    "react",
    "es2015",
    "stage-2"
  ],
  "plugins": [
    "transform-class-properties",
    "transform-async-to-generator",
    "transform-decorators-legacy"
  ]
}

With the setup out of the way, let’s create our source tree. It should look something like this:

├── src/
│   ├── assets/
│   ├── manifest.json
│   ├── modules/
│   ├── pages/
│   │   ├── background/
│   │   └── popup/
│   └── shared/
└── .babelrc
└── package.json
└── webpack.config.js

Background page

Let’s start with the background page, since it’s the more complex of the two.

This is the content of src/pages/background/index.js:

import axios from 'axios'

import store from './store'

Pretty simple! Axios is a useful promises-based HTTP client that will be really handy. As for the logic on the store.js file:

import { applyMiddleware, createStore } from 'redux'
import { wrapStore, alias } from 'react-chrome-redux'
import { createLogger } from 'redux-logger'
import thunk from 'redux-thunk'

import aliases from './aliases'
import reducer from './reducers'

const logger = createLogger({
  collapsed: true,
})

const initialState = {
  lists: {
    activeId: null,
    records: [],
  }
}

const store = createStore(
  reducer,
  initialState,
  applyMiddleware(
    alias(aliases),
    thunk,
    logger,  // NOTE: logger _must_ be last in middleware chain
  ),
)

wrapStore(store, {
  portName: 'BOOKMARKSAVER',
})

export default store

Going over each of the parts:

import { applyMiddleware, createStore } from 'redux'
import { wrapStore, alias } from 'react-chrome-redux'
import { createLogger } from 'redux-logger'
import thunk from 'redux-thunk'

We first import the relevant parts of Redux and React-Chrome-Redux. Loggers are always handy in development, so we make use of redux-logger. Finally, we import thunk to be able to dispatch actions to our store that return other dispatches. In practice what this means is that we’re able to send a whole flow of actions to the store in the form of promises. This will become clearer ahead.

import aliases from './aliases'
import reducer from './reducers'

These imports refer to our reducers, which are typical for a Redux app, and aliases that are a special quirk of React-Chrome-Redux. They are needed since communication between the parts of our extension via message passing only supports JSON-serializable objects. Standard FSAs1 are easily serialized onto objects, but thunks (our promise-based flows) are not, so we create a specific action type to be issued on the popup page that maps onto a thunk on the background page. More on aliases ahead.

const logger = createLogger({
  collapsed: true,
})

const initialState = {
  lists: {
    activeId: null,
    records: [],
  }
}

Here we set options for the logger, and define our initial state for the app.

const store = createStore(
  reducer,
  initialState,
  applyMiddleware(
    alias(aliases),
    thunk,
    logger,  // NOTE: logger _must_ be last in middleware chain
  ),
)

wrapStore(store, {
  portName: 'BOOKMARKSAVER',
})

Finally, we create the Redux store passing our reducers, initial state and middleware we’re using. This is just like any other Redux store - on the popup page we’ll need to create a React-Chrome-Redux specific proxy to this store. Notice also that the message passing port name has to match the one on the popup page.

Now for the aliases.js content:

import {
  LISTS_REFRESH_REQUESTED,
  URL_SUBMIT_REQUESTED,
} from '../../shared/constants'

import { fetchLists, submitURL } from '../../modules/ajax'

const listsRefreshRequestedAlias = (originalAction) => {
  return (dispatch, getState) => {
    fetchLists(dispatch)
  }
}

const urlSubmitRequestedAlias = (originalAction) => {
  const { listId, url, title } = originalAction.payload

  return (dispatch, getState) => {
    submitURL(dispatch, listId, url, title)
  }
}

export default {
  LISTS_REFRESH_REQUESTED: listsRefreshRequestedAlias,
  URL_SUBMIT_REQUESTED: urlSubmitRequestedAlias,
}

We start by including some constants. It’s a good practice to use constants instead of strings to refer to actions, since it’s less error prone. The constants.js file is straightforward:

export const LISTS_REFRESH_START = 'LISTS_REFRESH_START'
export const LISTS_REFRESH_FULFILLED = 'LISTS_REFRESH_FULFILLED'
export const LISTS_REFRESH_ERRORED = 'LISTS_REFRESH_ERRORED'
export const LISTS_REFRESH_REQUESTED = 'LISTS_REFRESH_REQUESTED'

export const LISTS_SET_ACTIVE = 'LISTS_SET_ACTIVE'

export const URL_SUBMIT_START = 'URL_SUBMIT_START'
export const URL_SUBMIT_FULFILLED = 'URL_SUBMIT_FULFILLED'
export const URL_SUBMIT_ERRORED = 'URL_SUBMIT_ERRORED'
export const URL_SUBMIT_REQUESTED = 'URL_SUBMIT_REQUESTED'

Back to our aliases, the important bit is at the end:

export default {
  LISTS_REFRESH_REQUESTED: listsRefreshRequestedAlias,
  URL_SUBMIT_REQUESTED: urlSubmitRequestedAlias,
}

This will be used by React-Chrome-Redux to map incoming actions from the popup to the functions mentioned. Let’s see what happens when, for example, our background store receives a LISTS_REFRESH_REQUESTED action.

React-Chrome-Redux will forward that action from the popup to the background store, but will check defined aliases first. Since we have a function set here, this code will run:

const listsRefreshRequestedAlias = (originalAction) => {
  return (dispatch, getState) => {
    fetchLists(dispatch)
  }
}

fetchLists is defined in the src/modules/ajax.js file:

export function fetchLists(dispatch) {
  dispatch( (dispatch) => {
    dispatch(listActions.refreshStart())
    axios.get(`${apiHome}/lists`)
      .then( (data) => {
        dispatch(listActions.refreshFulfilled(data))
      })
      .catch( (err) => {
        dispatch(listActions.refreshErrored(err))
      })
  })
}

As we can see here, we want to be able to work with promises and execute follow-up data processing to the fetch result by Axios, or catch any errors. This flow would not be able to reach our background store if called from the popup due to the message passing serialization restrictions, hence the need to “alias” it.

Moving on to the src/pages/background/reducers.js file:

import {combineReducers} from 'redux'

import listsReducer from './reducers/listsReducer'

export default combineReducers({
  lists: listsReducer,
})

This is the standard Redux way of combining multiple reducers. We only have one for this example, but adding a new one would be easy - just a new key matching a new import. To note that as a side-effect of this setup, listsReducer will not receive the whole state whenever processing new actions, only the subset under the lists key in the overall state.

The actual reducer:

import {
  LISTS_REFRESH_FULFILLED,
  LISTS_SET_ACTIVE,
} from '../../../shared/constants'

export default function listsReducer(state={}, action) {
  switch(action.type) {
    case LISTS_SET_ACTIVE:
      state =  {...state, activeId: action.payload}
      break

    case LISTS_REFRESH_FULFILLED:
      const records = action.payload.data.map( (item) => {
        return {
          icon: item.icon,
          name: item.name,
          id: item.id,
        }
      })
      state =  {...state, records: records, activeId: state.activeId || records[0].id}
      break
  }

  return state
}

We’re handling two cases here: when a list is set to active by being selected on the popup’s UI, and when Axios successfully gets the lists from the remote API.

The popup page is quite simple. Bear in mind that the background page runs as long as the browser is running, but the popup page is instatiated anew every time it is interacted with.

Here’s src/pages/popup/index.js:

import React from 'react'
import ReactDOM from 'react-dom'
import {Provider} from 'react-redux'
import {Store} from 'react-chrome-redux'

import App from './app'

const store = new Store({
  portName: 'BOOKMARKSAVER',
})

store.ready().then(() => {
  const mountNode = document.createElement('div')
  document.body.appendChild(mountNode)

  ReactDOM.render(
    <Provider store={store}>
      <App />
    </Provider>,
    mountNode
  )
})

Straightforward setup! Notice that the store we create is not from Redux, but from React-Chrome-Redux - this is the “proxy store” mentioned before. Since it is a proxy, the only setting it needs is the port name to reach the background store through message passing.

After making sure the store is ready after doing a behind-the-scenes initial sync, we create a div element to mount the app on. Let’s move on to the meat of the popup, src/pages/popup/app.js.

import React from 'react'
import {connect} from 'react-redux'
import { Dropdown, Button } from 'semantic-ui-react'
import 'semantic-ui-css/semantic.min.css'

The basic React imports, plus a little help from SemanticUI for element styling.

import * as listActions from '../../shared/actions/listActions'

import { submitURL } from '../../modules/ajax'

@connect((store) => {
  return {
    lists: store.lists,
  }
})

Next up, we import our list action creators and the submitURL function from our shared AJAX module. We also connect the component to the Redux store using decorators - one of the plugins we’ve added to our .babelrc ( “transform-decorators-legacy”). The connection to the store makes the lists branch of our state tree available to the class as a prop.

export default class App extends React.Component {
  componentWillMount() {
    const { lists, dispatch } = this.props

    if (lists.records.size === undefined) {
      dispatch(listActions.requestRefresh())
    }
  }
}

Using one of React’s lifecycle methods we make sure that we dispatch an action to refresh our lists from the server. This will be done on the first instance we interact with the popup, as that first time will populate the state for our background store. So subsequent instances of the popup page will just sync up with that state in the store.

Let’s go over the main render method.

render() {
  const { lists } = this.props

  const options = lists.records.map( (record) => {
    return {
      key: record.id,
      text: record.name,
      value: record.id,
      icon: record.icon,
    }
  })

  return (
    <div className="ui grid divided container">
      <div className="ui one wide column">
        <br/>
        <Dropdown
          onChange={(_evt, data) => this.listChanged(data)}
          selection
          placeholder='Select List'
          options={options}
          defaultValue={this.optionWithDefaultValue(options)}
        />
        <div className="ui divider"/>
        <Button primary onClick={
          () => this.submitCurrentURL()
        }>Save</Button>
      </div>
    </div>
  )
}

We iterate over our lists and create an options array, that is then fed into a Dropdown SemanticUI component. The two handlers we setup cover changing the selected list (onChange event for the dropdown) and submitting the current URL (onClick for the submit button).

When the list is changed, we dispatch an action to change the default one. We’ve covered the reducer above:

listChanged(data) {
  const { dispatch } = this.props

  dispatch(
    listActions.setActive(data.value)
  )
}

And when we submit the current URL, we just use Chrome’s tabs API to get the current one’s properties, and use that as a payload to the URL submit action:

async submitCurrentURL() {
  const { dispatch, lists } = this.props
  const activeListId = lists.activeId

  chrome.tabs.query({
    active: true,
    currentWindow: true
  }, (arrayOfTabs) => {
    const activeTab = arrayOfTabs[0]
    const url = activeTab.url
    const title = activeTab.title

    dispatch(
      listActions.requestURLSubmit(activeListId, url, title)
    )
    window.close()
  })
}

To see the extension in action, enable “developer mode” on Chrome’s extensions page, and then “Load unpacked extension” and point it to the dist folder that webpack’s build creates. The extension should be now added to your list.

To use the extension, you need a server providing the simple API it consumes. Let’s mock it using json-server. Install it with npm, then create a JSON file with this content:

{
  "lists": [
    {
      "id": 1,
      "icon": "inbox",
      "name": "Inbox"
    },
    {
      "id": 2,
      "icon": "rss",
      "name": "RSS"
    }
  ]
}

Save it as db.json and run it with json-server --port 5000 --watch db.json.

We can now use the extension. Whenever you save a link with it, you should see a POST on the terminal window you’re running json-server on:

OPTIONS /lists/1/list_items 204 0.462 ms - 0
POST /lists/1/list_items 404 13.328 ms - 2

Once added to Chrome, you can navigate to any URL and click the extension’s icon to bring up the popup.

And that’s it! This is a bit of a contrived example, but serves as a good intro to how Redux can help manage state in your Chrome extensions, and also on how to separate interactions within your extension into clearly delineated actions. Full code for this article is available here.

To note, background pages have the potential to negatively impact your browser’s performance and your device’s battery life. Event pages are preferable, and I might revisit that topic in a follow-up post. For now, have a look at this to have a feel for a potential solution using events.

A helpful tool as an extension developer is Quick Extension Reload that adds a very helpful button to the browser’s right-click menu to reload extensions, which is much handier than going to the extension settings page.

 

1: Have a look at Flux Standard Action for more on how to structure your action objects.