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 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.
Popup page
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.