├── .babelrc
├── .eslintrc
├── .gitignore
├── LICENSE
├── README.md
├── loaders
├── sass-loader.js
└── scope-name.js
├── package.json
├── src
├── client
│ └── index.js
├── components
│ ├── about
│ │ ├── About.js
│ │ ├── About.scss
│ │ └── index.js
│ ├── app
│ │ ├── App.js
│ │ ├── App.scss
│ │ └── index.js
│ ├── home
│ │ ├── Home.js
│ │ ├── Home.scss
│ │ └── index.js
│ └── not-found
│ │ ├── NotFound.js
│ │ ├── NotFound.scss
│ │ └── index.js
├── containers
│ ├── About.js
│ ├── App.js
│ ├── Home.js
│ └── NotFound.js
├── redux
│ ├── actions
│ │ ├── about
│ │ │ ├── getData.js
│ │ │ └── index.js
│ │ └── home
│ │ │ ├── getData.js
│ │ │ └── index.js
│ ├── reducers
│ │ ├── about.js
│ │ ├── home.js
│ │ └── index.js
│ └── types
│ │ ├── about.js
│ │ ├── home.js
│ │ └── prefixer.js
├── server
│ ├── handleRender.js
│ ├── index.js
│ └── renderFullPage.js
└── universal
│ ├── createReduxStore.js
│ └── routes.js
├── webpack.config.dev.js
├── webpack.config.prod.js
└── yarn.lock
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["@babel/preset-env", "@babel/preset-react"],
3 | "env": {
4 | "server": {
5 | "plugins": [
6 | [
7 | "css-modules-transform",
8 | {
9 | "preprocessCss": "./loaders/sass-loader.js",
10 | "generateScopedName": "./loaders/scope-name.js",
11 | "extensions": [".scss", ".css"]
12 | }
13 | ]
14 | ]
15 | }
16 | }
17 | }
18 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "babel-eslint",
3 | "extends": "airbnb",
4 | "env": {
5 | "browser": true,
6 | "node": true
7 | },
8 | "rules": {
9 | "arrow-parens": ["off"],
10 | "consistent-return": "off",
11 | "comma-dangle": "off",
12 | "no-use-before-define": "off",
13 | "import/no-unresolved": ["error", { "ignore": ["electron"] }],
14 | "import/no-extraneous-dependencies": "off",
15 | "react/jsx-no-bind": "off",
16 | "promise/param-names": 2,
17 | "promise/always-return": 2,
18 | "promise/catch-or-return": 2,
19 | "promise/no-native": 0,
20 | "react/jsx-filename-extension": ["error", { "extensions": [".js", ".jsx"] }],
21 | "react/prefer-stateless-function": "off",
22 | "generator-star-spacing": "off",
23 | "new-cap": 0,
24 | "class-methods-use-this": 0,
25 | "arrow-body-style": 0
26 | },
27 | "plugins": [
28 | "import",
29 | "promise",
30 | "react"
31 | ]
32 | }
33 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | .DS_Store
4 | npm-debug.log
5 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2018 William Woodhead
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Simple Universal React Redux
2 |
3 | ### The simplest possible Async Universal React & Redux boilerplate.
4 |
5 | This repo is an attempt to make the simplest server-side rendered (universal) async React Redux app.
6 |
7 | Boilerplates can be a great for two things:
8 |
9 | 1. Get started with your application code quickly since you don't have to scaffold your app.
10 | 1. Learn how apps can be scaffolded, and learn how technologies can fit together.
11 |
12 | This repository is more aimed at the second point.
13 |
14 | It was born out of frustrations with complex boilerplates where you can't understand what is going on behind the scenes. Developers tend to want to know how things work under the hood. This repo offers a boiled-down example to be tweaked and hacked around with.
15 |
16 | It tries to be as un-opinionated and simple as possible.
17 |
18 | It borrows heavily from the documentation of [Redux](https://redux.js.org/) and [React-Router](https://reacttraining.com/react-router/web).
19 |
20 | ### These are the technologies it uses:
21 |
22 | #### For the app
23 |
24 | - [React](https://reactjs.org/)
25 | - [Redux](https://redux.js.org/)
26 | - [React-router](https://reacttraining.com/react-router/web)
27 | - [Express](http://expressjs.com/)
28 | - [Yarn](https://yarnpkg.com/lang/en/)
29 |
30 | #### Build tools
31 |
32 | - [Babel for ES6 Javascript](https://babeljs.io/)
33 | - [Webpack 4](https://webpack.js.org/)
34 | - [Sass](http://sass-lang.com/)
35 | - [Nodemon](https://nodemon.io/)
36 | - [ESlint](https://eslint.org/)
37 |
38 | ## Commands
39 |
40 | ###### Install
41 |
42 | ```bash
43 | yarn install
44 | ```
45 |
46 | ###### Develop
47 |
48 | ```bash
49 | yarn run dev
50 | ```
51 |
52 | Open [localhost:3000](http://localhost:3000)
53 |
54 | ###### Build for production
55 |
56 | ```bash
57 | yarn run build
58 | ```
59 |
60 | ###### Run in production
61 |
62 | ```bash
63 | yarn run start
64 | ```
65 |
66 | Open [localhost:3000](http://localhost:3000)
67 |
68 | ## Platform
69 |
70 | This repo is developed and tested on Mac OS with **node v10.10.0** and **npm v6.7.0**
71 |
72 | #### Windows
73 |
74 | This repo is tested on Windows. You might have to install nodemon globally though.
75 |
76 | ```bash
77 | npm i -g nodemon
78 | ```
79 |
80 | ## Documentation
81 |
82 | #### Server side
83 |
84 | Everything starts with the Express App.
85 | You can find this in `src/server/index.js`
86 |
87 | Here we can see that all requests are routed to the `handleRender` function:
88 |
89 | ```javascript
90 | app.use(handleRender);
91 | ```
92 |
93 | **The handleRender function does a number of things:**
94 |
95 | 1. Create a new redux store on every request from the client
96 | 1. Match the request path (`req.path`) to the react router routes specified in `src/universal/routes`
97 | 1. Asynchronously fetch the data required to render this route (using the route's `loadData` function)
98 | 1. Use react-dom/server `renderToString` function to create the required html
99 | 1. Insert the html and redux preloadedState into a full html page using the `renderFullPage` function
100 | 1. Send the response to the client `res.send(`
101 |
102 | #### Client side
103 |
104 | For the client side the index file is `src/client/index.js`
105 |
106 | In this file, we use the redux `preloadedState` provided by the server to initialise a client side redux store.
107 |
108 | We then use the React `hydrate` function to initialise React on the client side.
109 |
110 | In the React components, any asynchronous data is fetched in `componentDidMount`. If data already exists, the component will not make the fetch.
111 |
112 | ```javascript
113 | componentDidMount() {
114 | // only fetch the data if there is no data
115 | if (!this.props.data) this.props.getData();
116 | }
117 | ```
118 |
119 | In this way, components won't make requests for data if the data has already been requested server side.
120 |
121 | #### React Router
122 |
123 | The difference in the react tree between server side and client side is as follows:
124 |
125 | **Server** `src/server/handleRender.js`
126 |
127 | ```jsx
128 |
129 |
130 |
131 |
132 |
133 | ```
134 |
135 | **Client** `src/client/index.js`
136 |
137 | ```jsx
138 |
139 |
140 |
141 |
142 |
143 | ```
144 |
145 | Everything else in the entire React tree is the same between server and client.
146 |
147 | ## Contributing
148 |
149 | Any issues, reports, feedback or bugs or pull requests are more than welcome.
150 |
151 | However it is worth mentioning that the purpose of this repo is to create the **simplest**, **most up-to-date**, **most robust** universal async react redux boilerplate.
152 |
153 | Therefore any pull request should aim to simplify, fix or update the current solution, not add new packages or complexity.
154 |
155 | ## License
156 |
157 | MIT License
158 |
159 | Copyright (c) 2019 William Woodhead
160 |
161 | ## Have a play around
162 |
163 | Good luck with it!
164 | Please star or follow on twitter:
165 | [@williamwoodhead](https://twitter.com/williamwoodhead)
166 |
--------------------------------------------------------------------------------
/loaders/sass-loader.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const sass = require('node-sass');
3 |
4 | // this module stop .scss files breaking the server side render
5 | // this file is referenced in .babelrc
6 | module.exports = (data, file) => {
7 | if (path.extname(file) === '.css') return data;
8 | try {
9 | return sass.renderSync({ data, file }).css.toString('utf8');
10 | } catch (e) {
11 | console.error(e);
12 | }
13 | };
14 |
--------------------------------------------------------------------------------
/loaders/scope-name.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const loaderUtils = require('loader-utils');
3 |
4 | module.exports = (data, file) => {
5 | const { ext, name } = path.parse(file);
6 | if (ext === '.css') return data;
7 | return loaderUtils.interpolateName({}, `${name}__${data}___[hash:base64:5]`, {
8 | content: `${path.relative(path.resolve(__dirname, '..'), file)}+${data}`
9 | });
10 | };
11 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "simple-universal-react-redux",
3 | "version": "0.10.1",
4 | "description": "The simplest Async Universal React & Redux boilerplate I could possibly make.",
5 | "main": "src/server/index.js",
6 | "scripts": {
7 | "clean": "rimraf dist",
8 | "dev-server": "cross-env BABEL_ENV=server NODE_ENV=development nodemon ./src/server/index.js --exec babel-node --ignore dist/ -e js,scss,css",
9 | "dev-client": "cross-env NODE_ENV=development webpack --config ./webpack.config.dev.js",
10 | "dev": "concurrently \"cross-env NODE_ENV=development npm run dev-server\" \"cross-env NODE_ENV=development npm run dev-client\"",
11 | "prod-server": "cross-env BABEL_ENV=server NODE_ENV=production babel src --out-dir ./dist",
12 | "prod-client": "cross-env NODE_ENV=production webpack --config ./webpack.config.prod.js",
13 | "build": "npm run clean && npm run prod-server && npm run prod-client",
14 | "start": "cross-env NODE_ENV=production node ./dist/server/index.js"
15 | },
16 | "author": "William Woodhead",
17 | "license": "MIT",
18 | "repository": {
19 | "type": "git",
20 | "url": "https://github.com/william-woodhead/simple-universal-react-redux"
21 | },
22 | "homepage": "https://github.com/william-woodhead/simple-universal-react-redux",
23 | "keywords": [
24 | "react",
25 | "isomorphic",
26 | "universal",
27 | "webpack",
28 | "express",
29 | "redux",
30 | "boilerplate",
31 | "babel",
32 | "express",
33 | "react-router"
34 | ],
35 | "dependencies": {
36 | "express": "^4.16.4",
37 | "loader-utils": "^1.2.3",
38 | "lodash": "^4.17.11",
39 | "react": "^16.7.0",
40 | "react-dom": "^16.7.0",
41 | "react-redux": "^6.0.0",
42 | "react-router": "^4.3.1",
43 | "react-router-dom": "^4.3.1",
44 | "redux": "^4.0.1",
45 | "redux-thunk": "^2.3.0"
46 | },
47 | "devDependencies": {
48 | "@babel/cli": "^7.2.3",
49 | "@babel/core": "^7.2.2",
50 | "@babel/node": "^7.2.2",
51 | "@babel/plugin-transform-react-jsx": "^7.3.0",
52 | "@babel/polyfill": "^7.2.5",
53 | "@babel/preset-env": "^7.3.1",
54 | "@babel/preset-react": "^7.0.0",
55 | "babel-eslint": "^10.0.1",
56 | "babel-loader": "^8.0.5",
57 | "babel-plugin-css-modules-transform": "^1.6.2",
58 | "concurrently": "^4.1.0",
59 | "cross-env": "^5.2.0",
60 | "css-loader": "^2.1.0",
61 | "eslint": "^5.12.1",
62 | "eslint-config-airbnb": "^17.1.0",
63 | "eslint-formatter-pretty": "^2.1.1",
64 | "eslint-import-resolver-webpack": "^0.11.0",
65 | "eslint-loader": "^2.1.1",
66 | "eslint-plugin-import": "^2.16.0",
67 | "eslint-plugin-jsx-a11y": "^6.2.0",
68 | "eslint-plugin-promise": "^4.0.1",
69 | "eslint-plugin-react": "^7.12.4",
70 | "mini-css-extract-plugin": "^0.5.0",
71 | "node-sass": "^4.11.0",
72 | "nodemon": "^1.18.9",
73 | "redux-logger": "^3.0.6",
74 | "sass-loader": "^7.1.0",
75 | "style-loader": "^0.23.1",
76 | "webpack": "^4.29.0",
77 | "webpack-cli": "^3.2.1"
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/client/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { hydrate } from 'react-dom';
3 | import { BrowserRouter } from 'react-router-dom';
4 | import { Provider } from 'react-redux';
5 | import Router from '../universal/routes';
6 | import createReduxStore from '../universal/createReduxStore';
7 |
8 | // Grab the state from a global variable injected into the server-generated HTML
9 | const preloadedState = window.__PRELOADED_STATE__; // eslint-disable-line no-underscore-dangle
10 |
11 | // Allow the passed state to be garbage-collected
12 | delete window.__PRELOADED_STATE__; // eslint-disable-line no-underscore-dangle
13 |
14 | // Create Redux store with initial state
15 | const store = createReduxStore({ preloadedState });
16 |
17 | hydrate(
18 |
19 |
20 |
21 |
22 | ,
23 | document.getElementById('root')
24 | );
25 |
--------------------------------------------------------------------------------
/src/components/about/About.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React, { Component } from 'react';
3 | import styles from './About.scss';
4 |
5 | export default class About extends Component {
6 | componentDidMount() {
7 | // only fetch the data if there is no data
8 | if (!this.props.data) this.props.getData();
9 | }
10 |
11 | render() {
12 | const { data } = this.props;
13 | if (!data) return 'Loading async data...';
14 |
15 | return (
16 |
17 |
About page
18 |
Async Text: {data.text}
19 |
20 | );
21 | }
22 | }
23 |
24 | About.propTypes = {
25 | data: PropTypes.shape({
26 | text: PropTypes.string
27 | }),
28 | getData: PropTypes.func.isRequired
29 | };
30 |
31 | About.defaultProps = {
32 | data: null
33 | };
34 |
--------------------------------------------------------------------------------
/src/components/about/About.scss:
--------------------------------------------------------------------------------
1 | .About {
2 | color: #6bbdb9;
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/about/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './About';
2 |
--------------------------------------------------------------------------------
/src/components/app/App.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsx-a11y/anchor-is-valid */
2 | import PropTypes from 'prop-types';
3 | import { Link } from 'react-router-dom';
4 | import React, { Component } from 'react';
5 | import styles from './App.scss';
6 |
7 | export default class App extends Component {
8 | render() {
9 | return (
10 |
11 |
12 | Home
13 | About
14 |
15 | {this.props.children}
16 |
William Woodhead - MIT License
17 |
18 | );
19 | }
20 | }
21 |
22 | App.propTypes = {
23 | children: PropTypes.node.isRequired
24 | };
25 |
--------------------------------------------------------------------------------
/src/components/app/App.scss:
--------------------------------------------------------------------------------
1 | .App {
2 | font-size: 1.5em;
3 | }
4 |
5 | .TopBar {
6 | display: flex;
7 | }
8 |
9 | .Link {
10 | display: inline-block;
11 |
12 | & + & {
13 | margin-left: 8px;
14 | }
15 | }
16 |
17 | .Footer {
18 | color: #AAAAAA;
19 | }
20 |
--------------------------------------------------------------------------------
/src/components/app/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './App';
2 |
--------------------------------------------------------------------------------
/src/components/home/Home.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import React, { Component } from 'react';
3 | import styles from './Home.scss';
4 |
5 | export default class Home extends Component {
6 | componentDidMount() {
7 | // only fetch data if it does not already exist
8 | if (!this.props.data) this.props.getData();
9 | }
10 |
11 | render() {
12 | const { data } = this.props;
13 | if (!data) return 'Loading async data...';
14 |
15 | return (
16 |
17 |
Home page
18 |
Async Text: {data.text}
19 |
20 | );
21 | }
22 | }
23 |
24 | Home.propTypes = {
25 | data: PropTypes.shape({
26 | text: PropTypes.string
27 | }),
28 | getData: PropTypes.func.isRequired
29 | };
30 |
31 | Home.defaultProps = {
32 | data: null
33 | };
34 |
--------------------------------------------------------------------------------
/src/components/home/Home.scss:
--------------------------------------------------------------------------------
1 | .Home {
2 | color: #9d99cb;
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/home/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './Home';
2 |
--------------------------------------------------------------------------------
/src/components/not-found/NotFound.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import styles from './NotFound.scss';
3 |
4 | export default class NotFound extends Component {
5 | render() {
6 | return (
7 |
8 | Route not found
9 |
10 | );
11 | }
12 | }
13 |
--------------------------------------------------------------------------------
/src/components/not-found/NotFound.scss:
--------------------------------------------------------------------------------
1 | .NotFound {
2 | color: inherit;
3 | }
4 |
--------------------------------------------------------------------------------
/src/components/not-found/index.js:
--------------------------------------------------------------------------------
1 | export { default } from './NotFound';
2 |
--------------------------------------------------------------------------------
/src/containers/About.js:
--------------------------------------------------------------------------------
1 | import { bindActionCreators } from 'redux';
2 | import { connect } from 'react-redux';
3 | import About from '../components/about';
4 | import { getAboutData } from '../redux/actions/about';
5 |
6 | const mapStateToProps = (state, ownProps) => {
7 | return {
8 | data: state.about
9 | };
10 | }
11 |
12 | const mapDispatchToProps = (dispatch, ownProps) => {
13 | return bindActionCreators({
14 | getData: getAboutData
15 | }, dispatch);
16 | }
17 |
18 | export default connect(
19 | mapStateToProps,
20 | mapDispatchToProps
21 | )(About);
22 |
--------------------------------------------------------------------------------
/src/containers/App.js:
--------------------------------------------------------------------------------
1 | import { bindActionCreators } from 'redux';
2 | import { connect } from 'react-redux';
3 | import App from '../components/app';
4 |
5 | const mapStateToProps = (state, ownProps) => {
6 | return {};
7 | }
8 |
9 | const mapDispatchToProps = (dispatch, ownProps) => {
10 | return bindActionCreators({}, dispatch);
11 | }
12 |
13 | export default connect(
14 | mapStateToProps,
15 | mapDispatchToProps
16 | )(App);
17 |
--------------------------------------------------------------------------------
/src/containers/Home.js:
--------------------------------------------------------------------------------
1 | import { bindActionCreators } from 'redux';
2 | import { connect } from 'react-redux';
3 | import Home from '../components/home';
4 | import { getHomeData } from '../redux/actions/home';
5 |
6 | const mapStateToProps = (state, ownProps) => {
7 | return {
8 | data: state.home
9 | };
10 | }
11 |
12 | const mapDispatchToProps = (dispatch, ownProps) => {
13 | return bindActionCreators({
14 | getData: getHomeData
15 | }, dispatch);
16 | }
17 |
18 | export default connect(
19 | mapStateToProps,
20 | mapDispatchToProps
21 | )(Home);
22 |
--------------------------------------------------------------------------------
/src/containers/NotFound.js:
--------------------------------------------------------------------------------
1 | import { bindActionCreators } from 'redux';
2 | import { connect } from 'react-redux';
3 | import NotFound from '../components/not-found';
4 |
5 | const mapStateToProps = (state, ownProps) => {
6 | return {};
7 | }
8 |
9 | const mapDispatchToProps = (dispatch, ownProps) => {
10 | return bindActionCreators({}, dispatch);
11 | }
12 |
13 | export default connect(
14 | mapStateToProps,
15 | mapDispatchToProps
16 | )(NotFound);
17 |
--------------------------------------------------------------------------------
/src/redux/actions/about/getData.js:
--------------------------------------------------------------------------------
1 | import TYPE from '../../types/about';
2 |
3 | export default function getData() {
4 | return (dispatch) => {
5 | dispatch({ type: TYPE.REQ_DATA });
6 |
7 | // here is where you can make async api requests for data
8 | return new Promise((resolve, reject) => {
9 | dispatch({
10 | type: TYPE.RES_DATA,
11 | data: { text: 'This is some text for the ABOUT page fetched asynchronously' }
12 | });
13 | return resolve();
14 | });
15 | };
16 | }
17 |
--------------------------------------------------------------------------------
/src/redux/actions/about/index.js:
--------------------------------------------------------------------------------
1 | export { default as getAboutData } from './getData';
2 |
--------------------------------------------------------------------------------
/src/redux/actions/home/getData.js:
--------------------------------------------------------------------------------
1 | import TYPE from '../../types/home';
2 |
3 | export default function getData() {
4 | return (dispatch) => {
5 | dispatch({ type: TYPE.REQ_DATA });
6 |
7 | // here is where you can make async api requests for data
8 | return new Promise((resolve, reject) => {
9 | dispatch({
10 | type: TYPE.RES_DATA,
11 | data: { text: 'This is some text for the HOME page fetched asynchronously' }
12 | });
13 | return resolve();
14 | });
15 | };
16 | }
17 |
--------------------------------------------------------------------------------
/src/redux/actions/home/index.js:
--------------------------------------------------------------------------------
1 | export { default as getHomeData } from './getData';
2 |
--------------------------------------------------------------------------------
/src/redux/reducers/about.js:
--------------------------------------------------------------------------------
1 | import TYPE from '../types/about';
2 |
3 | export default function (state = null, action) {
4 | switch (action.type) {
5 | case TYPE.RES_DATA: return resData(state, action);
6 | default: return state;
7 | }
8 | }
9 |
10 | function resData(state, action) {
11 | const { data } = action;
12 | return data;
13 | }
14 |
--------------------------------------------------------------------------------
/src/redux/reducers/home.js:
--------------------------------------------------------------------------------
1 | import TYPE from '../types/home';
2 |
3 | export default function (state = null, action) {
4 | switch (action.type) {
5 | case TYPE.RES_DATA: return resData(state, action);
6 | default: return state;
7 | }
8 | }
9 |
10 | function resData(state, action) {
11 | const { data } = action;
12 | return data;
13 | }
14 |
--------------------------------------------------------------------------------
/src/redux/reducers/index.js:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux';
2 | import home from './home';
3 | import about from './about';
4 |
5 | const reduxState = combineReducers({
6 | home,
7 | about
8 | });
9 |
10 | export default reduxState;
11 |
--------------------------------------------------------------------------------
/src/redux/types/about.js:
--------------------------------------------------------------------------------
1 | import prefixer from './prefixer';
2 |
3 | const prefix = '@@aboutTypes';
4 |
5 | const types = {
6 | REQ_DATA: null,
7 | RES_DATA: null,
8 | FAIL_DATA: null
9 | };
10 |
11 | export default prefixer(types, prefix);
12 |
--------------------------------------------------------------------------------
/src/redux/types/home.js:
--------------------------------------------------------------------------------
1 | import prefixer from './prefixer';
2 |
3 | const prefix = '@@homeTypes';
4 |
5 | const types = {
6 | REQ_DATA: null,
7 | RES_DATA: null,
8 | FAIL_DATA: null
9 | };
10 |
11 | export default prefixer(types, prefix);
12 |
--------------------------------------------------------------------------------
/src/redux/types/prefixer.js:
--------------------------------------------------------------------------------
1 | import reduce from 'lodash/reduce';
2 |
3 | export default function prefixer(types, prefix) {
4 | return reduce(types, (result, value, key) => {
5 | result[key] = `${prefix}/${key}`; // eslint-disable-line no-param-reassign
6 | return result;
7 | }, {});
8 | }
9 |
--------------------------------------------------------------------------------
/src/server/handleRender.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { renderToString } from 'react-dom/server';
3 | import { matchPath } from 'react-router-dom';
4 | import { StaticRouter } from 'react-router';
5 | import { Provider } from 'react-redux';
6 | import Router, { routes } from '../universal/routes';
7 | import renderFullPage from './renderFullPage';
8 | import createReduxStore from '../universal/createReduxStore';
9 |
10 | export default function handleRender(req, res) {
11 | const promises = [];
12 |
13 | // Create a new Redux store instance for every request
14 | const store = createReduxStore({ server: true });
15 |
16 | let matchedRoute;
17 | // use `some` to imitate `` behavior of selecting only the first to match
18 | routes.some((route) => {
19 | matchedRoute = matchPath(req.path, route);
20 | if (matchedRoute && route.loadData) promises.push(store.dispatch(route.loadData()));
21 | return matchedRoute;
22 | });
23 |
24 | // once all the promises from the routes have been resolved, continue with rendering
25 | return Promise.all(promises).then(() => {
26 | // here is where we actually render the html, once we have the required asnyc data
27 | const html = renderToString( // eslint-disable-line
28 |
29 |
30 |
31 |
32 | );
33 |
34 | // get the preloaded state from the redux store
35 | const preloadedState = store.getState();
36 |
37 | // send a code based on whether the route matched or was not found
38 | return res
39 | .status(matchedRoute && matchedRoute.isExact ? 200 : 404)
40 | .send(renderFullPage(html, preloadedState));
41 | });
42 | }
43 |
--------------------------------------------------------------------------------
/src/server/index.js:
--------------------------------------------------------------------------------
1 | import Express from 'express';
2 | import handleRender from './handleRender';
3 |
4 | // this is a very simple express app designed only for the purpose of this repo
5 | const app = Express();
6 | const port = 3000;
7 |
8 | // server static content
9 | app.use('/dist', Express.static('dist'));
10 |
11 | // register route handler
12 | app.use(handleRender);
13 |
14 | // listen out for incoming requests
15 | app.listen(port, () => {
16 | console.log('app now listening on port', port);
17 | });
18 |
--------------------------------------------------------------------------------
/src/server/renderFullPage.js:
--------------------------------------------------------------------------------
1 | // https://redux.js.org/recipes/server-rendering#inject-initial-component-html-and-state
2 | export default function renderFullPage(html, preloadedState) {
3 | return `
4 |
5 |
6 |
7 | Redux Universal Example
8 | ${process.env.NODE_ENV === 'production' ? '' : ''}
9 |
10 |
11 | ${html}
12 |
17 |
18 |
19 |
20 | `;
21 | }
22 |
--------------------------------------------------------------------------------
/src/universal/createReduxStore.js:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware } from 'redux';
2 | import thunkMiddleware from 'redux-thunk';
3 | import { createLogger } from 'redux-logger';
4 | import reduxState from '../redux/reducers';
5 |
6 | const loggerMiddleware = createLogger();
7 |
8 | export default function createReduxStore({ preloadedState, server } = {}) {
9 | let enhancer;
10 |
11 | if (process.env.NODE_ENV !== 'production' && !server) {
12 | enhancer = applyMiddleware(thunkMiddleware, loggerMiddleware);
13 | } else {
14 | enhancer = applyMiddleware(thunkMiddleware);
15 | }
16 |
17 | return createStore(reduxState, preloadedState, enhancer);
18 | }
19 |
--------------------------------------------------------------------------------
/src/universal/routes.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Switch, Route } from 'react-router';
3 | import App from '../containers/App';
4 | import Home from '../containers/Home';
5 | import About from '../containers/About';
6 | import NotFound from '../containers/NotFound';
7 | import { getHomeData } from '../redux/actions/home';
8 | import { getAboutData } from '../redux/actions/about';
9 |
10 | // for more details see https://reacttraining.com/react-router/web/guides/server-rendering
11 | // specify routes with the asnyc function required to fetch the data to render the route
12 | // IMPORTANT: the loadData function must return a Promise
13 | export const routes = [{
14 | path: '/',
15 | exact: true,
16 | component: Home,
17 | loadData: () => getHomeData()
18 | }, {
19 | path: '/about',
20 | exact: true,
21 | component: About,
22 | loadData: () => getAboutData()
23 | }, {
24 | component: NotFound
25 | }];
26 |
27 | export default function Router() {
28 | return (
29 |
30 |
31 | {routes.map(route => (
32 |
33 | ))}
34 |
35 |
36 | );
37 | }
38 |
--------------------------------------------------------------------------------
/webpack.config.dev.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | mode: 'development',
3 | watch: true,
4 | devtool: 'inline-source-map',
5 | entry: {
6 | main: ['./src/client/index.js']
7 | },
8 | output: {
9 | publicPath: '/dist/',
10 | filename: './[name].bundle.js'
11 | },
12 | module: {
13 | rules: [{
14 | test: /\.js$/,
15 | exclude: /node_modules/,
16 | use: 'babel-loader'
17 | }, {
18 | test: /\.css$/,
19 | use: ['style-loader', 'css-loader']
20 | }, {
21 | test: /\.scss$/,
22 | use: [
23 | 'style-loader',
24 | {
25 | loader: 'css-loader',
26 | options: {
27 | modules: true,
28 | sourceMap: true,
29 | importLoaders: 1,
30 | localIdentName: '[name]__[local]___[hash:base64:5]'
31 | }
32 | },
33 | 'sass-loader'
34 | ]
35 | }]
36 | }
37 | };
38 |
--------------------------------------------------------------------------------
/webpack.config.prod.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
3 |
4 | module.exports = {
5 | mode: 'production',
6 | entry: {
7 | main: ['./src/client/index.js']
8 | },
9 | output: {
10 | path: path.join(__dirname, '/dist/'),
11 | filename: './[name].bundle.js'
12 | },
13 | module: {
14 | rules: [{
15 | test: /\.js$/,
16 | exclude: /node_modules/,
17 | use: 'babel-loader'
18 | }, {
19 | test: /\.css$/,
20 | use: [{
21 | loader: MiniCssExtractPlugin.loader
22 | }, {
23 | loader: 'css-loader',
24 | options: {
25 | sourceMap: true,
26 | modules: true,
27 | localIdentName: '[name]__[local]___[hash:base64:5]'
28 | }
29 | }]
30 | }, {
31 | test: /\.scss$/,
32 | use: [{
33 | loader: MiniCssExtractPlugin.loader
34 | }, {
35 | loader: 'css-loader',
36 | options: {
37 | sourceMap: true,
38 | modules: true,
39 | localIdentName: '[name]__[local]___[hash:base64:5]'
40 | }
41 | },
42 | 'sass-loader'
43 | ]
44 | }
45 | ]
46 | },
47 | optimization: {
48 | splitChunks: {
49 | chunks: 'all',
50 | },
51 | runtimeChunk: true,
52 | },
53 | plugins: [
54 | new MiniCssExtractPlugin({
55 | filename: '[name].style.css'
56 | })
57 | ]
58 | };
59 |
--------------------------------------------------------------------------------