├── .babelrc ├── .eslintrc ├── .gitignore ├── README.md ├── config ├── client.js └── index.js ├── gulpfile.js ├── lib ├── express.js └── render.js ├── package.json ├── src └── js │ ├── actions │ ├── repo.js │ └── user.js │ ├── components │ ├── Application.jsx │ ├── HomePage.jsx │ ├── RepoList.jsx │ ├── RepoListItem.jsx │ ├── Router.jsx │ └── UserPage.jsx │ ├── consts │ └── ActionTypes.js │ ├── decorators │ ├── index.js │ └── prepareRoute.js │ ├── lib │ ├── createAPI.js │ ├── createReducer.js │ └── createRedux.js │ ├── main.js │ ├── reducers │ ├── Repo.js │ ├── User.js │ └── index.js │ ├── routes.js │ └── vendor.js └── views └── layouts └── main.hbs /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "stage": 0, 3 | "optional": [ "runtime" ] 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "settings": { 4 | "ecmascript": 6, 5 | "jsx": true 6 | }, 7 | "plugins": [ "react" ], 8 | "env": { 9 | "node": true, 10 | "browser": true 11 | }, 12 | "rules": { 13 | "new-parens": 0, 14 | "no-shadow": 0, 15 | "no-underscore-dangle": 0, 16 | "no-console": 1, 17 | "quotes": [ 1, "single" ], 18 | "strict": 0 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS temp files 2 | .DS_Store 3 | thumb.db 4 | 5 | # node_modules 6 | /node_modules 7 | 8 | # config files 9 | # /config/index.js 10 | 11 | # Log files 12 | /*.log 13 | 14 | # Dist 15 | /dist 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React + Redux example 2 | Also includes `ReactRouter`, `Immutable` for Universal (isomorphic) application. This is good start for those who want a full solution to work with a RESTful API. 3 | This project is written with ES7 and uses `Babel` to transpile to ES5. 4 | 5 | [Redux v1.0.0-rc](https://github.com/gaearon/redux/releases/tag/v1.0.0-rc) is now available on npm. You can view the example for [redux@v1.0.0-rc](https://github.com/quangbuule/redux-example/tree/redux%40v1.0.0-rc) on branch [redux@rc](https://github.com/quangbuule/redux-example/tree/redux%40v1.0.0-rc). 6 | 7 | ##Installation 8 | ``` 9 | npm install 10 | ``` 11 | 12 | ##Run 13 | ``` 14 | npm run dev 15 | ``` 16 | 17 | ##How it works 18 | ###API 19 | Thanks to React and Redux, Server side and client side calls will run almost the same code for rendering. 20 | 21 | The differences are in how `api` calls from the client and from the server (running the same code with client) are achieved. Server side calls may send information that is different from client, such as an `accessToken` field (which is received from each request's session), or may send to another api-server. 22 | 23 | The `api` methods should therefore be different between server and client. 24 | You can take a look at implementations of the `api` method on the server (/lib/render.js) and client (/src/js/main.js). 25 | 26 | ###Why Immutable? 27 | 28 | Data changing over time can cause some unpredictable errors. Immutable makes sure the referenced objects won't have their data changed over time. If you want to change the data, you must reference it to another object. For example: 29 | 30 | ```js 31 | handleStarButtonClick() { 32 | const repo = this.state.repo; // Assume we use Immutable for state.repo 33 | this.setState({ 34 | repo: repo.set('starred', true) 35 | }); 36 | } 37 | ``` 38 | 39 | ##TODO 40 | - Improve english. 41 | - Rewrite README.md 42 | - Add inline-style. 43 | - Prevent first loading when having initial state. 44 | - Loading indicator. 45 | -------------------------------------------------------------------------------- /config/client.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export default { 4 | apiServer: { 5 | urlPrefix: 'https://api.github.com' 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | export default { 4 | /** 5 | * Front-End Server 6 | */ 7 | server: { 8 | host: 'localhost', 9 | port: 8080 10 | }, 11 | 12 | /** 13 | * API Server 14 | */ 15 | apiServer: { 16 | urlPrefix: 'https://api.github.com' 17 | }, 18 | 19 | /** 20 | * WebpackDevServer 21 | */ 22 | webpackDevServer: { 23 | host: 'localhost', 24 | port: 8081 25 | }, 26 | 27 | /** 28 | * browserSync 29 | */ 30 | browserSyncServer: { 31 | host: 'localhost', 32 | port: 8082 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-use-before-define, no-console */ 2 | 'use strict'; 3 | 4 | import BrowserSync from 'browser-sync'; 5 | import childProcess from 'child_process'; 6 | import config from './config'; 7 | import del from 'del'; 8 | import gulp from 'gulp'; 9 | import gulpLoadPlugins from 'gulp-load-plugins'; 10 | import nodemon from 'nodemon'; 11 | import notifier from 'node-notifier'; 12 | import path from 'path'; 13 | import runSequence from 'run-sequence'; 14 | import webpack from 'webpack'; 15 | import WebpackDevServer from 'webpack-dev-server'; 16 | 17 | const $ = gulpLoadPlugins(); 18 | const env = process.env.NODE_ENV || 'development'; 19 | 20 | var isWatching = false; 21 | 22 | gulp.task('clean', clean); 23 | gulp.task('js:lint', jsLint); 24 | gulp.task('js:bundle', jsBundle); 25 | gulp.task('browser-sync', browserSyncInitialize); 26 | 27 | gulp.task('watch', function () { 28 | isWatching = true; 29 | runSequence([ 'js:lint', 'js:bundle', 'browser-sync' ]); 30 | }); 31 | 32 | gulp.task('nodemon', function () { 33 | nodemon({ 34 | ignore: [ 'src/js/**', 'node_modules' ], 35 | exec: 'npm run express', 36 | verbose: false 37 | }); 38 | }); 39 | 40 | gulp.task('dev', function (callback) { 41 | runSequence('js:bundle', [ 'watch', 'nodemon' ], callback); 42 | }); 43 | 44 | gulp.task('build', function (callback) { 45 | runSequence('js:bundle', callback); 46 | }); 47 | 48 | function clean() { 49 | del('dist'); 50 | } 51 | 52 | function jsLint() { 53 | var srcBlob = [ '**/*.@(js|jsx)', '!node_modules/**/*', '!dist/**/*' ]; 54 | 55 | return (isWatching ? $.watch(srcBlob) : gulp.src(srcBlob)) 56 | .pipe($.eslint()) 57 | .pipe($.plumber({ 58 | errorHandler(err) { 59 | if (isWatching) { 60 | let { fileName, lineNumber, message } = err; 61 | let relativeFilename = path.relative(process.cwd(), fileName); 62 | 63 | notifier.notify({ 64 | title: 'ESLint Error', 65 | wait: true, 66 | message: `Line ${lineNumber}: ${message} (${relativeFilename})` 67 | }, (err, message) => { 68 | if (err) { 69 | console.error(err); 70 | } 71 | 72 | if (message.startsWith('Activate')) { 73 | childProcess.exec(`subl --command open_file ${fileName}:${lineNumber}`); 74 | } 75 | }); 76 | } 77 | } 78 | })) 79 | .pipe($.eslint.failOnError()) 80 | .pipe($.eslint.formatEach()); 81 | } 82 | 83 | function jsBundle(callback) { 84 | const { webpackDevServer: { host, port } } = config; 85 | var webpackDevServerUrl = `http://${host}:${port}`; 86 | var babelLoader = { 87 | test: /\.jsx?$/, 88 | loaders: [ 'babel-loader' ], 89 | exclude: [ 90 | path.resolve(__dirname, 'node_modules') 91 | ] 92 | }; 93 | 94 | var webpackConfig = { 95 | devtool: '#inline-source-map', 96 | entry: { 97 | main: './src/js/main', 98 | vendor: './src/js/vendor' 99 | }, 100 | resolve: { 101 | extensions: [ '', '.jsx', '.js' ], 102 | modulesDirectories: [ 'node_modules' ] 103 | }, 104 | target: 'web', 105 | module: { 106 | loaders: [ babelLoader ] 107 | }, 108 | plugins: [ 109 | new webpack.DefinePlugin({ 110 | 'process.env': { 111 | 'NODE_ENV': `"${env}"` 112 | } 113 | }), 114 | new webpack.optimize.CommonsChunkPlugin('vendor', 'vendor.js') 115 | ], 116 | output: { 117 | path: path.resolve(__dirname, 'dist/js'), 118 | publicPath: `${webpackDevServerUrl}/js/`, 119 | filename: '[name].js', 120 | chunkFilename: '[id].js' 121 | } 122 | }; 123 | 124 | var devServerConfig = { 125 | contentBase: path.resolve(__dirname, 'dist'), 126 | publicPath: webpackConfig.output.publicPath, 127 | hot: true, 128 | quiet: true, 129 | noInfo: true, 130 | stats: { 131 | colors: true 132 | } 133 | }; 134 | 135 | if (env === 'production') { 136 | webpackConfig.devtool = '#source-map'; 137 | webpackConfig.plugins.push( 138 | new webpack.optimize.DedupePlugin(), 139 | new webpack.optimize.UglifyJsPlugin() 140 | ); 141 | } 142 | 143 | if (!isWatching) { 144 | webpack(webpackConfig).run(function (err) { 145 | if (err) { 146 | handleError(err); 147 | } 148 | 149 | if (callback) { 150 | callback(); 151 | } 152 | }); 153 | 154 | } else { 155 | webpackConfig.entry.main = [ 156 | `webpack-dev-server/client?${webpackDevServerUrl}`, 157 | 'webpack/hot/only-dev-server', 158 | webpackConfig.entry.main 159 | ]; 160 | 161 | babelLoader.loaders.unshift('react-hot'); 162 | webpackConfig.plugins.push( 163 | new webpack.HotModuleReplacementPlugin(), 164 | new webpack.NoErrorsPlugin() 165 | ); 166 | var compiler = webpack(webpackConfig); 167 | var server = new WebpackDevServer(compiler, devServerConfig); 168 | 169 | compiler.plugin('done', (stats) => { 170 | if (stats.hasErrors()) { 171 | console.error($.util.colors.red('WebpackError')); 172 | stats.toJson().errors.forEach(err => console.error(err)); 173 | } 174 | 175 | $.util.log('Finished', $.util.colors.cyan('jsBundle()')); 176 | }); 177 | 178 | server.listen(config.webpackDevServer.port); 179 | } 180 | } 181 | 182 | function browserSyncInitialize() { 183 | const browserSync = BrowserSync.create(); 184 | browserSync.init({ 185 | files: [ 'dist/**/*' ], 186 | open: false, 187 | ui: false, 188 | logLevel: 'silent', 189 | port: config.browserSyncServer.port 190 | }); 191 | } 192 | 193 | function handleError(err) { 194 | var { name, message } = err; 195 | 196 | console.error($.util.colors.red(name), message); 197 | 198 | if (isWatching) { 199 | notifier.notify({ 200 | title: 'Build Error', 201 | message: 'Something went wrong.' 202 | }); 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /lib/express.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import config from '../config'; 4 | import express from 'express'; 5 | 6 | const app = express(); 7 | const env = process.env.NODE_ENV || 'development'; 8 | 9 | 10 | // Serve static files 11 | // -------------------------------------------------- 12 | import path from 'path'; 13 | app.use(express.static(path.resolve(process.cwd(), 'dist'))); 14 | 15 | app.use('/favicon.ico', function (req, res) { 16 | res.redirect('http://github.com/favicon.ico'); 17 | }); 18 | 19 | // View engine 20 | // -------------------------------------------------- 21 | import expressHandlebars from 'express-handlebars'; 22 | import handlebars from 'handlebars'; 23 | 24 | handlebars.registerHelper('json-stringify', ::JSON.stringify); 25 | 26 | app.engine('hbs', expressHandlebars()); 27 | app.set('view engine', 'hbs'); 28 | 29 | 30 | // Render layout 31 | // -------------------------------------------------- 32 | import render from '../lib/render'; 33 | 34 | app.get('/*', (req, res) => { 35 | // Js files 36 | const jsPaths = [ 'vendor', 'main' ].map(basename => { 37 | if (env === 'development') { 38 | let { webpackDevServer: { host, port } } = config; 39 | return `//${host}:${port}/js/${basename}.js`; 40 | } 41 | return `/js/${basename}.js`; 42 | }); 43 | 44 | if (env === 'development') { 45 | let { browserSyncServer: { host, port } } = config; 46 | const BSVersion = require('browser-sync/package.json').version; 47 | jsPaths.push(`//${host}:${port}/browser-sync/browser-sync-client.${BSVersion}.js`); 48 | } 49 | 50 | // Render 51 | const layout = 'layouts/main'; 52 | const payload = { 53 | jsPaths, 54 | initialState: {}, 55 | body: '' 56 | }; 57 | 58 | // if (env === 'development') { 59 | // return res.render(layout, payload); 60 | // } 61 | 62 | render(req, res, layout, { 63 | payload 64 | }); 65 | }); 66 | 67 | const server = app.listen(config.server.port, () => { 68 | const { address: host, port } = server.address(); 69 | console.log(`Front-End server is running at ${host}:${port}`); // eslint-disable-line no-console 70 | }); 71 | -------------------------------------------------------------------------------- /lib/render.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | import { Router as ReactRouter } from 'react-router'; 5 | import Location from 'react-router/lib/Location'; 6 | import History from 'react-router/lib/MemoryHistory'; 7 | import request from 'superagent'; 8 | import qs from 'qs'; 9 | import createRedux from '../src/js/lib/createRedux'; 10 | import createAPI from '../src/js/lib/createAPI'; 11 | import routes from '../src/js/routes'; 12 | import { apiServer } from '../config'; 13 | import Router from '../src/js/components/Router'; 14 | import { Provider } from 'redux/react'; 15 | 16 | export default function render(req, res, layout, { payload }) { 17 | const { path, query } = req; 18 | const location = new Location(path, query); 19 | const history = new History(path); 20 | 21 | const api = createAPI( 22 | /** 23 | * Server's createRequest() method 24 | * You can modify headers, pathname, query, body to make different request 25 | * from client's createRequest() method 26 | * 27 | * Example: 28 | * You API server is `http://api.example.com` and it require accessToken 29 | * on query, then you can assign accessToken (get from req) to query object 30 | * before calling API 31 | */ 32 | ({ method, headers = {}, pathname, query = {}, body = {} }) => { 33 | var url = `${apiServer.urlPrefix}${pathname}`; 34 | 35 | return request(method, url) 36 | .query(qs.stringify(query)) 37 | .set(headers) 38 | .send(body); 39 | } 40 | ); 41 | 42 | const redux = createRedux(api); 43 | 44 | ReactRouter.run(routes, location, async (err, routerState) => { 45 | try { 46 | if (err) { 47 | throw err; 48 | } 49 | 50 | const { params, location } = routerState; 51 | const prepareRouteMethods = routerState.components.map(component => 52 | component.prepareRoute); 53 | 54 | for (let prepareRoute of prepareRouteMethods) { 55 | if (!prepareRoute) { 56 | continue; 57 | } 58 | 59 | await prepareRoute({ redux, params, location }); 60 | } 61 | 62 | const body = React.renderToStaticMarkup( 63 | 64 | {() => } 65 | 66 | ); 67 | 68 | const initialState = redux.getState(); 69 | 70 | res.render(layout, { ...payload, body, initialState }); 71 | 72 | } catch(err) { 73 | res.status(500).send(err.stack); 74 | } 75 | }); 76 | } 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "redux-example", 3 | "version": "1.0.0", 4 | "description": "Universal with React + Redux.", 5 | "main": "index.js", 6 | "scripts": { 7 | "gulp": "babel-node ./node_modules/gulp/bin/gulp", 8 | "express": "babel-node lib/express", 9 | "dev": "npm run gulp dev", 10 | "lint": "npm run gulp js:lint", 11 | "test": "echo \"Error: no test specified\" && exit 1" 12 | }, 13 | "keywords": [ 14 | "react", 15 | "redux" 16 | ], 17 | "author": "Quangbuu Le ", 18 | "license": "ISC", 19 | "devDependencies": { 20 | "babel": "^5.6.14", 21 | "babel-core": "^5.6.15", 22 | "babel-eslint": "^3.1.19", 23 | "babel-loader": "^5.2.2", 24 | "babel-runtime": "^5.6.15", 25 | "browser-sync": "^2.7.12", 26 | "del": "^1.2.0", 27 | "eslint": "^0.24.0", 28 | "eslint-plugin-react": "^2.5.2", 29 | "gulp": "^3.9.0", 30 | "gulp-eslint": "^0.14.0", 31 | "gulp-load-plugins": "^1.0.0-rc.1", 32 | "gulp-plumber": "^1.0.1", 33 | "gulp-util": "^3.0.6", 34 | "gulp-watch": "^4.2.4", 35 | "node-libs-browser": "^0.5.2", 36 | "node-notifier": "^4.2.3", 37 | "nodemon": "^1.3.7", 38 | "react": "^0.13.3", 39 | "react-hot-loader": "^1.2.7", 40 | "run-sequence": "^1.1.1", 41 | "webpack": "^1.10.0", 42 | "webpack-dev-server": "^1.10.0" 43 | }, 44 | "dependencies": { 45 | "bluebird": "^2.9.30", 46 | "express": "^4.13.0", 47 | "express-handlebars": "^2.0.1", 48 | "handlebars": "^3.0.3", 49 | "immutable": "^3.7.4", 50 | "lodash": "^3.9.3", 51 | "parse-link-header": "^0.2.0", 52 | "qs": "^3.1.0", 53 | "react-router": "^1.0.0-beta2", 54 | "redux": "^0.12.0", 55 | "superagent": "^1.2.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/js/actions/repo.js: -------------------------------------------------------------------------------- 1 | import ActionTypes from '../consts/ActionTypes'; 2 | import parseLinkHeader from 'parse-link-header'; 3 | 4 | /** 5 | * get repositores by username 6 | * @return {function} action handler 7 | */ 8 | export function getByUsername(username) { 9 | /** 10 | * Asynchronous call API and return an action payload which will be 11 | * pass to reducer method as 2nd parameter. 12 | * @return {object} 13 | * type: action type 14 | * username: username 15 | * res: response from api 16 | * 17 | * See more at: ../reducers/Repo.js 18 | */ 19 | return async api => ({ 20 | type: ActionTypes.Repo.getByUsername, 21 | username, 22 | res: await api(`/users/${username}/repos`, { 23 | sort: 'updated', 24 | direction: 'desc' 25 | }) 26 | }); 27 | } 28 | 29 | export function getMore(username) { 30 | return async (api, getState) => { 31 | const reposRes = getState().Repo.get(`users/${username}__res`); 32 | const nextUrl = parseLinkHeader(reposRes.header.link).next.url; 33 | 34 | return { 35 | type: ActionTypes.Repo.getMore, 36 | username, 37 | res: await api(nextUrl) 38 | }; 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /src/js/actions/user.js: -------------------------------------------------------------------------------- 1 | import ActionTypes from '../consts/ActionTypes'; 2 | 3 | export function getOneByUsername(username) { 4 | return async api => ({ 5 | type: ActionTypes.User.getOneByUsername, 6 | username, 7 | res: await api(`/users/${username}`) 8 | }); 9 | } 10 | -------------------------------------------------------------------------------- /src/js/components/Application.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | class Application extends React.Component { 4 | 5 | render() { 6 | const { props: { children } } = this; 7 | 8 | return ( 9 |
10 | {children} 11 |
12 | ); 13 | } 14 | } 15 | 16 | export default Application; 17 | -------------------------------------------------------------------------------- /src/js/components/HomePage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router'; 3 | 4 | class UserPage extends React.Component { 5 | 6 | render () { 7 | return ( 8 |
9 |

