├── .babelrc
├── .eslintrc
├── .gitignore
├── LICENSE
├── README.md
├── example
├── LoadingWrapper.js
└── getJson.js
├── package.json
└── src
├── AsyncState.js
├── __tests__
└── index.spec.js
├── asyncMiddleware.js
├── asyncReducer.js
├── getAsyncState.js
├── index.js
└── resetAction.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | presets: ['es2015']
3 | }
4 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "eslint:recommended",
3 |
4 | "parser": "babel-eslint",
5 |
6 | "env": {
7 | "browser": true,
8 | "node": true,
9 | "mocha": true
10 | },
11 |
12 | "rules": {
13 | "semi": 1,
14 | "strict": 0,
15 | "quotes": [2, "single"],
16 | "no-underscore-dangle": [0],
17 | "new-cap": 0,
18 | "no-shadow": 0,
19 | "no-console": 0,
20 | "comma-dangle": 0
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | lib/
3 | npm-debug.log
4 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Thomas Boyt
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # redux-happy-async
2 |
3 | **(this library isn't battle-tested or recommended for usage yet!)**
4 |
5 | Tries to cut some of the more obnoxious boilerplate out of handling async state in Redux.
6 |
7 | Basically, this adds an `async` reducer to your store's state that contains async state per action.
8 |
9 | ## Why?
10 |
11 | Managing async action state in Redux is a very, very common topic of discussion. The classical way to do it is to create three action types (for start, error, and success), and then create boilerplate to set loading/error states for each action inside a reducer. This boilerplate adds up fast if you're creating an app with lots of async actions!
12 |
13 | redux-happy-async abstracts over this pattern and keeps this boilerplate out of your reducers. To do this, it adds an `async` reducer to that tracks action states which your components can read from.
14 |
15 | ## Example
16 |
17 | https://github.com/thomasboyt/earthling-github-issues/tree/async-rewrite
18 |
19 | ## Usage
20 |
21 | First, add the async reducer and middleware to your Redux store:
22 |
23 | ```js
24 | import {createStore, combineReducers, applyMiddleware} from 'redux';
25 | import {asyncReducer, asyncMiddleware} from 'redux-happy-async';
26 |
27 | // thunk middleware is optional, but used in below examples
28 | const createStoreWithMiddleware = applyMiddleware(thunkMiddleware, asyncMiddleware)(createStore);
29 |
30 | const store = createStoreWithMiddleware(combineReducers({
31 | async: asyncReducer,
32 | // ...
33 | }));
34 | ```
35 |
36 | Then, write your reducer like normal. Note that any async actions you have will only reach your reducer if the `ACTION_SUCCESS` status is part of the payload. In the below example, the reducer is only called with `{type: LOAD_TODOS}` once the todos have successfully been loaded:
37 |
38 | ```js
39 | const State = I.Record({
40 | todos: null,
41 | });
42 |
43 | export default function todoReducer(state=new State(), action) {
44 | switch (action.type) {
45 | case LOAD_TODOS:
46 | // This action is only actually received by the reducer if `asyncStatus: ACTION_SUCCESS` is part
47 | // of the payload!
48 | return state.set('todos', action.todos);
49 | default:
50 | return state;
51 | }
52 | }
53 | ```
54 |
55 | Then, create an action creator that uses the `asyncStatus` fields in its payloads.
56 |
57 | ```js
58 | import {ACTION_START, ACTION_SUCCESS, ACTION_ERROR} from 'redux-happy-async';
59 |
60 | export function getTodos() {
61 | return async function(dispatch) {
62 | dispatch({
63 | type: LOAD_TODOS,
64 | // This special `asyncStatus` field is read by the async middleware
65 | // to update the async reducer
66 | asyncStatus: ACTION_START
67 | });
68 |
69 | const resp = await window.fetch(/*...*/);
70 |
71 | if (resp.status !== 200) {
72 | const err = await resp.json();
73 |
74 | dispatch({
75 | type: LOAD_TODOS,
76 | asyncStatus: ACTION_ERROR,
77 | // This field is set on the action's async state as `error`
78 | error: err,
79 | });
80 | }
81 |
82 | const data = await resp.json();
83 |
84 | dispatch({
85 | type: LOAD_TODOS,
86 | asyncStatus: ACTION_SUCCESS,
87 | todos: data
88 | });
89 | };
90 | }
91 | ```
92 |
93 | Then inside your component you could do something like:
94 |
95 | ```js
96 | import React from 'react';
97 | import {connect} from 'react-redux';
98 |
99 | import {getAsyncState} from 'redux-happy-async';
100 |
101 | const TodosList = React.createClass({
102 | componentWillMount() {
103 | this.props.dispatch(getTodos());
104 | },
105 |
106 | render() {
107 | if (this.props.loadingState.loading || !this.props.todos) {
108 | return Loading...;
109 |
110 | } else if (this.props.loadingState.error) {
111 | return Encountered error loading;
112 |
113 | } else {
114 | return todos.map((todo) => {/*...*/});
115 | }
116 | }
117 | });
118 |
119 | function select(state) {
120 | return {
121 | todos: state.todos.todos,
122 | // returns an object of shape {loading: true/false, error: obj/null}
123 | loadingState: getAsyncState(state, LOAD_TODOS)
124 | };
125 | }
126 |
127 | export default connect(select)(TodosList);
128 | ```
129 |
130 | You can also create further abstractions, look in `example/` for some.
131 |
132 | ### Using Unique IDs
133 |
134 | Of course, in some cases, your application may have multiple inflight actions of the same type. For example, imagine a todo list with a "complete" action that saves to a very, very slow server. You might click the "complete" checkbox for multiple items at once, and need to separately track the state of each item's "complete" action.
135 |
136 | In that case, you'll want to set a `uniqueId` on the action payload that the async reducer will use to determine which state to update. For example, given a "complete todo" action:
137 |
138 | ```js
139 | import {ACTION_START, ACTION_SUCCESS, ACTION_ERROR} from 'redux-happy-async';
140 | import {COMPLETE_TODO} from '../ActionTypes';
141 |
142 | export function completeTodo(todoId) {
143 | return async function(dispatch) {
144 | dispatch({
145 | type: COMPLETE_TODO,
146 | asyncStatus: ACTION_START,
147 |
148 | // we pass todoId here since it is the "unique key" for this action
149 | uniqueId: todoId,
150 | });
151 |
152 | const resp = await window.fetch(/*...*/);
153 |
154 | if (resp.status !== 200) {
155 | const err = await resp.json();
156 |
157 | dispatch({
158 | type: COMPLETE_TODO,
159 | asyncStatus: ACTION_ERROR,
160 | uniqueId: todoId,
161 | error: err,
162 | });
163 | }
164 |
165 | dispatch({
166 | type: COMPLETE_TODO,
167 | asyncStatus: ACTION_SUCCESS,
168 | uniqueId: todoId,
169 | });
170 | };
171 | }
172 | ```
173 |
174 | And this reducer:
175 |
176 | ```js
177 | const State = I.Record({
178 | todos: null,
179 | });
180 |
181 | export default function todoReducer(state=new State(), action) {
182 | switch (action.type) {
183 | case LOAD_TODOS:
184 | return state.set('todos', I.Map(action.todos.map((todo) => [todo.id, I.Map(todo)])));
185 | case COMPLETE_TODO:
186 | return state.setIn(['todos', action.id, 'complete'], true);
187 | default:
188 | return state;
189 | }
190 | }
191 | ```
192 |
193 | You could create an individual todo component that displays the async state of its complete action:
194 |
195 | ```js
196 | import React from 'react';
197 | import {connect} from 'react-redux';
198 |
199 | import {getAsyncState} from 'redux-happy-async';
200 |
201 | import {COMPLETE_TODO} from '../ActionTypes';
202 | import {completeTodo} from './actions/TodoActions';
203 |
204 | const Todo = React.createClass({
205 | propTypes: {
206 | todoId: React.PropTypes.number.isRequired,
207 | },
208 |
209 | handleComplete() {
210 | this.props.dispatch(completeTodo(this.props.todoId));
211 | },
212 |
213 | renderComplete() {
214 | const {completeAsyncState} = this.props;
215 |
216 | if (completeAsyncState.error) {
217 | return (
218 | error completing. retry?
219 | );
220 | } else if (completeAsyncState.loading) {
221 | return (
222 | loading...
223 | );
224 | } else {
225 | return (
226 | complete
227 | );
228 | }
229 | },
230 |
231 | render() {
232 | const {todo} = this.props;
233 |
234 | return (
235 |
236 | {todo.text}
237 | {' '}
238 | {todo.completed === false &&
239 | complete}
240 |
241 | );
242 | }
243 | });
244 |
245 | function select(state, props) {
246 | const {todoId} = props;
247 |
248 | return {
249 | todo: state.todos.todos.get(todoId),
250 |
251 | // Note the third argument to getAsyncState!
252 | completeAsyncState: getAsyncState(state, COMPLETE_TODO, todoId)
253 | };
254 | }
255 |
256 | export default connect(select)(TodosList);
257 | ```
258 |
259 | ## API
260 |
261 | ### Action payload fields
262 |
263 | * `asyncStatus`: one of `ACTION_START`, `ACTION_SUCCESS`, or `ACTION_ERROR`. Setting this field is what tells the async middleware to handle this action as an async action.
264 | * `error`: an error that will be set on the async state object (see below). This can be whatever you want as long as your component knows how to consume it (e.g. an error response from your API, a string respresentation of an error...).
265 | * `uniqueId`: the unique ID used to track multiple inflight actions of the same type.
266 |
267 | ### `getAsyncState(state, actionType, [id])`
268 |
269 | Returns an object of form `{loading, error}` representing the current state.
270 |
271 | ### `resetAction(type, {all, uniqueId})`
272 |
273 | Action creator that will reset action state for a given action type.
274 |
--------------------------------------------------------------------------------
/example/LoadingWrapper.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | const LoadingWrapper = React.createClass({
4 | propTypes: {
5 | loadingState: React.PropTypes.object.isRequired,
6 | onRetry: React.PropTypes.func.isRequired,
7 | children: React.PropTypes.func.isRequired,
8 | isHydrated: React.PropTypes.bool.isRequired,
9 | },
10 |
11 | render() {
12 | if (this.props.loadingState.error) {
13 | const error = this.props.loadingState.error.message;
14 |
15 | return (
16 |
17 |
Error loading: {error}.{' '}
18 |
19 | Retry?
20 |
21 |
22 | );
23 |
24 | } else if (this.props.loading || !this.props.isHydrated) {
25 | // If the action is loading new data, or if there's nothing hydrated, show loading state
26 | return (
27 |
28 | Loading...
29 |
30 | );
31 |
32 | } else {
33 | return this.props.children();
34 |
35 | }
36 | }
37 | });
38 |
39 | export default LoadingWrapper;
40 |
--------------------------------------------------------------------------------
/example/getJson.js:
--------------------------------------------------------------------------------
1 | /*
2 | * This example shows how to create a generic, reusable action creator with redux-happy-async.
3 | *
4 | * An example action creator built on this might look like:
5 | *
6 | * export function getUser(username) {
7 | * return async function(dispatch) {
8 | * await getJson(`https://api.github.com/users/${username}`, {
9 | * dispatch,
10 | * type: GET_USER,
11 | * payload: {username},
12 | * });
13 | * };
14 | * }
15 | */
16 |
17 | import {
18 | ACTION_START,
19 | ACTION_SUCCESS,
20 | ACTION_ERROR,
21 | } from 'redux-happy-async';
22 |
23 | export default async function getJson(url, {dispatch, type, payload}) {
24 | dispatch({
25 | asyncStatus: ACTION_START,
26 | type,
27 | ...payload
28 | });
29 |
30 | const resp = await window.fetch(url);
31 |
32 | if (resp.status !== 200) {
33 | const respText = await resp.text();
34 |
35 | let error;
36 | try {
37 | error = JSON.parse(respText);
38 | } catch(err) {
39 | error = respText;
40 | }
41 |
42 | dispatch({
43 | asyncStatus: ACTION_ERROR,
44 | type,
45 | error,
46 | ...payload
47 | });
48 | return;
49 | }
50 |
51 | const responseJson = await resp.json();
52 |
53 | dispatch({
54 | asyncStatus: ACTION_SUCCESS,
55 | type,
56 | resp: responseJson,
57 | ...payload,
58 | });
59 | }
60 |
61 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "redux-happy-async",
3 | "version": "0.0.4",
4 | "description": "async state with less boilerplate",
5 | "main": "lib/index.js",
6 | "files": [
7 | "lib"
8 | ],
9 | "scripts": {
10 | "test": "mocha --no-color --compilers js:babel-register src/**/*.spec.js",
11 | "build": "rimraf lib && babel src --out-dir lib --ignore *.spec.js",
12 | "prepublish": "npm run build"
13 | },
14 | "author": "Thomas Boyt ",
15 | "repository": "https://github.com/thomasboyt/redux-happy-async",
16 | "license": "MIT",
17 | "dependencies": {
18 | "immutable": "^3.7.6"
19 | },
20 | "devDependencies": {
21 | "babel": "^6.3.26",
22 | "babel-cli": "^6.4.5",
23 | "babel-core": "^6.4.5",
24 | "babel-eslint": "^5.0.0-beta6",
25 | "babel-preset-es2015": "^6.3.13",
26 | "babel-register": "^6.4.3",
27 | "eslint": "^1.10.3",
28 | "expect": "^1.13.4",
29 | "mocha": "^2.3.4",
30 | "redux": "^3.0.6",
31 | "rimraf": "^2.5.1"
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/src/AsyncState.js:
--------------------------------------------------------------------------------
1 | import I from 'immutable';
2 |
3 | const AsyncState = I.Record({
4 | loaded: false,
5 | loading: null,
6 | error: null,
7 | });
8 |
9 | export default AsyncState;
10 |
--------------------------------------------------------------------------------
/src/__tests__/index.spec.js:
--------------------------------------------------------------------------------
1 | import expect from 'expect';
2 |
3 | import I from 'immutable';
4 | import {createStore, combineReducers, applyMiddleware} from 'redux';
5 |
6 | import {
7 | asyncReducer,
8 | asyncMiddleware,
9 | getAsyncState,
10 | ACTION_START,
11 | ACTION_SUCCESS
12 | } from '../';
13 |
14 | const initialState = I.Map();
15 |
16 | const LOAD_TODOS = 'LOAD_TODOS';
17 | const COMPLETE_TODO = 'COMPLETE_TODO';
18 |
19 | const mockTodos = [
20 | {id: 1, text: 'improve async boilerplate in redux', complete: false},
21 | {id: 2, text: 'fix javascript', complete: false}
22 | ];
23 |
24 | function todoReducer(state=initialState, action) {
25 | switch (action.type) {
26 | case LOAD_TODOS:
27 | return state.set('todos', I.Map(action.todos.map((todo) => [todo.id, I.Map(todo)])));
28 | case COMPLETE_TODO:
29 | return state.setIn(['todos', action.id, 'complete'], true);
30 | default:
31 | return state;
32 | }
33 | }
34 |
35 | describe('async reducer', () => {
36 | let store;
37 |
38 | beforeEach(() => {
39 | store = applyMiddleware(asyncMiddleware)(createStore)(combineReducers({
40 | async: asyncReducer,
41 | todos: todoReducer
42 | }));
43 | });
44 |
45 | it('is updated when an async action is triggered', () => {
46 | store.dispatch({
47 | type: LOAD_TODOS,
48 | asyncStatus: ACTION_START,
49 | });
50 |
51 | expect(getAsyncState(store.getState(), LOAD_TODOS).loading).toBe(true);
52 |
53 | store.dispatch({
54 | type: LOAD_TODOS,
55 | asyncStatus: ACTION_SUCCESS,
56 | todos: mockTodos,
57 | });
58 |
59 | expect(getAsyncState(store.getState(), LOAD_TODOS).loading).toBe(false);
60 | expect(store.getState().todos.get('todos')).toExist();
61 | });
62 |
63 | describe('uniqueId', () => {
64 | it('updates the correct path', () => {
65 | const id = 1;
66 |
67 | store.dispatch({
68 | type: LOAD_TODOS,
69 | todos: mockTodos,
70 | });
71 |
72 | expect(store.getState().todos.getIn(['todos', id, 'complete'])).toBe(false);
73 |
74 | store.dispatch({
75 | type: COMPLETE_TODO,
76 | asyncStatus: ACTION_START,
77 | uniqueId: id,
78 | });
79 |
80 | expect(getAsyncState(store.getState(), COMPLETE_TODO, id).loading).toBe(true);
81 |
82 | store.dispatch({
83 | type: COMPLETE_TODO,
84 | asyncStatus: ACTION_SUCCESS,
85 | uniqueId: id,
86 | id,
87 | });
88 |
89 | expect(getAsyncState(store.getState(), COMPLETE_TODO, id).loading).toBe(false);
90 | expect(store.getState().todos.getIn(['todos', id, 'complete'])).toBe(true);
91 | });
92 | });
93 | });
94 |
--------------------------------------------------------------------------------
/src/asyncMiddleware.js:
--------------------------------------------------------------------------------
1 | import {
2 | ASYNC_UPDATE,
3 | ACTION_SUCCESS,
4 | } from './asyncReducer';
5 |
6 | export default function asyncMiddleware() {
7 | return (next) => {
8 | return (action) => {
9 | if (action.asyncStatus) {
10 | let res;
11 | if (action.asyncStatus === ACTION_SUCCESS) {
12 | res = next(action);
13 | }
14 |
15 | next({
16 | type: ASYNC_UPDATE,
17 | originalAction: action,
18 | });
19 |
20 | return res;
21 |
22 | } else {
23 | return next(action);
24 | }
25 | };
26 | };
27 | }
28 |
--------------------------------------------------------------------------------
/src/asyncReducer.js:
--------------------------------------------------------------------------------
1 | import I from 'immutable';
2 | import AsyncState from './AsyncState';
3 |
4 | export const ACTION_START = 'START';
5 | export const ACTION_SUCCESS = 'SUCCESS';
6 | export const ACTION_ERROR = 'ERROR';
7 | export const ACTION_RESET = 'RESET';
8 | export const ASYNC_UPDATE = 'ASYNC_UPDATE';
9 |
10 | const initialState = I.Map();
11 |
12 | function getKeyPath(action) {
13 | if (action.uniqueId !== undefined) {
14 | const id = action.uniqueId;
15 | return [action.type, id];
16 | }
17 |
18 | return [action.type];
19 | }
20 |
21 | function updateAsyncState(action, state) {
22 | const keyPath = getKeyPath(action);
23 |
24 | if (action.asyncStatus === ACTION_START) {
25 | return state.setIn(keyPath, new AsyncState({
26 | loading: true,
27 | }));
28 |
29 | } else if (action.asyncStatus === ACTION_ERROR) {
30 | if (!action.error) {
31 | throw new Error(`${action.type} was triggered with an error status but no \`error\` field was passed`);
32 | }
33 |
34 | return state
35 | .setIn([...keyPath, 'loading'], false)
36 | .setIn([...keyPath, 'loaded'], false)
37 | .setIn([...keyPath, 'error'], action.error);
38 |
39 | } else if (action.asyncStatus === ACTION_SUCCESS) {
40 | return state
41 | .setIn([...keyPath, 'loading'], false)
42 | .setIn([...keyPath, 'loaded'], true)
43 | .setIn([...keyPath, 'error'], null);
44 |
45 | } else if (action.asyncStatus === ACTION_RESET) {
46 | if (action.all === true) {
47 | // reset all action states for this type
48 | const states = state.get(action.type);
49 |
50 | if (states) {
51 | return state.set(action.type, states.map((actionState) =>
52 | actionState
53 | .set('loading', false)
54 | .set('loaded', false)
55 | .set('error', null)
56 | ));
57 |
58 | } else {
59 | return state;
60 | }
61 | }
62 |
63 | return state
64 | .setIn([...keyPath, 'loading'], false)
65 | .setIn([...keyPath, 'loaded'], false)
66 | .setIn([...keyPath, 'error'], null);
67 | }
68 |
69 | throw new Error(`Async action ${action.type} triggered with unknown status ${action.status}`);
70 | }
71 |
72 | export default function asyncReducer(state=initialState, action) {
73 | switch (action.type) {
74 | case ASYNC_UPDATE:
75 | return updateAsyncState(action.originalAction, state);
76 | default:
77 | return state;
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/getAsyncState.js:
--------------------------------------------------------------------------------
1 | import AsyncState from './AsyncState';
2 |
3 | export default function getAsyncState(state, type, id) {
4 | let path;
5 |
6 | if (id !== undefined) {
7 | path = [type, id];
8 | } else {
9 | path = [type];
10 | }
11 |
12 | const asyncState = state.async.getIn(path);
13 |
14 | if (!asyncState) {
15 | return new AsyncState();
16 | }
17 |
18 | return asyncState;
19 | }
20 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import asyncReducer, {
2 | ACTION_START,
3 | ACTION_SUCCESS,
4 | ACTION_ERROR,
5 | } from './asyncReducer';
6 | import getAsyncState from './getAsyncState';
7 | import asyncMiddleware from './asyncMiddleware';
8 | import resetAction from './resetAction';
9 |
10 | export {
11 | asyncReducer,
12 | getAsyncState,
13 | asyncMiddleware,
14 | ACTION_START,
15 | ACTION_SUCCESS,
16 | ACTION_ERROR,
17 | resetAction,
18 | };
19 |
--------------------------------------------------------------------------------
/src/resetAction.js:
--------------------------------------------------------------------------------
1 | import {ACTION_RESET} from './asyncReducer';
2 |
3 | export default function resetAction(type, {all, uniqueId}) {
4 | return {
5 | asyncStatus: ACTION_RESET,
6 | type,
7 | all,
8 | uniqueId,
9 | };
10 | }
11 |
--------------------------------------------------------------------------------