diff --git a/examples/counter/index.html b/examples/counter/index.html
index 696101a536..0f72d6a041 100644
--- a/examples/counter/index.html
+++ b/examples/counter/index.html
@@ -1,6 +1,6 @@
-
diff --git a/examples/counter/package.json b/examples/counter/package.json
index 80e397c2cf..59599191f3 100644
--- a/examples/counter/package.json
+++ b/examples/counter/package.json
@@ -1,7 +1,7 @@
{
- "name": "counter-redux",
+ "name": "redux-counter-example",
"version": "0.0.0",
- "description": "Counter example for redux",
+ "description": "Redux counter example",
"main": "server.js",
"scripts": {
"start": "node server.js",
diff --git a/examples/real-world/.babelrc b/examples/real-world/.babelrc
new file mode 100644
index 0000000000..cab5d10d92
--- /dev/null
+++ b/examples/real-world/.babelrc
@@ -0,0 +1,3 @@
+{
+ "stage": 2
+}
diff --git a/examples/real-world/actions/index.js b/examples/real-world/actions/index.js
new file mode 100644
index 0000000000..9e02f21c09
--- /dev/null
+++ b/examples/real-world/actions/index.js
@@ -0,0 +1,148 @@
+import { CALL_API, Schemas } from '../middleware/api';
+
+export const USER_REQUEST = 'USER_REQUEST';
+export const USER_SUCCESS = 'USER_SUCCESS';
+export const USER_FAILURE = 'USER_FAILURE';
+/**
+ * Fetches a single user from Github API.
+ * Relies on the custom API middleware defined in ../middleware/api.js.
+ */
+function fetchUser(login) {
+ return {
+ [CALL_API]: {
+ types: [USER_REQUEST, USER_SUCCESS, USER_FAILURE],
+ endpoint: `users/${login}`,
+ schema: Schemas.USER
+ }
+ };
+}
+/**
+ * Fetches a single user from Github API unless it is cached.
+ * Relies on Redux Thunk middleware.
+ */
+export function loadUser(login, requiredFields = []) {
+ return (dispatch, getState) => {
+ const user = getState().entities.users[login];
+ if (user && requiredFields.every(key => user.hasOwnProperty(key))) {
+ return null;
+ }
+
+ return dispatch(fetchUser(login));
+ };
+}
+
+export const REPO_REQUEST = 'REPO_REQUEST';
+export const REPO_SUCCESS = 'REPO_SUCCESS';
+export const REPO_FAILURE = 'REPO_FAILURE';
+/**
+ * Fetches a single repository from Github API.
+ * Relies on the custom API middleware defined in ../middleware/api.js.
+ */
+function fetchRepo(fullName) {
+ return {
+ [CALL_API]: {
+ types: [REPO_REQUEST, REPO_SUCCESS, REPO_FAILURE],
+ endpoint: `repos/${fullName}`,
+ schema: Schemas.REPO
+ }
+ };
+}
+/**
+ * Loads a single user from Github API unless it is cached.
+ * Relies on Redux Thunk middleware.
+ */
+export function loadRepo(fullName, requiredFields = []) {
+ return (dispatch, getState) => {
+ const repo = getState().entities.repos[fullName];
+ if (repo && requiredFields.every(key => repo.hasOwnProperty(key))) {
+ return null;
+ }
+
+ return dispatch(fetchRepo(fullName));
+ };
+}
+
+export const STARRED_REQUEST = 'STARRED_REQUEST';
+export const STARRED_SUCCESS = 'STARRED_SUCCESS';
+export const STARRED_FAILURE = 'STARRED_FAILURE';
+/**
+ * Fetches a page of starred repos by a particular user.
+ * Relies on the custom API middleware defined in ../middleware/api.js.
+ */
+function fetchStarred(login, nextPageUrl) {
+ return {
+ login,
+ [CALL_API]: {
+ types: [STARRED_REQUEST, STARRED_SUCCESS, STARRED_FAILURE],
+ endpoint: nextPageUrl,
+ schema: Schemas.REPO_ARRAY
+ }
+ };
+}
+/**
+ * Loads a page of starred repos by a particular user.
+ * Bails out if page is cached and user didn’t specifically request next page.
+ * Relies on Redux Thunk middleware.
+ */
+export function loadStarred(login, nextPage) {
+ return (dispatch, getState) => {
+ const {
+ nextPageUrl = `users/${login}/starred`,
+ pageCount = 0
+ } = getState().pagination.starredByUser[login] || {};
+
+ if (pageCount > 0 && !nextPage) {
+ return null;
+ }
+
+ return dispatch(fetchStarred(login, nextPageUrl));
+ };
+}
+
+
+export const STARGAZERS_REQUEST = 'STARGAZERS_REQUEST';
+export const STARGAZERS_SUCCESS = 'STARGAZERS_SUCCESS';
+export const STARGAZERS_FAILURE = 'STARGAZERS_FAILURE';
+/**
+ * Fetches a page of stargazers for a particular repo.
+ * Relies on the custom API middleware defined in ../middleware/api.js.
+ */
+function fetchStargazers(fullName, nextPageUrl) {
+ return {
+ fullName,
+ [CALL_API]: {
+ types: [STARGAZERS_REQUEST, STARGAZERS_SUCCESS, STARGAZERS_FAILURE],
+ endpoint: nextPageUrl,
+ schema: Schemas.USER_ARRAY
+ }
+ };
+}
+/**
+ * Loads a page of stargazers for a particular repo.
+ * Bails out if page is cached and user didn’t specifically request next page.
+ * Relies on Redux Thunk middleware.
+ */
+export function loadStargazers(fullName, nextPage) {
+ return (dispatch, getState) => {
+ const {
+ nextPageUrl = `repos/${fullName}/stargazers`,
+ pageCount = 0
+ } = getState().pagination.stargazersByRepo[fullName] || {};
+
+ if (pageCount > 0 && !nextPage) {
+ return null;
+ }
+
+ return dispatch(fetchStargazers(fullName, nextPageUrl));
+ };
+}
+
+export const RESET_ERROR_MESSAGE = 'RESET_ERROR_MESSAGE';
+/**
+ * Resets the currently visible error message.
+ */
+export function resetErrorMessage() {
+ return {
+ type: RESET_ERROR_MESSAGE
+ };
+}
diff --git a/examples/real-world/components/Explore.js b/examples/real-world/components/Explore.js
new file mode 100644
index 0000000000..7787aa5d7c
--- /dev/null
+++ b/examples/real-world/components/Explore.js
@@ -0,0 +1,61 @@
+import React, { Component, PropTypes, findDOMNode } from 'react';
+
+const GITHUB_REPO = 'https://github.com/gaearon/redux';
+
+export default class Explore extends Component {
+ constructor(props) {
+ super(props);
+ this.handleKeyUp = this.handleKeyUp.bind(this);
+ this.handleGoClick = this.handleGoClick.bind(this);
+ }
+
+ getInputValue() {
+ return findDOMNode(this.refs.input).value;
+ }
+
+ setInputValue(val) {
+ // Generally mutating DOM is a bad idea in React components,
+ // but doing this for a single uncontrolled field is less fuss
+ // than making it controlled and maintaining a state for it.
+ findDOMNode(this.refs.input).value = val;
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.value !== this.props.value) {
+ this.setInputValue(nextProps.value);
+ }
+ }
+
+ render() {
+ return (
+
+
Type a username or repo full name and hit 'Go':
+
+
+
+ Code on Github.
+
+
+ );
+ }
+
+ handleKeyUp(e) {
+ if (e.keyCode === 13) {
+ this.handleGoClick();
+ }
+ }
+
+ handleGoClick() {
+ this.props.onChange(this.getInputValue())
+ }
+}
+
+Explore.propTypes = {
+ value: PropTypes.string.isRequired,
+ onChange: PropTypes.func.isRequired
+};
diff --git a/examples/real-world/components/List.js b/examples/real-world/components/List.js
new file mode 100644
index 0000000000..83a218870e
--- /dev/null
+++ b/examples/real-world/components/List.js
@@ -0,0 +1,50 @@
+import React, { Component, PropTypes } from 'react';
+
+export default class List extends Component {
+ render() {
+ const {
+ isFetching, nextPageUrl, pageCount,
+ items, renderItem, loadingLabel
+ } = this.props;
+
+ const isEmpty = items.length === 0;
+ if (isEmpty && isFetching) {
+ return
{loadingLabel}
;
+ }
+
+ const isLastPage = !nextPageUrl;
+ if (isEmpty && isLastPage) {
+ return
Nothing here!
;
+ }
+
+ return (
+
+ {items.map(renderItem)}
+ {pageCount > 0 && !isLastPage && this.renderLoadMore()}
+
+ );
+ }
+
+ renderLoadMore() {
+ const { isFetching, onLoadMoreClick } = this.props;
+ return (
+
+ );
+ }
+}
+
+List.propTypes = {
+ loadingLabel: PropTypes.string.isRequired,
+ isFetching: PropTypes.bool.isRequired,
+ onLoadMoreClick: PropTypes.func.isRequired,
+ nextPageUrl: PropTypes.string
+};
+
+List.defaultProps = {
+ isFetching: true,
+ loadingLabel: 'Loading...'
+};
diff --git a/examples/real-world/components/Repo.js b/examples/real-world/components/Repo.js
new file mode 100644
index 0000000000..b9bb4fa8b0
--- /dev/null
+++ b/examples/real-world/components/Repo.js
@@ -0,0 +1,37 @@
+import React, { PropTypes } from 'react';
+import { Link } from 'react-router';
+
+export default class Repo {
+ static propTypes = {
+ repo: PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ description: PropTypes.string
+ }).isRequired,
+ owner: PropTypes.shape({
+ login: PropTypes.string.isRequired
+ }).isRequired
+ }
+
+ render() {
+ const { repo, owner } = this.props;
+ const { login } = owner;
+ const { name, description } = repo;
+
+ return (
+
+
+
+ {name}
+
+ {' by '}
+
+ {login}
+
+
+ {description &&
+
{description}
+ }
+
+ );
+ }
+}
diff --git a/examples/real-world/components/User.js b/examples/real-world/components/User.js
new file mode 100644
index 0000000000..995926738a
--- /dev/null
+++ b/examples/real-world/components/User.js
@@ -0,0 +1,27 @@
+import React, { Component, PropTypes } from 'react';
+import { Link } from 'react-router';
+
+export default class User extends Component {
+ render() {
+ const { login, avatarUrl, name } = this.props.user;
+
+ return (
+
+
+
+
+ {login} {name && ({name})}
+
+
+
+ );
+ }
+}
+
+User.propTypes = {
+ user: PropTypes.shape({
+ login: PropTypes.string.isRequired,
+ avatarUrl: PropTypes.string.isRequired,
+ name: PropTypes.string
+ }).isRequired
+};
diff --git a/examples/real-world/containers/App.js b/examples/real-world/containers/App.js
new file mode 100644
index 0000000000..654c978f8d
--- /dev/null
+++ b/examples/real-world/containers/App.js
@@ -0,0 +1,84 @@
+import React, { Component, PropTypes } from 'react';
+import { connect } from 'react-redux';
+import Explore from '../components/Explore';
+import { resetErrorMessage } from '../actions';
+
+class App extends Component {
+ constructor(props) {
+ super(props);
+ this.handleChange = this.handleChange.bind(this);
+ this.handleDismissClick = this.handleDismissClick.bind(this);
+ }
+
+ render() {
+ // Injected by React Router
+ const { location, children } = this.props;
+ const { pathname } = location;
+ const value = pathname.substring(1);
+
+ return (
+
+
+
+ {this.renderErrorMessage()}
+ {children}
+
+ );
+ }
+
+ renderErrorMessage() {
+ const { errorMessage } = this.props;
+ if (!errorMessage) {
+ return null;
+ }
+
+ return (
+
+ {errorMessage}
+ {' '}
+ (
+ Dismiss
+ )
+
+ );
+ }
+
+ handleDismissClick(e) {
+ this.props.resetErrorMessage();
+ e.preventDefault();
+ }
+
+ handleChange(nextValue) {
+ // Available thanks to contextTypes below
+ const { router } = this.context;
+ router.transitionTo(`/${nextValue}`);
+ }
+}
+
+App.propTypes = {
+ errorMessage: PropTypes.string,
+ location: PropTypes.shape({
+ pathname: PropTypes.string.isRequired
+ }),
+ params: PropTypes.shape({
+ userLogin: PropTypes.string,
+ repoName: PropTypes.string
+ }).isRequired
+};
+
+App.contextTypes = {
+ router: PropTypes.object.isRequired
+};
+
+function mapStateToProps(state) {
+ return {
+ errorMessage: state.errorMessage
+ };
+}
+
+export default connect(
+ mapStateToProps,
+ { resetErrorMessage }
+)(App);
diff --git a/examples/real-world/containers/RepoPage.js b/examples/real-world/containers/RepoPage.js
new file mode 100644
index 0000000000..3499cffbaa
--- /dev/null
+++ b/examples/real-world/containers/RepoPage.js
@@ -0,0 +1,106 @@
+import React, { Component, PropTypes } from 'react';
+import { connect } from 'react-redux';
+import { loadRepo, loadStargazers } from '../actions';
+import Repo from '../components/Repo';
+import User from '../components/User';
+import List from '../components/List';
+
+function loadData(props) {
+ const { fullName } = props;
+ props.loadRepo(fullName, ['description']);
+ props.loadStargazers(fullName);
+}
+
+class RepoPage extends Component {
+ constructor(props) {
+ super(props);
+ this.renderUser = this.renderUser.bind(this);
+ this.handleLoadMoreClick = this.handleLoadMoreClick.bind(this);
+ }
+
+ componentWillMount() {
+ loadData(this.props);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.fullName !== this.props.fullName) {
+ loadData(nextProps);
+ }
+ }
+
+ render() {
+ const { repo, owner, name } = this.props;
+ if (!repo || !owner) {
+ return
Loading {name} details...
;
+ }
+
+ const { stargazers, stargazersPagination } = this.props;
+ return (
+
+
+
+
+
+ );
+ }
+
+ renderUser(user) {
+ return (
+
+ );
+ }
+
+ handleLoadMoreClick() {
+ this.props.loadStargazers(this.props.fullName, true);
+ }
+}
+
+RepoPage.propTypes = {
+ repo: PropTypes.object,
+ fullName: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ stargazers: PropTypes.array.isRequired,
+ stargazersPagination: PropTypes.object,
+ loadRepo: PropTypes.func.isRequired,
+ loadStargazers: PropTypes.func.isRequired
+};
+
+function mapStateToProps(state) {
+ return {
+ entities: state.entities,
+ stargazersByRepo: state.pagination.stargazersByRepo
+ };
+}
+
+function mergeProps(stateProps, dispatchProps, ownProps) {
+ const { entities, stargazersByRepo } = stateProps;
+ const { login, name } = ownProps.params;
+
+ const fullName = `${login}/${name}`;
+ const repo = entities.repos[fullName];
+ const owner = entities.users[login];
+
+ const stargazersPagination = stargazersByRepo[fullName] || { ids: [] };
+ const stargazers = stargazersPagination.ids.map(id => entities.users[id]);
+
+ return Object.assign({}, dispatchProps, {
+ fullName,
+ name,
+ repo,
+ owner,
+ stargazers,
+ stargazersPagination
+ });
+}
+
+export default connect(
+ mapStateToProps,
+ { loadRepo, loadStargazers },
+ mergeProps
+)(RepoPage);
diff --git a/examples/real-world/containers/Root.js b/examples/real-world/containers/Root.js
new file mode 100644
index 0000000000..355e786cd1
--- /dev/null
+++ b/examples/real-world/containers/Root.js
@@ -0,0 +1,30 @@
+import React, { Component } from 'react';
+import { Provider } from 'react-redux';
+import { Router, Route } from 'react-router';
+import createAsyncExampleStore from '../store/createAsyncExampleStore';
+import App from './App';
+import UserPage from './UserPage';
+import RepoPage from './RepoPage';
+
+const store = createAsyncExampleStore();
+
+export default class Root extends Component {
+ render() {
+ return (
+
+
+ {() =>
+
+
+
+
+
+
+ }
+
+
+ );
+ }
+}
diff --git a/examples/real-world/containers/UserPage.js b/examples/real-world/containers/UserPage.js
new file mode 100644
index 0000000000..4b333a8012
--- /dev/null
+++ b/examples/real-world/containers/UserPage.js
@@ -0,0 +1,104 @@
+import React, { Component, PropTypes } from 'react';
+import { connect } from 'react-redux';
+import { loadUser, loadStarred } from '../actions';
+import User from '../components/User';
+import Repo from '../components/Repo';
+import List from '../components/List';
+import zip from 'lodash/array/zip';
+
+function loadData(props) {
+ const { login } = props;
+ props.loadUser(login, ['name']);
+ props.loadStarred(login);
+}
+
+class UserPage extends Component {
+ constructor(props) {
+ super(props);
+ this.renderRepo = this.renderRepo.bind(this);
+ this.handleLoadMoreClick = this.handleLoadMoreClick.bind(this);
+ }
+
+ componentWillMount() {
+ loadData(this.props);
+ }
+
+ componentWillReceiveProps(nextProps) {
+ if (nextProps.login !== this.props.login) {
+ loadData(nextProps);
+ }
+ }
+
+ render() {
+ const { user, login } = this.props;
+ if (!user) {
+ return
Loading {login}’s profile...
;
+ }
+
+ const { starredRepos, starredRepoOwners, starredPagination } = this.props;
+ return (
+
+
+
+
+
+ );
+ }
+
+ renderRepo([repo, owner]) {
+ return (
+
+ );
+ }
+
+ handleLoadMoreClick() {
+ this.props.loadStarred(this.props.login, true);
+ }
+}
+
+UserPage.propTypes = {
+ login: PropTypes.string.isRequired,
+ user: PropTypes.object,
+ starredPagination: PropTypes.object,
+ starredRepos: PropTypes.array.isRequired,
+ starredRepoOwners: PropTypes.array.isRequired,
+ loadUser: PropTypes.func.isRequired,
+ loadStarred: PropTypes.func.isRequired
+};
+
+function mapStateToProps(state) {
+ return {
+ entities: state.entities,
+ starredByUser: state.pagination.starredByUser
+ };
+}
+
+function mergeProps(stateProps, dispatchProps, ownProps) {
+ const { entities, starredByUser } = stateProps;
+ const { login } = ownProps.params;
+
+ const user = entities.users[login];
+ const starredPagination = starredByUser[login] || { ids: [] };
+ const starredRepos = starredPagination.ids.map(id => entities.repos[id]);
+ const starredRepoOwners = starredRepos.map(repo => entities.users[repo.owner]);
+
+ return Object.assign({}, dispatchProps, {
+ login,
+ user,
+ starredPagination,
+ starredRepos,
+ starredRepoOwners
+ });
+}
+
+export default connect(
+ mapStateToProps,
+ { loadUser, loadStarred },
+ mergeProps
+)(UserPage);
diff --git a/examples/real-world/index.html b/examples/real-world/index.html
new file mode 100644
index 0000000000..c26e4dad44
--- /dev/null
+++ b/examples/real-world/index.html
@@ -0,0 +1,10 @@
+
+
+
Redux real-world example
+
+
+
+
+
+
+
diff --git a/examples/real-world/index.js b/examples/real-world/index.js
new file mode 100644
index 0000000000..7127e34fa7
--- /dev/null
+++ b/examples/real-world/index.js
@@ -0,0 +1,9 @@
+import 'babel-core/polyfill';
+import React from 'react';
+import Root from './containers/Root';
+import BrowserHistory from 'react-router/lib/BrowserHistory';
+
+React.render(
+
,
+ document.getElementById('root')
+);
diff --git a/examples/real-world/middleware/api.js b/examples/real-world/middleware/api.js
new file mode 100644
index 0000000000..1b898d45e8
--- /dev/null
+++ b/examples/real-world/middleware/api.js
@@ -0,0 +1,142 @@
+import { Schema, arrayOf, normalize } from 'normalizr';
+import { camelizeKeys } from 'humps';
+import 'isomorphic-fetch';
+
+/**
+ * Extracts the next page URL from Github API response.
+ */
+function getNextPageUrl(response) {
+ const link = response.headers.get('link');
+ if (!link) {
+ return null;
+ }
+
+ const nextLink = link.split(',').filter(s => s.indexOf('rel="next"') > -1)[0];
+ if (!nextLink) {
+ return null;
+ }
+
+ return nextLink.split(';')[0].slice(1, -1);
+}
+
+const API_ROOT = 'https://api.github.com/';
+
+/**
+ * Fetches an API response and normalizes the result JSON according to schema.
+ * This makes every API response have the same shape, regardless of how nested it was.
+ */
+function callApi(endpoint, schema) {
+ if (endpoint.indexOf(API_ROOT) === -1) {
+ endpoint = API_ROOT + endpoint;
+ }
+
+ return fetch(endpoint)
+ .then(response =>
+ response.json().then(json => ({ json, response}))
+ ).then(({ json, response }) => {
+ if (!response.ok) {
+ return Promise.reject(json);
+ }
+
+ const camelizedJson = camelizeKeys(json);
+ const nextPageUrl = getNextPageUrl(response) || undefined;
+
+ return {
+ ...normalize(camelizedJson, schema),
+ nextPageUrl
+ };
+ });
+}
+
+// We use this Normalizr schemas to transform API responses from a nested form
+// to a flat form where repos and users are placed in `entities`, and nested
+// JSON objects are replaced with their IDs. This is very convenient for
+// consumption by reducers, because we can easily build a normalized tree
+// and keep it updated as we fetch more data.
+
+// Read more about Normalizr: https://github.com/gaearon/normalizr
+
+const userSchema = new Schema('users', {
+ idAttribute: 'login'
+});
+
+const repoSchema = new Schema('repos', {
+ idAttribute: 'fullName'
+});
+
+repoSchema.define({
+ owner: userSchema
+});
+
+/**
+ * Schemas for Github API responses.
+ */
+export const Schemas = {
+ USER: userSchema,
+ USER_ARRAY: arrayOf(userSchema),
+ REPO: repoSchema,
+ REPO_ARRAY: arrayOf(repoSchema)
+};
+
+/**
+ * Action key that carries API call info interpreted by this Redux middleware.
+ */
+export const CALL_API = Symbol('Call API');
+
+/**
+ * A Redux middleware that interprets actions with CALL_API info specified.
+ * Performs the call and promises when such actions are dispatched.
+ */
+export default store => next => action => {
+ const callAPI = action[CALL_API];
+ if (typeof callAPI === 'undefined') {
+ return next(action);
+ }
+
+ let { endpoint } = callAPI;
+ const { schema, types, bailout } = callAPI;
+
+ if (typeof endpoint === 'function') {
+ endpoint = endpoint(store.getState());
+ }
+
+ if (typeof endpoint !== 'string') {
+ throw new Error('Specify a string endpoint URL.');
+ }
+ if (!schema) {
+ throw new Error('Specify on of the exported Schemas.');
+ }
+ if (!Array.isArray(types) || types.length !== 3) {
+ throw new Error('Expected an array of three action types.');
+ }
+ if (!types.every(type => typeof type === 'string')) {
+ throw new Error('Expected action types to be strings.');
+ }
+ if (typeof bailout !== 'undefined' && typeof bailout !== 'function') {
+ throw new Error('Expected bailout to either be undefined or a function.');
+ }
+
+ if (bailout && bailout(store.getState())) {
+ return Promise.resolve();
+ }
+
+ function actionWith(data) {
+ const finalAction = Object.assign({}, action, data);
+ delete finalAction[CALL_API];
+ return finalAction;
+ }
+
+ const [requestType, successType, failureType] = types;
+ next(actionWith({ type: requestType }));
+
+ return callApi(endpoint, schema).then(
+ response => next(actionWith({
+ response,
+ type: successType
+ })),
+ error => next(actionWith({
+ type: failureType,
+ error: error.message || 'Something bad happened'
+ }))
+ );
+};
diff --git a/examples/real-world/package.json b/examples/real-world/package.json
new file mode 100644
index 0000000000..7b24a18702
--- /dev/null
+++ b/examples/real-world/package.json
@@ -0,0 +1,47 @@
+{
+ "name": "redux-real-world-example",
+ "version": "0.0.0",
+ "description": "Redux real-world example",
+ "main": "server.js",
+ "scripts": {
+ "start": "node server.js"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/gaearon/redux.git"
+ },
+ "keywords": [
+ "react",
+ "reactjs",
+ "hot",
+ "reload",
+ "hmr",
+ "live",
+ "edit",
+ "webpack",
+ "flux"
+ ],
+ "license": "MIT",
+ "bugs": {
+ "url": "https://github.com/gaearon/redux/issues"
+ },
+ "homepage": "https://github.com/gaearon/redux#readme",
+ "dependencies": {
+ "humps": "^0.6.0",
+ "isomorphic-fetch": "^2.1.1",
+ "lodash": "^3.10.1",
+ "normalizr": "^0.1.3",
+ "react": "^0.13.3",
+ "react-redux": "^0.8.0",
+ "react-router": "^1.0.0-beta3",
+ "redux": "^1.0.0-rc",
+ "redux-thunk": "^0.1.0"
+ },
+ "devDependencies": {
+ "babel-core": "^5.6.18",
+ "babel-loader": "^5.1.4",
+ "react-hot-loader": "^1.2.7",
+ "webpack": "^1.9.11",
+ "webpack-dev-server": "^1.9.0"
+ }
+}
diff --git a/examples/real-world/reducers/index.js b/examples/real-world/reducers/index.js
new file mode 100644
index 0000000000..125a0f5c4e
--- /dev/null
+++ b/examples/real-world/reducers/index.js
@@ -0,0 +1,52 @@
+import * as ActionTypes from '../actions';
+import merge from 'lodash/object/merge';
+import paginate from './paginate';
+import { combineReducers } from 'redux';
+
+/**
+ * Updates an entity cache in response to any action with response.entities.
+ */
+export function entities(state = { users: {}, repos: {} }, action) {
+ if (action.response && action.response.entities) {
+ return merge({}, state, action.response.entities);
+ }
+
+ return state;
+}
+
+/**
+ * Updates error message to notify about the failed fetches.
+ */
+export function errorMessage(state = null, action) {
+ const { type, error } = action;
+
+ if (type === ActionTypes.RESET_ERROR_MESSAGE) {
+ return null;
+ } else if (error) {
+ return action.error;
+ }
+
+ return state;
+}
+
+/**
+ * Updates the pagination data for different actions.
+ */
+export const pagination = combineReducers({
+ starredByUser: paginate({
+ mapActionToKey: action => action.login,
+ types: [
+ ActionTypes.STARRED_REQUEST,
+ ActionTypes.STARRED_SUCCESS,
+ ActionTypes.STARRED_FAILURE
+ ]
+ }),
+ stargazersByRepo: paginate({
+ mapActionToKey: action => action.fullName,
+ types: [
+ ActionTypes.STARGAZERS_REQUEST,
+ ActionTypes.STARGAZERS_SUCCESS,
+ ActionTypes.STARGAZERS_FAILURE
+ ]
+ })
+});
diff --git a/examples/real-world/reducers/paginate.js b/examples/real-world/reducers/paginate.js
new file mode 100644
index 0000000000..05c682edd5
--- /dev/null
+++ b/examples/real-world/reducers/paginate.js
@@ -0,0 +1,64 @@
+import merge from 'lodash/object/merge';
+import union from 'lodash/array/union';
+
+/**
+ * Creates a reducer managing pagination, given the action types to handle,
+ * and a function telling how to extract the key from an action.
+ */
+export default function paginate({ types, mapActionToKey }) {
+ if (!Array.isArray(types) || types.length !== 3) {
+ throw new Error('Expected types to be an array of three elements.');
+ }
+ if (!types.every(t => typeof t === 'string')) {
+ throw new Error('Expected types to be strings.');
+ }
+ if (typeof mapActionToKey !== 'function') {
+ throw new Error('Expected mapActionToKey to be a function.');
+ }
+
+ const [requestType, successType, failureType] = types;
+
+ function updatePagination(state = {
+ isFetching: false,
+ nextPageUrl: undefined,
+ pageCount: 0,
+ ids: []
+ }, action) {
+ switch (action.type) {
+ case requestType:
+ return merge({}, state, {
+ isFetching: true
+ });
+ case successType:
+ return merge({}, state, {
+ isFetching: false,
+ ids: union(state.ids, action.response.result),
+ nextPageUrl: action.response.nextPageUrl,
+ pageCount: state.pageCount + 1
+ });
+ case failureType:
+ return merge({}, state, {
+ isFetching: false
+ });
+ default:
+ return state;
+ }
+ }
+
+ return function updatePaginationByKey(state = {}, action) {
+ switch (action.type) {
+ case requestType:
+ case successType:
+ case failureType:
+ const key = mapActionToKey(action);
+ if (typeof key !== 'string') {
+ throw new Error('Expected key to be a string.');
+ }
+ return merge({}, state, {
+ [key]: updatePagination(state[key], action)
+ });
+ default:
+ return state;
+ }
+ };
+}
diff --git a/examples/real-world/server.js b/examples/real-world/server.js
new file mode 100644
index 0000000000..ff92aa06b6
--- /dev/null
+++ b/examples/real-world/server.js
@@ -0,0 +1,18 @@
+var webpack = require('webpack');
+var WebpackDevServer = require('webpack-dev-server');
+var config = require('./webpack.config');
+
+new WebpackDevServer(webpack(config), {
+ publicPath: config.output.publicPath,
+ hot: true,
+ historyApiFallback: true,
+ stats: {
+ colors: true
+ }
+}).listen(3000, 'localhost', function (err) {
+ if (err) {
+ console.log(err);
+ }
+
+ console.log('Listening at localhost:3000');
+});
diff --git a/examples/real-world/store/createAsyncExampleStore.js b/examples/real-world/store/createAsyncExampleStore.js
new file mode 100644
index 0000000000..7f8d4f6ea7
--- /dev/null
+++ b/examples/real-world/store/createAsyncExampleStore.js
@@ -0,0 +1,17 @@
+import { createStore, applyMiddleware, combineReducers } from 'redux';
+import thunkMiddleware from 'redux-thunk';
+import apiMiddleware from '../middleware/api';
+import * as reducers from '../reducers';
+
+const reducer = combineReducers(reducers);
+const createStoreWithMiddleware = applyMiddleware(
+ thunkMiddleware,
+ apiMiddleware
+)(createStore);
+
+/**
+ * Creates a preconfigured store for this example.
+ */
+export default function createAsyncExampleStore(initialState) {
+ return createStoreWithMiddleware(reducer, initialState);
+}
diff --git a/examples/real-world/webpack.config.js b/examples/real-world/webpack.config.js
new file mode 100644
index 0000000000..2061e19a48
--- /dev/null
+++ b/examples/real-world/webpack.config.js
@@ -0,0 +1,38 @@
+var path = require('path');
+var webpack = require('webpack');
+
+module.exports = {
+ devtool: 'eval',
+ entry: [
+ 'webpack-dev-server/client?http://localhost:3000',
+ 'webpack/hot/only-dev-server',
+ './index'
+ ],
+ output: {
+ path: path.join(__dirname, 'dist'),
+ filename: 'bundle.js',
+ publicPath: '/static/'
+ },
+ plugins: [
+ new webpack.HotModuleReplacementPlugin(),
+ new webpack.NoErrorsPlugin()
+ ],
+ resolve: {
+ alias: {
+ 'redux': path.join(__dirname, '..', '..', 'src')
+ },
+ extensions: ['', '.js']
+ },
+ module: {
+ loaders: [{
+ test: /\.js$/,
+ loaders: ['react-hot', 'babel'],
+ exclude: /node_modules/,
+ include: __dirname
+ }, {
+ test: /\.js$/,
+ loaders: ['babel'],
+ include: path.join(__dirname, '..', '..', 'src')
+ }]
+ }
+};
diff --git a/examples/todomvc/index.html b/examples/todomvc/index.html
index ce8e8222e9..3a5d65bf3e 100644
--- a/examples/todomvc/index.html
+++ b/examples/todomvc/index.html
@@ -1,6 +1,6 @@
-
Redux TodoMVC
+
Redux TodoMVC example
diff --git a/examples/todomvc/package.json b/examples/todomvc/package.json
index fbe0328dcc..be44925d67 100644
--- a/examples/todomvc/package.json
+++ b/examples/todomvc/package.json
@@ -1,7 +1,7 @@
{
- "name": "todomvc",
+ "name": "redux-todomvc-example",
"version": "0.0.0",
- "description": "TodoMVC example for redux",
+ "description": "Redux TodoMVC example",
"main": "server.js",
"scripts": {
"start": "node server.js",