├── .gitignore ├── .npmignore ├── LICENSE ├── README.md ├── example ├── README.md ├── actions │ └── index.js ├── client.js ├── components │ ├── Explore.js │ ├── List.js │ ├── Repo.js │ └── User.js ├── containers │ ├── App.js │ ├── RepoPage.js │ ├── Root.js │ └── UserPage.js ├── index.html ├── index.js ├── middleware │ └── api.js ├── package.json ├── reducers │ ├── index.js │ └── paginate.js ├── server.js └── store │ └── index.js ├── package.json └── src └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | pids 10 | logs 11 | results 12 | npm-debug.log 13 | node_modules 14 | /lib 15 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | lib-cov 2 | *.seed 3 | *.log 4 | *.csv 5 | *.dat 6 | *.out 7 | *.pid 8 | *.gz 9 | pids 10 | logs 11 | results 12 | npm-debug.log 13 | node_modules 14 | /src 15 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Forbes Lindesay 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # redux-wait 2 | 3 | A helper to let you wait for redux actions to be processed in a universal app. 4 | 5 | [![Build Status](https://img.shields.io/travis/ForbesLindesay/redux-wait/master.svg)](https://travis-ci.org/ForbesLindesay/redux-wait) 6 | [![Dependency Status](https://img.shields.io/david/ForbesLindesay/redux-wait.svg)](https://david-dm.org/ForbesLindesay/redux-wait) 7 | [![NPM version](https://img.shields.io/npm/v/redux-wait.svg)](https://www.npmjs.org/package/redux-wait) 8 | 9 | ## Installation 10 | 11 | npm install redux-wait 12 | 13 | 14 | ## Usage 15 | 16 | ### 1. Replace `applyMiddleware` with `redux-await` 17 | 18 | 19 | ```diff 20 | - var applyMiddleware = require('redux').applyMiddleware; 21 | + var applyMiddleware = require('redux-wait'); 22 | ``` 23 | 24 | This will add an extra method to your store called `store.renderToString`. 25 | 26 | ### 2. Ensure all middleware returns a promise (or runs synchronously) 27 | 28 | If you're not using any custom middleware and you're not using redux-thunk, you can skip this step. 29 | 30 | If you are writing asynchronous middleware, you need to make sure your middleware returns a promise 31 | that is only resolved once it has finished processing the action. [redux-promise](https://github.com/acdlite/redux-promise) is a perfect example of how to do this. 32 | 33 | If you are using redux-thunk, you can only use it synchronously, or it will break server rendering. 34 | 35 | Good: 36 | 37 | ```js 38 | // action creator 39 | function loadUser(login) { 40 | return (dispatch, getState) => { 41 | const user = getState().entities.users[login]; 42 | if (user) { 43 | return null; 44 | } 45 | 46 | return dispatch(fetchUser(login)); 47 | }; 48 | } 49 | ``` 50 | 51 | Bad: 52 | 53 | ```js 54 | // action creator 55 | function loadUser(login) { 56 | return (dispatch, getState) => { 57 | const user = getState().entities.users[login]; 58 | if (user) { 59 | return null; 60 | } 61 | 62 | $.getJson('/user/' + login, function (data) { 63 | // The server won't wait for this action :( 64 | dispatch({type: 'LOADED_USER', user: data}); 65 | }); 66 | }; 67 | } 68 | ``` 69 | 70 | ### 3. Ensure that actions to load data are not fired if the data is alreay loading 71 | 72 | Good: 73 | 74 | ```js 75 | componentWillMount() { 76 | if (!this.props.isLoading && !this.props.user) { 77 | this.props.dispatch(loadUser()); 78 | } 79 | } 80 | ``` 81 | 82 | Bad: 83 | 84 | ```js 85 | componentWillMount() { 86 | if (!this.props.user) { 87 | this.props.dispatch(loadUser()); 88 | } 89 | } 90 | ``` 91 | 92 | ### 4. Render the page in your server 93 | 94 | ```js 95 | // N.B. `createStore` is the result of using redux-wait instead of Redux.applyMiddleware 96 | let store = createStore(); 97 | let element = ; 98 | store.renderToString(React, element).done(function (html) { 99 | res.send( 100 | indexHtml.replace( 101 | '{{content}}', 102 | html 103 | ).replace( 104 | '{{state}}', 105 | stringify(store.getState()) 106 | ) 107 | ); 108 | }, next); 109 | ``` 110 | 111 | ## License 112 | 113 | MIT 114 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ### 1. Ensure "store" is passed in as a property to the root component 4 | 5 | `history` is also passed in as a property, this was already the case so is not marked as a diff. 6 | 7 | **N.B.** We are also including an INITIAL_STATE variable here, which we will populate later. 8 | 9 | /containers/Root.js 10 | 11 | ```diff 12 | import React, { Component } from 'react'; 13 | import { Provider } from 'react-redux'; 14 | import { Router, Route } from 'react-router'; 15 | - import configureStore from '../store/configureStore'; 16 | import App from './App'; 17 | import UserPage from './UserPage'; 18 | import RepoPage from './RepoPage'; 19 | 20 | - const store = configureStore(); 21 | 22 | export default class Root extends Component { 23 | render() { 24 | return ( 25 |
26 | - 27 | + 28 | {() => 29 | 30 | 31 | 33 | 35 | 36 | 37 | } 38 | 39 |
40 | ); 41 | } 42 | } 43 | ``` 44 | 45 | /client.js 46 | 47 | ```diff 48 | import 'babel-core/polyfill'; 49 | import React from 'react'; 50 | import BrowserHistory from 'react-router/lib/BrowserHistory'; 51 | import Root from './containers/Root'; 52 | + import createStore from './store'; 53 | 54 | + const store = createStore(INITIAL_STATE); 55 | 56 | React.render( 57 | - , 58 | + , 59 | document.getElementById('root') 60 | ); 61 | ``` 62 | 63 | ### 2. Ensure in-flight requests are not duplicated 64 | 65 | The code already ensured that when data was already loaded, it was not re-requested. Unfortunately, 66 | it made a request for data on every `componentWillMount` call, even if the data had already been requested 67 | (but not loaded). This change keeps track of currently in-progress requests and skips the action if a request 68 | is already in flight. 69 | 70 | **N.B.** This asynchronous middleware returns a promise that is picked up by redux-wait. 71 | 72 | /middleware/api.js 73 | 74 | ```diff 75 | import { Schema, arrayOf, normalize } from 'normalizr'; 76 | import { camelizeKeys } from 'humps'; 77 | import 'isomorphic-fetch'; 78 | 79 | + let inFlightRequests = {}; 80 | 81 | ... 82 | 83 | 84 | /** 85 | * A Redux middleware that interprets actions with CALL_API info specified. 86 | * Performs the call and promises when such actions are dispatched. 87 | */ 88 | export default store => next => action => { 89 | const callAPI = action[CALL_API]; 90 | if (typeof callAPI === 'undefined') { 91 | return next(action); 92 | } 93 | 94 | let { endpoint } = callAPI; 95 | const { schema, types, bailout } = callAPI; 96 | 97 | if (typeof endpoint === 'function') { 98 | endpoint = endpoint(store.getState()); 99 | } 100 | 101 | ... 102 | 103 | + if (inFlightRequests[endpoint]) { 104 | + return Promise.resolve(); 105 | + } 106 | + inFlightRequests[endpoint] = true; 107 | 108 | const [requestType, successType, failureType] = types; 109 | next(actionWith({ type: requestType })); 110 | 111 | return callApi(endpoint, schema).then( 112 | response => { 113 | next(actionWith({ 114 | response, 115 | type: successType 116 | })) 117 | + delete inFlightRequests[endpoint]; 118 | }, 119 | error => { 120 | next(actionWith({ 121 | type: failureType, 122 | error: error.message || 'Something bad happened' 123 | })) 124 | + delete inFlightRequests[endpoint]; 125 | + // on the server side, respond with 500 when an error happens 126 | + throw new Error(error.message || 'Something bad happened'); 127 | } 128 | ); 129 | }; 130 | ``` 131 | 132 | ### 3. Add method to store to support async rendering 133 | 134 | Replacing the build in `applyMiddleware` from `redux` with the `applyMiddleware` from `redux-wait` adds an additional `.renderToString` method to the store, which we will make use of later. 135 | 136 | **N.B.** if you use redux-thunk instead of promises for async it will break server side rendering. It can be used for conditional actions though (which is what it is used for in this project). 137 | 138 | /store/index.js 139 | 140 | ```diff 141 | - import { createStore, combineReducers, applyMiddleware } from 'redux'; 142 | + import { createStore, combineReducers } from 'redux'; 143 | + import applyMiddleware from 'redux-wait'; 144 | import thunkMiddleware from 'redux-thunk'; 145 | import apiMiddleware from '../middleware/api'; 146 | import loggerMiddleware from 'redux-logger'; 147 | import * as reducers from '../reducers'; 148 | 149 | const reducer = combineReducers(reducers); 150 | const createStoreWithMiddleware = applyMiddleware( 151 | thunkMiddleware, 152 | apiMiddleware, 153 | loggerMiddleware 154 | )(createStore); 155 | 156 | /** 157 | * Creates a preconfigured store for this example. 158 | */ 159 | export default function configureStore(initialState) { 160 | return createStoreWithMiddleware(reducer, initialState); 161 | } 162 | ``` 163 | 164 | ### 4. Add placeholders for "content" and "initial state" in the HTML template 165 | 166 | /index.html 167 | 168 | ```diff 169 | 170 | 171 | Redux real-world example 172 | 173 | 174 | -
175 | +
{{content}}
176 | + 177 | 178 | 179 | 180 | ``` 181 | 182 | ### 5. Render the app server side 183 | 184 | `store.renderToString` is added by `redux-wait`. It returns a promise for the string representation of 185 | an element, but waits until there are no more pending actions before resolving. 186 | 187 | /server.js 188 | 189 | ```diff 190 | 'use strict'; 191 | 192 | + import fs from 'fs'; 193 | import express from 'express'; 194 | import browserify from 'browserify-middleware'; 195 | import React from 'react'; 196 | + import stringify from 'js-stringify'; 197 | + import Root from './containers/Root'; 198 | + import MemoryHistory from 'react-router/lib/MemoryHistory'; 199 | + import createStore from './store'; 200 | 201 | + const store = createStore(); 202 | 203 | const app = express(); 204 | 205 | + const indexHtml = fs.readFileSync(__dirname + '/index.html', 'utf8'); 206 | 207 | app.get('/static/bundle.js', browserify( 208 | __dirname + '/client.js', 209 | { transform: [require('babelify')] } 210 | )); 211 | app.use(function (req, res, next) { 212 | if (req.path === '/favicon.ico') return next(); 213 | - res.sendFile(__dirname + '/index.html'); 214 | + let store = createStore(); 215 | + let element = ; 216 | + store.renderToString(React, element).done(function (html) { 217 | + res.send(indexHtml.replace(/\{\{([a-z]*)\}\}/g, function (_, name) { 218 | + if (name === 'content') return html; 219 | + if (name === 'state') return stringify(store.getState()); 220 | + return _; 221 | + })); 222 | + }, next); 223 | }); 224 | app.listen(3000, function (err) { 225 | if (err) { 226 | console.log(err); 227 | } 228 | 229 | console.log('Listening at localhost:3000'); 230 | }); 231 | ``` 232 | -------------------------------------------------------------------------------- /example/actions/index.js: -------------------------------------------------------------------------------- 1 | import { CALL_API, Schemas } from '../middleware/api'; 2 | 3 | export const USER_REQUEST = 'USER_REQUEST'; 4 | export const USER_SUCCESS = 'USER_SUCCESS'; 5 | export const USER_FAILURE = 'USER_FAILURE'; 6 | /** 7 | * Fetches a single user from Github API. 8 | * Relies on the custom API middleware defined in ../middleware/api.js. 9 | */ 10 | function fetchUser(login) { 11 | return { 12 | [CALL_API]: { 13 | types: [USER_REQUEST, USER_SUCCESS, USER_FAILURE], 14 | endpoint: `users/${login}`, 15 | schema: Schemas.USER 16 | } 17 | }; 18 | } 19 | /** 20 | * Fetches a single user from Github API unless it is cached. 21 | * Relies on Redux Thunk middleware. 22 | */ 23 | export function loadUser(login, requiredFields = []) { 24 | return (dispatch, getState) => { 25 | const user = getState().entities.users[login]; 26 | if (user && requiredFields.every(key => user.hasOwnProperty(key))) { 27 | return null; 28 | } 29 | 30 | return dispatch(fetchUser(login)); 31 | }; 32 | } 33 | 34 | export const REPO_REQUEST = 'REPO_REQUEST'; 35 | export const REPO_SUCCESS = 'REPO_SUCCESS'; 36 | export const REPO_FAILURE = 'REPO_FAILURE'; 37 | /** 38 | * Fetches a single repository from Github API. 39 | * Relies on the custom API middleware defined in ../middleware/api.js. 40 | */ 41 | function fetchRepo(fullName) { 42 | return { 43 | [CALL_API]: { 44 | types: [REPO_REQUEST, REPO_SUCCESS, REPO_FAILURE], 45 | endpoint: `repos/${fullName}`, 46 | schema: Schemas.REPO 47 | } 48 | }; 49 | } 50 | /** 51 | * Fetches a single repository from Github API unless it is cached. 52 | * Relies on Redux Thunk middleware. 53 | */ 54 | export function loadRepo(fullName, requiredFields = []) { 55 | return (dispatch, getState) => { 56 | const repo = getState().entities.repos[fullName]; 57 | if (repo && requiredFields.every(key => repo.hasOwnProperty(key))) { 58 | return null; 59 | } 60 | 61 | return dispatch(fetchRepo(fullName)); 62 | }; 63 | } 64 | 65 | export const STARRED_REQUEST = 'STARRED_REQUEST'; 66 | export const STARRED_SUCCESS = 'STARRED_SUCCESS'; 67 | export const STARRED_FAILURE = 'STARRED_FAILURE'; 68 | /** 69 | * Fetches a page of starred repos by a particular user. 70 | * Relies on the custom API middleware defined in ../middleware/api.js. 71 | */ 72 | function fetchStarred(login, nextPageUrl) { 73 | return { 74 | login, 75 | [CALL_API]: { 76 | types: [STARRED_REQUEST, STARRED_SUCCESS, STARRED_FAILURE], 77 | endpoint: nextPageUrl, 78 | schema: Schemas.REPO_ARRAY 79 | } 80 | }; 81 | } 82 | /** 83 | * Fetches a page of starred repos by a particular user. 84 | * Bails out if page is cached and user didn’t specifically request next page. 85 | * Relies on Redux Thunk middleware. 86 | */ 87 | export function loadStarred(login, nextPage) { 88 | return (dispatch, getState) => { 89 | const { 90 | nextPageUrl = `users/${login}/starred`, 91 | pageCount = 0 92 | } = getState().pagination.starredByUser[login] || {}; 93 | 94 | if (pageCount > 0 && !nextPage) { 95 | return null; 96 | } 97 | 98 | return dispatch(fetchStarred(login, nextPageUrl)); 99 | }; 100 | } 101 | 102 | 103 | export const STARGAZERS_REQUEST = 'STARGAZERS_REQUEST'; 104 | export const STARGAZERS_SUCCESS = 'STARGAZERS_SUCCESS'; 105 | export const STARGAZERS_FAILURE = 'STARGAZERS_FAILURE'; 106 | /** 107 | * Fetches a page of stargazers for a particular repo. 108 | * Relies on the custom API middleware defined in ../middleware/api.js. 109 | */ 110 | function fetchStargazers(fullName, nextPageUrl) { 111 | return { 112 | fullName, 113 | [CALL_API]: { 114 | types: [STARGAZERS_REQUEST, STARGAZERS_SUCCESS, STARGAZERS_FAILURE], 115 | endpoint: nextPageUrl, 116 | schema: Schemas.USER_ARRAY 117 | } 118 | }; 119 | } 120 | /** 121 | * Fetches a page of stargazers for a particular repo. 122 | * Bails out if page is cached and user didn’t specifically request next page. 123 | * Relies on Redux Thunk middleware. 124 | */ 125 | export function loadStargazers(fullName, nextPage) { 126 | return (dispatch, getState) => { 127 | const { 128 | nextPageUrl = `repos/${fullName}/stargazers`, 129 | pageCount = 0 130 | } = getState().pagination.stargazersByRepo[fullName] || {}; 131 | 132 | if (pageCount > 0 && !nextPage) { 133 | return null; 134 | } 135 | 136 | return dispatch(fetchStargazers(fullName, nextPageUrl)); 137 | }; 138 | } 139 | 140 | export const RESET_ERROR_MESSAGE = 'RESET_ERROR_MESSAGE'; 141 | /** 142 | * Resets the currently visible error message. 143 | */ 144 | export function resetErrorMessage() { 145 | return { 146 | type: RESET_ERROR_MESSAGE 147 | }; 148 | } 149 | -------------------------------------------------------------------------------- /example/client.js: -------------------------------------------------------------------------------- 1 | import 'babel-core/polyfill'; 2 | import React from 'react'; 3 | import BrowserHistory from 'react-router/lib/BrowserHistory'; 4 | import Root from './containers/Root'; 5 | import createStore from './store'; 6 | 7 | const store = createStore(INITIAL_STATE); 8 | 9 | React.render( 10 | , 11 | document.getElementById('root') 12 | ); 13 | -------------------------------------------------------------------------------- /example/components/Explore.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes, findDOMNode } from 'react'; 2 | 3 | const GITHUB_REPO = 'https://github.com/gaearon/redux'; 4 | 5 | export default class Explore extends Component { 6 | constructor(props) { 7 | super(props); 8 | this.handleKeyUp = this.handleKeyUp.bind(this); 9 | this.handleGoClick = this.handleGoClick.bind(this); 10 | } 11 | 12 | getInputValue() { 13 | return findDOMNode(this.refs.input).value; 14 | } 15 | 16 | setInputValue(val) { 17 | // Generally mutating DOM is a bad idea in React components, 18 | // but doing this for a single uncontrolled field is less fuss 19 | // than making it controlled and maintaining a state for it. 20 | findDOMNode(this.refs.input).value = val; 21 | } 22 | 23 | componentWillReceiveProps(nextProps) { 24 | if (nextProps.value !== this.props.value) { 25 | this.setInputValue(nextProps.value); 26 | } 27 | } 28 | 29 | render() { 30 | return ( 31 |
32 |

Type a username or repo full name and hit 'Go':

33 | 37 | 40 |

41 | Code on Github. 42 |

43 |
44 | ); 45 | } 46 | 47 | handleKeyUp(e) { 48 | if (e.keyCode === 13) { 49 | this.handleGoClick(); 50 | } 51 | } 52 | 53 | handleGoClick() { 54 | this.props.onChange(this.getInputValue()) 55 | } 56 | } 57 | 58 | Explore.propTypes = { 59 | value: PropTypes.string.isRequired, 60 | onChange: PropTypes.func.isRequired 61 | }; 62 | -------------------------------------------------------------------------------- /example/components/List.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | 3 | export default class List extends Component { 4 | render() { 5 | const { 6 | isFetching, nextPageUrl, pageCount, 7 | items, renderItem, loadingLabel 8 | } = this.props; 9 | 10 | const isEmpty = items.length === 0; 11 | if (isEmpty && isFetching) { 12 | return

{loadingLabel}

; 13 | } 14 | 15 | const isLastPage = !nextPageUrl; 16 | if (isEmpty && isLastPage) { 17 | return

Nothing here!

; 18 | } 19 | 20 | return ( 21 |
22 | {items.map(renderItem)} 23 | {pageCount > 0 && !isLastPage && this.renderLoadMore()} 24 |
25 | ); 26 | } 27 | 28 | renderLoadMore() { 29 | const { isFetching, onLoadMoreClick } = this.props; 30 | return ( 31 | 36 | ); 37 | } 38 | } 39 | 40 | List.propTypes = { 41 | loadingLabel: PropTypes.string.isRequired, 42 | isFetching: PropTypes.bool.isRequired, 43 | onLoadMoreClick: PropTypes.func.isRequired, 44 | nextPageUrl: PropTypes.string 45 | }; 46 | 47 | List.defaultProps = { 48 | isFetching: true, 49 | loadingLabel: 'Loading...' 50 | }; 51 | -------------------------------------------------------------------------------- /example/components/Repo.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { Link } from 'react-router'; 3 | 4 | export default class Repo extends Component { 5 | 6 | render() { 7 | const { repo, owner } = this.props; 8 | const { login } = owner; 9 | const { name, description } = repo; 10 | 11 | return ( 12 |
13 |

14 | 15 | {name} 16 | 17 | {' by '} 18 | 19 | {login} 20 | 21 |

22 | {description && 23 |

{description}

24 | } 25 |
26 | ); 27 | } 28 | } 29 | 30 | Repo.propTypes = { 31 | repo: PropTypes.shape({ 32 | name: PropTypes.string.isRequired, 33 | description: PropTypes.string 34 | }).isRequired, 35 | owner: PropTypes.shape({ 36 | login: PropTypes.string.isRequired 37 | }).isRequired 38 | }; 39 | -------------------------------------------------------------------------------- /example/components/User.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { Link } from 'react-router'; 3 | 4 | export default class User extends Component { 5 | render() { 6 | const { login, avatarUrl, name } = this.props.user; 7 | 8 | return ( 9 |
10 | 11 | 12 |

13 | {login} {name && ({name})} 14 |

15 | 16 |
17 | ); 18 | } 19 | } 20 | 21 | User.propTypes = { 22 | user: PropTypes.shape({ 23 | login: PropTypes.string.isRequired, 24 | avatarUrl: PropTypes.string.isRequired, 25 | name: PropTypes.string 26 | }).isRequired 27 | }; 28 | -------------------------------------------------------------------------------- /example/containers/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import Explore from '../components/Explore'; 4 | import { resetErrorMessage } from '../actions'; 5 | 6 | class App extends Component { 7 | constructor(props) { 8 | super(props); 9 | this.handleChange = this.handleChange.bind(this); 10 | this.handleDismissClick = this.handleDismissClick.bind(this); 11 | } 12 | 13 | render() { 14 | // Injected by React Router 15 | const { location, children } = this.props; 16 | const { pathname } = location; 17 | const value = pathname.substring(1); 18 | 19 | return ( 20 |
21 | 23 |
24 | {this.renderErrorMessage()} 25 | {children} 26 |
27 | ); 28 | } 29 | 30 | renderErrorMessage() { 31 | const { errorMessage } = this.props; 32 | if (!errorMessage) { 33 | return null; 34 | } 35 | 36 | return ( 37 |

38 | {errorMessage} 39 | {' '} 40 | ( 42 | Dismiss 43 | ) 44 |

45 | ); 46 | } 47 | 48 | handleDismissClick(e) { 49 | this.props.resetErrorMessage(); 50 | e.preventDefault(); 51 | } 52 | 53 | handleChange(nextValue) { 54 | // Available thanks to contextTypes below 55 | const { router } = this.context; 56 | router.transitionTo(`/${nextValue}`); 57 | } 58 | } 59 | 60 | App.propTypes = { 61 | errorMessage: PropTypes.string, 62 | location: PropTypes.shape({ 63 | pathname: PropTypes.string.isRequired 64 | }), 65 | params: PropTypes.shape({ 66 | userLogin: PropTypes.string, 67 | repoName: PropTypes.string 68 | }).isRequired 69 | }; 70 | 71 | App.contextTypes = { 72 | router: PropTypes.object.isRequired 73 | }; 74 | 75 | function mapStateToProps(state) { 76 | return { 77 | errorMessage: state.errorMessage 78 | }; 79 | } 80 | 81 | export default connect( 82 | mapStateToProps, 83 | { resetErrorMessage } 84 | )(App); 85 | -------------------------------------------------------------------------------- /example/containers/RepoPage.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { loadRepo, loadStargazers } from '../actions'; 4 | import Repo from '../components/Repo'; 5 | import User from '../components/User'; 6 | import List from '../components/List'; 7 | 8 | function loadData(props) { 9 | const { fullName } = props; 10 | props.loadRepo(fullName, ['description']); 11 | props.loadStargazers(fullName); 12 | } 13 | 14 | class RepoPage extends Component { 15 | constructor(props) { 16 | super(props); 17 | this.renderUser = this.renderUser.bind(this); 18 | this.handleLoadMoreClick = this.handleLoadMoreClick.bind(this); 19 | } 20 | 21 | componentWillMount() { 22 | loadData(this.props); 23 | } 24 | 25 | componentWillReceiveProps(nextProps) { 26 | if (nextProps.fullName !== this.props.fullName) { 27 | loadData(nextProps); 28 | } 29 | } 30 | 31 | render() { 32 | const { repo, owner, name } = this.props; 33 | if (!repo || !owner) { 34 | return

Loading {name} details...

; 35 | } 36 | 37 | const { stargazers, stargazersPagination } = this.props; 38 | return ( 39 |
40 | 42 |
43 | 48 |
49 | ); 50 | } 51 | 52 | renderUser(user) { 53 | return ( 54 | 56 | ); 57 | } 58 | 59 | handleLoadMoreClick() { 60 | this.props.loadStargazers(this.props.fullName, true); 61 | } 62 | } 63 | 64 | RepoPage.propTypes = { 65 | repo: PropTypes.object, 66 | fullName: PropTypes.string.isRequired, 67 | name: PropTypes.string.isRequired, 68 | stargazers: PropTypes.array.isRequired, 69 | stargazersPagination: PropTypes.object, 70 | loadRepo: PropTypes.func.isRequired, 71 | loadStargazers: PropTypes.func.isRequired 72 | }; 73 | 74 | function mapStateToProps(state) { 75 | return { 76 | entities: state.entities, 77 | stargazersByRepo: state.pagination.stargazersByRepo 78 | }; 79 | } 80 | 81 | function mergeProps(stateProps, dispatchProps, ownProps) { 82 | const { entities, stargazersByRepo } = stateProps; 83 | const { login, name } = ownProps.params; 84 | 85 | const fullName = `${login}/${name}`; 86 | const repo = entities.repos[fullName]; 87 | const owner = entities.users[login]; 88 | 89 | const stargazersPagination = stargazersByRepo[fullName] || { ids: [] }; 90 | const stargazers = stargazersPagination.ids.map(id => entities.users[id]); 91 | 92 | return Object.assign({}, dispatchProps, { 93 | fullName, 94 | name, 95 | repo, 96 | owner, 97 | stargazers, 98 | stargazersPagination 99 | }); 100 | } 101 | 102 | export default connect( 103 | mapStateToProps, 104 | { loadRepo, loadStargazers }, 105 | mergeProps 106 | )(RepoPage); 107 | -------------------------------------------------------------------------------- /example/containers/Root.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import { Router, Route } from 'react-router'; 4 | import App from './App'; 5 | import UserPage from './UserPage'; 6 | import RepoPage from './RepoPage'; 7 | 8 | export default class Root extends Component { 9 | render() { 10 | return ( 11 |
12 | 13 | {() => 14 | 15 | 16 | 18 | 20 | 21 | 22 | } 23 | 24 |
25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /example/containers/UserPage.js: -------------------------------------------------------------------------------- 1 | import React, { Component, PropTypes } from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { loadUser, loadStarred } from '../actions'; 4 | import User from '../components/User'; 5 | import Repo from '../components/Repo'; 6 | import List from '../components/List'; 7 | import zip from 'lodash/array/zip'; 8 | 9 | function loadData(props) { 10 | const { login } = props; 11 | props.loadUser(login, ['name']); 12 | props.loadStarred(login); 13 | } 14 | 15 | class UserPage extends Component { 16 | constructor(props) { 17 | super(props); 18 | this.renderRepo = this.renderRepo.bind(this); 19 | this.handleLoadMoreClick = this.handleLoadMoreClick.bind(this); 20 | } 21 | 22 | componentWillMount() { 23 | loadData(this.props); 24 | } 25 | 26 | componentWillReceiveProps(nextProps) { 27 | if (nextProps.login !== this.props.login) { 28 | loadData(nextProps); 29 | } 30 | } 31 | 32 | render() { 33 | const { user, login } = this.props; 34 | if (!user) { 35 | return

Loading {login}’s profile...

; 36 | } 37 | 38 | const { starredRepos, starredRepoOwners, starredPagination } = this.props; 39 | return ( 40 |
41 | 42 |
43 | 48 |
49 | ); 50 | } 51 | 52 | renderRepo([repo, owner]) { 53 | return ( 54 | 57 | ); 58 | } 59 | 60 | handleLoadMoreClick() { 61 | this.props.loadStarred(this.props.login, true); 62 | } 63 | } 64 | 65 | UserPage.propTypes = { 66 | login: PropTypes.string.isRequired, 67 | user: PropTypes.object, 68 | starredPagination: PropTypes.object, 69 | starredRepos: PropTypes.array.isRequired, 70 | starredRepoOwners: PropTypes.array.isRequired, 71 | loadUser: PropTypes.func.isRequired, 72 | loadStarred: PropTypes.func.isRequired 73 | }; 74 | 75 | function mapStateToProps(state) { 76 | return { 77 | entities: state.entities, 78 | starredByUser: state.pagination.starredByUser 79 | }; 80 | } 81 | 82 | function mergeProps(stateProps, dispatchProps, ownProps) { 83 | const { entities, starredByUser } = stateProps; 84 | const { login } = ownProps.params; 85 | 86 | const user = entities.users[login]; 87 | const starredPagination = starredByUser[login] || { ids: [] }; 88 | const starredRepos = starredPagination.ids.map(id => entities.repos[id]); 89 | const starredRepoOwners = starredRepos.map(repo => entities.users[repo.owner]); 90 | 91 | return Object.assign({}, dispatchProps, { 92 | login, 93 | user, 94 | starredPagination, 95 | starredRepos, 96 | starredRepoOwners 97 | }); 98 | } 99 | 100 | export default connect( 101 | mapStateToProps, 102 | { loadUser, loadStarred }, 103 | mergeProps 104 | )(UserPage); 105 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Redux real-world example 4 | 5 | 6 |
{{content}}
7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /example/index.js: -------------------------------------------------------------------------------- 1 | require('babel/register'); 2 | require('./server.js'); 3 | -------------------------------------------------------------------------------- /example/middleware/api.js: -------------------------------------------------------------------------------- 1 | import { Schema, arrayOf, normalize } from 'normalizr'; 2 | import { camelizeKeys } from 'humps'; 3 | import 'isomorphic-fetch'; 4 | 5 | let inFlightRequests = {}; 6 | 7 | /** 8 | * Extracts the next page URL from Github API response. 9 | */ 10 | function getNextPageUrl(response) { 11 | const link = response.headers.get('link'); 12 | if (!link) { 13 | return null; 14 | } 15 | 16 | const nextLink = link.split(',').filter(s => s.indexOf('rel="next"') > -1)[0]; 17 | if (!nextLink) { 18 | return null; 19 | } 20 | 21 | return nextLink.split(';')[0].slice(1, -1); 22 | } 23 | 24 | const API_ROOT = 'https://api.github.com/'; 25 | 26 | /** 27 | * Fetches an API response and normalizes the result JSON according to schema. 28 | * This makes every API response have the same shape, regardless of how nested it was. 29 | */ 30 | function callApi(endpoint, schema) { 31 | if (endpoint.indexOf(API_ROOT) === -1) { 32 | endpoint = API_ROOT + endpoint; 33 | } 34 | 35 | return fetch(endpoint) 36 | .then(response => 37 | response.json().then(json => ({ json, response})) 38 | ).then(({ json, response }) => { 39 | if (!response.ok) { 40 | return Promise.reject(json); 41 | } 42 | 43 | const camelizedJson = camelizeKeys(json); 44 | const nextPageUrl = getNextPageUrl(response) || undefined; 45 | 46 | return Object.assign({}, 47 | normalize(camelizedJson, schema), 48 | { nextPageUrl } 49 | ); 50 | }); 51 | } 52 | 53 | // We use this Normalizr schemas to transform API responses from a nested form 54 | // to a flat form where repos and users are placed in `entities`, and nested 55 | // JSON objects are replaced with their IDs. This is very convenient for 56 | // consumption by reducers, because we can easily build a normalized tree 57 | // and keep it updated as we fetch more data. 58 | 59 | // Read more about Normalizr: https://github.com/gaearon/normalizr 60 | 61 | const userSchema = new Schema('users', { 62 | idAttribute: 'login' 63 | }); 64 | 65 | const repoSchema = new Schema('repos', { 66 | idAttribute: 'fullName' 67 | }); 68 | 69 | repoSchema.define({ 70 | owner: userSchema 71 | }); 72 | 73 | /** 74 | * Schemas for Github API responses. 75 | */ 76 | export const Schemas = { 77 | USER: userSchema, 78 | USER_ARRAY: arrayOf(userSchema), 79 | REPO: repoSchema, 80 | REPO_ARRAY: arrayOf(repoSchema) 81 | }; 82 | 83 | /** 84 | * Action key that carries API call info interpreted by this Redux middleware. 85 | */ 86 | export const CALL_API = Symbol('Call API'); 87 | 88 | /** 89 | * A Redux middleware that interprets actions with CALL_API info specified. 90 | * Performs the call and promises when such actions are dispatched. 91 | */ 92 | export default store => next => action => { 93 | const callAPI = action[CALL_API]; 94 | if (typeof callAPI === 'undefined') { 95 | return next(action); 96 | } 97 | 98 | let { endpoint } = callAPI; 99 | const { schema, types, bailout } = callAPI; 100 | 101 | if (typeof endpoint === 'function') { 102 | endpoint = endpoint(store.getState()); 103 | } 104 | 105 | if (typeof endpoint !== 'string') { 106 | throw new Error('Specify a string endpoint URL.'); 107 | } 108 | if (!schema) { 109 | throw new Error('Specify one of the exported Schemas.'); 110 | } 111 | if (!Array.isArray(types) || types.length !== 3) { 112 | throw new Error('Expected an array of three action types.'); 113 | } 114 | if (!types.every(type => typeof type === 'string')) { 115 | throw new Error('Expected action types to be strings.'); 116 | } 117 | if (typeof bailout !== 'undefined' && typeof bailout !== 'function') { 118 | throw new Error('Expected bailout to either be undefined or a function.'); 119 | } 120 | 121 | if (bailout && bailout(store.getState())) { 122 | return Promise.resolve(); 123 | } 124 | 125 | function actionWith(data) { 126 | const finalAction = Object.assign({}, action, data); 127 | delete finalAction[CALL_API]; 128 | return finalAction; 129 | } 130 | 131 | if (inFlightRequests[endpoint]) { 132 | return Promise.resolve(); 133 | } 134 | inFlightRequests[endpoint] = true; 135 | 136 | const [requestType, successType, failureType] = types; 137 | next(actionWith({ type: requestType })); 138 | 139 | return callApi(endpoint, schema).then( 140 | response => { 141 | next(actionWith({ 142 | response, 143 | type: successType 144 | })) 145 | delete inFlightRequests[endpoint]; 146 | }, 147 | error => { 148 | next(actionWith({ 149 | type: failureType, 150 | error: error.message || 'Something bad happened' 151 | })) 152 | delete inFlightRequests[endpoint]; 153 | // on the server side, respond with 500 when an error happens 154 | throw new Error(error.message || 'Something bad happened'); 155 | } 156 | ); 157 | }; 158 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-real-world-example", 3 | "version": "0.0.0", 4 | "description": "Redux real-world example", 5 | "scripts": { 6 | "start": "node index.js" 7 | }, 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/rackt/redux.git" 11 | }, 12 | "license": "MIT", 13 | "bugs": { 14 | "url": "https://github.com/rackt/redux/issues" 15 | }, 16 | "homepage": "http://rackt.github.io/redux", 17 | "dependencies": { 18 | "babelify": "^6.1.3", 19 | "browserify-middleware": "^7.0.0", 20 | "express": "^4.13.3", 21 | "humps": "^0.6.0", 22 | "isomorphic-fetch": "^2.1.1", 23 | "js-stringify": "^1.0.1", 24 | "lodash": "^3.10.1", 25 | "normalizr": "^0.1.3", 26 | "react": "^0.13.3", 27 | "react-redux": "^0.8.0", 28 | "react-router": "^1.0.0-beta3", 29 | "redux": "^1.0.0-rc", 30 | "redux-logger": "0.0.3", 31 | "redux-thunk": "^0.1.0" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /example/reducers/index.js: -------------------------------------------------------------------------------- 1 | import * as ActionTypes from '../actions'; 2 | import merge from 'lodash/object/merge'; 3 | import paginate from './paginate'; 4 | import { combineReducers } from 'redux'; 5 | 6 | /** 7 | * Updates an entity cache in response to any action with response.entities. 8 | */ 9 | export function entities(state = { users: {}, repos: {} }, action) { 10 | if (action.response && action.response.entities) { 11 | return merge({}, state, action.response.entities); 12 | } 13 | 14 | return state; 15 | } 16 | 17 | /** 18 | * Updates error message to notify about the failed fetches. 19 | */ 20 | export function errorMessage(state = null, action) { 21 | const { type, error } = action; 22 | 23 | if (type === ActionTypes.RESET_ERROR_MESSAGE) { 24 | return null; 25 | } else if (error) { 26 | return action.error; 27 | } 28 | 29 | return state; 30 | } 31 | 32 | /** 33 | * Updates the pagination data for different actions. 34 | */ 35 | export const pagination = combineReducers({ 36 | starredByUser: paginate({ 37 | mapActionToKey: action => action.login, 38 | types: [ 39 | ActionTypes.STARRED_REQUEST, 40 | ActionTypes.STARRED_SUCCESS, 41 | ActionTypes.STARRED_FAILURE 42 | ] 43 | }), 44 | stargazersByRepo: paginate({ 45 | mapActionToKey: action => action.fullName, 46 | types: [ 47 | ActionTypes.STARGAZERS_REQUEST, 48 | ActionTypes.STARGAZERS_SUCCESS, 49 | ActionTypes.STARGAZERS_FAILURE 50 | ] 51 | }) 52 | }); 53 | -------------------------------------------------------------------------------- /example/reducers/paginate.js: -------------------------------------------------------------------------------- 1 | import merge from 'lodash/object/merge'; 2 | import union from 'lodash/array/union'; 3 | 4 | /** 5 | * Creates a reducer managing pagination, given the action types to handle, 6 | * and a function telling how to extract the key from an action. 7 | */ 8 | export default function paginate({ types, mapActionToKey }) { 9 | if (!Array.isArray(types) || types.length !== 3) { 10 | throw new Error('Expected types to be an array of three elements.'); 11 | } 12 | if (!types.every(t => typeof t === 'string')) { 13 | throw new Error('Expected types to be strings.'); 14 | } 15 | if (typeof mapActionToKey !== 'function') { 16 | throw new Error('Expected mapActionToKey to be a function.'); 17 | } 18 | 19 | const [requestType, successType, failureType] = types; 20 | 21 | function updatePagination(state = { 22 | isFetching: false, 23 | nextPageUrl: undefined, 24 | pageCount: 0, 25 | ids: [] 26 | }, action) { 27 | switch (action.type) { 28 | case requestType: 29 | return merge({}, state, { 30 | isFetching: true 31 | }); 32 | case successType: 33 | return merge({}, state, { 34 | isFetching: false, 35 | ids: union(state.ids, action.response.result), 36 | nextPageUrl: action.response.nextPageUrl, 37 | pageCount: state.pageCount + 1 38 | }); 39 | case failureType: 40 | return merge({}, state, { 41 | isFetching: false 42 | }); 43 | default: 44 | return state; 45 | } 46 | } 47 | 48 | return function updatePaginationByKey(state = {}, action) { 49 | switch (action.type) { 50 | case requestType: 51 | case successType: 52 | case failureType: 53 | const key = mapActionToKey(action); 54 | if (typeof key !== 'string') { 55 | throw new Error('Expected key to be a string.'); 56 | } 57 | return merge({}, state, { 58 | [key]: updatePagination(state[key], action) 59 | }); 60 | default: 61 | return state; 62 | } 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /example/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import fs from 'fs'; 4 | import express from 'express'; 5 | import browserify from 'browserify-middleware'; 6 | import React from 'react'; 7 | import stringify from 'js-stringify'; 8 | import Root from './containers/Root'; 9 | import MemoryHistory from 'react-router/lib/MemoryHistory'; 10 | import createStore from './store'; 11 | 12 | const store = createStore(); 13 | 14 | const app = express(); 15 | 16 | const indexHtml = fs.readFileSync(__dirname + '/index.html', 'utf8'); 17 | 18 | app.get('/static/bundle.js', browserify( 19 | __dirname + '/client.js', 20 | { transform: [require('babelify')] } 21 | )); 22 | app.use(function (req, res, next) { 23 | if (req.path === '/favicon.ico') return next(); 24 | let store = createStore(); 25 | let element = ; 26 | store.renderToString(React, element).done(function (html) { 27 | res.send(indexHtml.replace(/\{\{([a-z]*)\}\}/g, function (_, name) { 28 | if (name === 'content') return html; 29 | if (name === 'state') return stringify(store.getState()); 30 | return _; 31 | })); 32 | }, next); 33 | }); 34 | app.listen(3000, function (err) { 35 | if (err) { 36 | console.log(err); 37 | } 38 | 39 | console.log('Listening at localhost:3000'); 40 | }); 41 | -------------------------------------------------------------------------------- /example/store/index.js: -------------------------------------------------------------------------------- 1 | import { createStore, combineReducers } from 'redux'; 2 | import applyMiddleware from '../../'; 3 | import thunkMiddleware from 'redux-thunk'; 4 | import apiMiddleware from '../middleware/api'; 5 | import loggerMiddleware from 'redux-logger'; 6 | import * as reducers from '../reducers'; 7 | 8 | function basicLoggerMiddleware({ getState }) { 9 | return (next) => (action) => { 10 | const time = new Date(); 11 | const message = `action ${action.type} @ ${time.getHours()}:${time.getMinutes()}:${time.getSeconds()}`; 12 | 13 | console.log(message); 14 | 15 | return next(action); 16 | }; 17 | } 18 | const reducer = combineReducers(reducers); 19 | const createStoreWithMiddleware = applyMiddleware( 20 | thunkMiddleware, 21 | apiMiddleware, 22 | typeof console !== 'undefined' && console && console.group ? loggerMiddleware : basicLoggerMiddleware 23 | )(createStore); 24 | 25 | /** 26 | * Creates a preconfigured store for this example. 27 | */ 28 | export default function configureStore(initialState) { 29 | return createStoreWithMiddleware(reducer, initialState); 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-wait", 3 | "version": "1.0.0", 4 | "main": "./lib/index.js", 5 | "description": "A helper to let you wait for redux actions to be processed in a universal app", 6 | "keywords": [], 7 | "dependencies": { 8 | "is-promise": "^2.0.0", 9 | "promise": "^7.0.4" 10 | }, 11 | "devDependencies": { 12 | "babel": "^5.8.21" 13 | }, 14 | "scripts": { 15 | "prepublish": "npm run build", 16 | "build": "babel src --out-dir lib", 17 | "test": "mocha -R spec" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/ForbesLindesay/redux-wait.git" 22 | }, 23 | "author": "ForbesLindesay", 24 | "license": "MIT" 25 | } 26 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import Promise from 'promise'; 4 | import isPromise from 'is-promise'; 5 | 6 | function noop() {} 7 | export default function reduxWait(...middlewares) { 8 | return function (next) { 9 | return function (reducer, initialState) { 10 | var store = next(reducer, initialState); 11 | var dispatch = store.dispatch; 12 | var chain = []; 13 | 14 | var pending = 0, onSuccess, onFailure; 15 | function handleWatingOnMiddleware(middleware) { 16 | return action => { 17 | let result = middleware(action); 18 | if (isPromise(result)) { 19 | pending++; 20 | Promise.resolve(result).done(function () { 21 | pending--; 22 | if (pending === 0 && onSuccess) onSuccess(); 23 | }, function (err) { 24 | if (onFailure) onFailure(err); 25 | else throw err; 26 | }); 27 | } 28 | return result; 29 | } 30 | } 31 | var middlewareAPI = { 32 | getState: store.getState, 33 | dispatch: (action) => dispatch(action) 34 | }; 35 | chain = middlewares.map( 36 | middleware => middleware(middlewareAPI) 37 | ).map( 38 | middleware => next => handleWatingOnMiddleware(middleware(next)) 39 | ); 40 | dispatch = compose(...chain, store.dispatch); 41 | 42 | function renderToString(React, element) { 43 | return new Promise(function (resolve, reject) { 44 | let html = '', resolved = false; 45 | let dirty = false, inProgress = false; 46 | onFailure = (err) => { 47 | resolved = true; 48 | reject(err); 49 | }; 50 | onSuccess = () => { 51 | resolved = true; 52 | resolve(html) 53 | }; 54 | function render() { 55 | if (resolved) return; 56 | dirty = true; 57 | if (inProgress) return; 58 | inProgress = true; 59 | while (dirty && !resolved) { 60 | dirty = false; 61 | html = React.renderToString(element); 62 | } 63 | inProgress = false; 64 | } 65 | store.subscribe(render); 66 | render(); 67 | if (pending === 0) onSuccess(); 68 | }); 69 | } 70 | return { 71 | ...store, 72 | dispatch, 73 | renderToString 74 | }; 75 | }; 76 | }; 77 | } 78 | 79 | function compose(...funcs) { 80 | return funcs.reduceRight((composed, f) => f(composed)); 81 | } 82 | --------------------------------------------------------------------------------