Github

10 |
11 | View Facebook's profile & repositories 12 |
13 |
14 | View Google's profile & repositories 15 |
16 |
17 | ); 18 | } 19 | } 20 | 21 | export default UserPage; 22 | -------------------------------------------------------------------------------- /src/js/components/RepoList.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import Immutable from 'immutable'; 3 | import RepoListItem from './RepoListItem'; 4 | 5 | class RepoList extends React.Component { 6 | 7 | static propTypes = { 8 | repos: PropTypes.instanceOf(Immutable.List).isRequired 9 | } 10 | 11 | render() { 12 | const { 13 | props: { repos } 14 | } = this; 15 | 16 | return ( 17 |
    18 | {repos.map(repo => )} 19 |
20 | ); 21 | } 22 | } 23 | 24 | export default RepoList; 25 | -------------------------------------------------------------------------------- /src/js/components/RepoListItem.jsx: -------------------------------------------------------------------------------- 1 | import React, { PropTypes } from 'react'; 2 | import Immutable from 'immutable'; 3 | 4 | class RepoListItem extends React.Component { 5 | 6 | static propTypes = { 7 | repo: PropTypes.instanceOf(Immutable.Map).isRequired 8 | } 9 | 10 | render() { 11 | const { 12 | props: { repo } 13 | } = this; 14 | 15 | return ( 16 |
  • 17 |

    18 | {repo.get('name')}:  19 | 20 | ({repo.get('stargazers_count')}  21 | ) 22 | 23 |
    24 | {repo.get('description')} 25 |

    26 |
  • 27 | ); 28 | } 29 | } 30 | 31 | export default RepoListItem; 32 | -------------------------------------------------------------------------------- /src/js/components/Router.jsx: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | import { Router } from 'react-router'; 5 | import routes from '../routes'; 6 | 7 | class AppRouter extends React.Component { 8 | 9 | static propTypes = { 10 | history: React.PropTypes.object.isRequired 11 | } 12 | 13 | render() { 14 | 15 | return ( 16 | 17 | {routes} 18 | 19 | ); 20 | } 21 | } 22 | 23 | export default AppRouter; 24 | -------------------------------------------------------------------------------- /src/js/components/UserPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'redux/react'; 3 | import { prepareRoute } from '../decorators'; 4 | import * as RepoActionCreators from '../actions/repo'; 5 | import * as UserActionCreators from '../actions/user'; 6 | import RepoList from './RepoList'; 7 | 8 | @prepareRoute(async function ({ redux, params: { username } }) { 9 | return await * [ 10 | redux.dispatch(RepoActionCreators.getByUsername(username)), 11 | redux.dispatch(UserActionCreators.getOneByUsername(username)) 12 | ]; 13 | }) 14 | @connect(({ Repo, User }) => ({ Repo, User })) 15 | class UserPage extends React.Component { 16 | 17 | render () { 18 | const { 19 | props: { 20 | Repo, 21 | User, 22 | params: { username } 23 | } 24 | } = this; 25 | 26 | const user = User.get(username); 27 | const repos = Repo.get(`users/${username}`); 28 | 29 | return ( 30 |
    31 |

    {user ? user.get('name') : 'Loading...'}

    32 |
    {user ? user.get('location') : 'Loading...'}
    33 |
    {user ? user.get('bio') : 'Loading...'}
    34 |

    Repositories:

    35 | {repos ? : 'Loading...'} 36 |

    37 | 38 |

    39 |
    40 | ); 41 | } 42 | 43 | getMore() { 44 | const { 45 | props: { 46 | dispatch, 47 | params: { username } 48 | } 49 | } = this; 50 | 51 | dispatch(RepoActionCreators.getMore(username)); 52 | } 53 | } 54 | 55 | export default UserPage; 56 | -------------------------------------------------------------------------------- /src/js/consts/ActionTypes.js: -------------------------------------------------------------------------------- 1 | import keyMirror from 'react/lib/keyMirror'; 2 | 3 | export default { 4 | 5 | Repo: keyMirror({ 6 | getByUsername: null, 7 | getMore: null 8 | }), 9 | 10 | User: keyMirror({ 11 | getOneByUsername: null 12 | }) 13 | }; 14 | -------------------------------------------------------------------------------- /src/js/decorators/index.js: -------------------------------------------------------------------------------- 1 | export { default as prepareRoute } from './prepareRoute'; 2 | -------------------------------------------------------------------------------- /src/js/decorators/prepareRoute.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React, { PropTypes } from 'react'; 4 | 5 | export default function prepareRoute(prepareFn) { 6 | 7 | return DecoratedComponent => 8 | class PrepareRouteDecorator extends React.Component { 9 | 10 | static prepareRoute = prepareFn 11 | 12 | static contextTypes = { 13 | redux: PropTypes.object.isRequired 14 | } 15 | 16 | render() { 17 | return ( 18 | 19 | ); 20 | } 21 | 22 | componentDidMount() { 23 | const { 24 | context: { redux }, 25 | props: { params, location } 26 | } = this; 27 | 28 | prepareFn({ redux, params, location }); 29 | } 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/js/lib/createAPI.js: -------------------------------------------------------------------------------- 1 | import Promise from 'bluebird'; 2 | import _ from 'lodash'; 3 | import qs from 'qs'; 4 | import URL from 'url'; 5 | 6 | /** 7 | * return api function base on createRequest function 8 | * Usage: 9 | * api('/users/facebook') 10 | * api('/users/facebook/repos') 11 | * ... 12 | * 13 | * createRequest() may different from client and server sides 14 | * You can see createRequest() at: 15 | * Client: ../main.js 16 | * Server: /lib/render.js 17 | */ 18 | export default function createAPI(createRequest) { 19 | return async function api(path, method = 'GET', params = {}) { 20 | var { pathname, query: queryStr } = URL.parse(path); 21 | var query, headers, body; 22 | 23 | if (_.isObject(method)) { 24 | params = method; 25 | method = 'GET'; 26 | } 27 | 28 | query = qs.parse(queryStr); 29 | 30 | if (method === 'GET') { 31 | if (_.isObject(params)) { 32 | _.assign(query, params); 33 | } 34 | 35 | } else { 36 | body = params; 37 | } 38 | 39 | return await new Promise((resolve, reject) => { 40 | createRequest({ method, headers, pathname, query, body }) 41 | .end((err, res) => { 42 | if (err) { 43 | return reject(err); 44 | } 45 | 46 | return resolve(res); 47 | }); 48 | }); 49 | }; 50 | } 51 | -------------------------------------------------------------------------------- /src/js/lib/createReducer.js: -------------------------------------------------------------------------------- 1 | import Immutable, { Map, List } from 'immutable'; 2 | 3 | export default function createReducer(initialState, handlers) { 4 | return (state = initialState, action) => { 5 | if (!Map.isMap(state) && !List.isList(state)) { 6 | state = Immutable.fromJS(state); 7 | } 8 | 9 | const handler = handlers[action.type]; 10 | 11 | if (!handler) { 12 | return state; 13 | } 14 | 15 | state = handler(state, action); 16 | 17 | if (!Map.isMap(state) && !List.isList(state)) { 18 | throw new TypeError('Reducers must return Immutable objects.'); 19 | } 20 | 21 | return state; 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/js/lib/createRedux.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import { createDispatcher, createRedux, composeStores } from 'redux'; 3 | import * as reducers from '../reducers'; 4 | 5 | function promiseMiddleware(api, getState) { 6 | return next => 7 | function _r(action) { 8 | if (action && _.isFunction(action.then)) { 9 | return action.then(_r); 10 | } 11 | 12 | if (_.isFunction(action)) { 13 | return _r(action(api, getState)); 14 | } 15 | 16 | return next(action); 17 | }; 18 | } 19 | 20 | export default function (api, intialState) { 21 | const dispatcher = createDispatcher( 22 | composeStores(reducers), 23 | getState => [ promiseMiddleware(api, getState) ] 24 | ); 25 | const redux = createRedux(dispatcher, intialState); 26 | 27 | return redux; 28 | } 29 | -------------------------------------------------------------------------------- /src/js/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import History from 'react-router/lib/BrowserHistory'; 3 | import Router from './components/Router'; 4 | import { Provider } from 'redux/react'; 5 | import createRedux from './lib/createRedux'; 6 | import request from 'superagent'; 7 | import qs from 'qs'; 8 | import createAPI from './lib/createAPI'; 9 | import { apiServer } from '../../config/client'; 10 | 11 | const history = new History; 12 | const api = createAPI( 13 | /** 14 | * Client's createRequest() method 15 | */ 16 | ({ method, headers = {}, pathname, query = {}, body = {} }) => { 17 | pathname = pathname.replace(new RegExp(`^${apiServer.urlPrefix}`), ''); 18 | var url = `${apiServer.urlPrefix}${pathname}`; 19 | 20 | return request(method, url) 21 | .query(qs.stringify(query)) 22 | .set(headers) 23 | .send(body); 24 | } 25 | ); 26 | 27 | /* global __INITIAL_STATE__:true */ 28 | const redux = createRedux(api, __INITIAL_STATE__); 29 | 30 | React.render( 31 | 32 | {() => } 33 | , 34 | document.getElementById('main') 35 | ); 36 | -------------------------------------------------------------------------------- /src/js/reducers/Repo.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import ActionTypes from '../consts/ActionTypes'; 3 | import createReducer from '../lib/createReducer'; 4 | 5 | const initialState = Immutable.fromJS({}); 6 | 7 | export default createReducer(initialState, { 8 | /** 9 | * Because github use header for pagination, so we should receive res from 10 | * api call and store res to store's state also. 11 | * 12 | * See action at ../actions/repo.js 13 | */ 14 | [ActionTypes.Repo.getByUsername](state, { username, res }) { 15 | const repos = res.body; 16 | 17 | return state.merge({ 18 | [`users/${username}__res`]: res, 19 | [`users/${username}`]: repos 20 | }); 21 | }, 22 | 23 | /** 24 | * Get more repos from github 25 | */ 26 | [ActionTypes.Repo.getMore](state, { username, res }) { 27 | const repos = state.get(`users/${username}`); 28 | const nextRepos = repos.concat(Immutable.fromJS(res.body)); 29 | 30 | return state.merge({ 31 | [`users/${username}__res`]: res, 32 | [`users/${username}`]: nextRepos 33 | }); 34 | } 35 | }); 36 | -------------------------------------------------------------------------------- /src/js/reducers/User.js: -------------------------------------------------------------------------------- 1 | import Immutable from 'immutable'; 2 | import ActionTypes from '../consts/ActionTypes'; 3 | import createReducer from '../lib/createReducer'; 4 | 5 | const initialState = Immutable.fromJS({}); 6 | 7 | export default createReducer(initialState, { 8 | 9 | [ActionTypes.User.getOneByUsername](state, { username, res }) { 10 | return state.merge({ 11 | [username]: res.body 12 | }); 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /src/js/reducers/index.js: -------------------------------------------------------------------------------- 1 | export { default as Repo } from './Repo'; 2 | export { default as User } from './User'; 3 | -------------------------------------------------------------------------------- /src/js/routes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; // eslint-disable-line no-unused-vars 4 | import { Route } from 'react-router'; 5 | import Application from './components/Application'; 6 | import HomePage from './components/HomePage'; 7 | import UserPage from './components/UserPage'; 8 | 9 | export default ( 10 | 11 | 12 | 13 | 14 | ); 15 | -------------------------------------------------------------------------------- /src/js/vendor.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | if (1 < 0) { // eslint-disable-line no-constant-condition, yoda 4 | require('react'); 5 | require('react-router'); 6 | require('redux'); 7 | } 8 | -------------------------------------------------------------------------------- /views/layouts/main.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Redux Example 5 | 6 | 13 | 14 | 15 |
    {{{body}}}
    16 | 19 | {{#each jsPaths}} 20 | 21 | {{/each}} 22 | 23 | 24 | --------------------------------------------------------------------------------