Tutorial: Handcrafting an Isomorphic Redux Application (With Love)

  • Wholegrain server-side rendering
  • Extensible routing rich in Omega-3
  • Buttery asynchronous data loading
  • A Smooth functional after-taste

Wait, hold up, what’s a Redux?

function exampleReducer(state, action) {
return state.changedBasedOn(action)
}

Making yourself comfortable

{
"optional": ["es7.decorators", "es7.classProperties", "es7.objectRestSpread"]
}

Serve me Seymour

client/
shared/
index.js
server.jsx
'use strict';require('babel/register')({});var server = require('./server');const PORT = process.env.PORT || 3000;server.listen(PORT, function () {
console.log('Server listening on', PORT);
});
import express from 'express';const app = express();app.use((req, res) => {
const HTML = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Isomorphic Redux Demo</title>
</head>
<body>
<div id="react-view"></div>
<script type="application/javascript" src="/bundle.js"></script>
</body>
</html>
`;

res.end(HTML);
});
export default app;

Routing like a pro

import React from 'react';export default class AppView extends React.Component {
render() {
return (
<div id="app-view">
<h1>Todos</h1>
<hr /> {this.props.children}
</div>
);
}
}
import React     from 'react';
import { Route } from 'react-router';
import App from 'components';export default (
<Route name="app" component={App} path="/">
</Route>
);
import express                   from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server'
import { RoutingContext, match } from 'react-router';
import createLocation from 'history/lib/createLocation';
import routes from 'routes';
const app = express();app.use((req, res) => {
const location = createLocation(req.url);
match({ routes, location }, (err, redirectLocation, renderProps) => {
if (err) {
console.error(err);
return res.status(500).end('Internal server error');
}
if (!renderProps) return res.status(404).end('Not found.');

const InitialComponent = (
<RoutingContext {...renderProps} />
);
const componentHTML = renderToString(InitialComponent); const HTML = `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Isomorphic Redux Demo</title>
</head>
<body>
<div id="react-view">${componentHTML}</div>
<script type="application/javascript" src="/bundle.js"></script>
</body>
</html>
`
res.end(HTML);
});
});
export default app;
<div id="react-view">${componentHTML}</div>
Pretty, huh.
import React       from 'react';
import { render } from 'react-dom';
import { Router } from 'react-router';
import createBrowserHistory from 'history/lib/createBrowserHistory';
import routes from 'routes';
const history = createBrowserHistory();render(
<Router children={routes} history={history} />,
document.getElementById('react-view')
);

Reduce, Reuse, Redux

export function createTodo(text) {
return {
type: 'CREATE_TODO',
text,
date: Date.now()
}
}
export function editTodo(id, text) {
return {
type: 'EDIT_TODO',
id,
text,
date: Date.now()
};
}
export function deleteTodo(id) {
return {
type: 'DELETE_TODO',
id
};
}
import Immutable from 'immutable';const defaultState = new Immutable.List();export default function todoReducer(state = defaultState, action) {
switch(action.type) {
case 'CREATE_TODO':
return state.concat(action.text);
case 'EDIT_TODO':
return state.set(action.id, action.text);
case 'DELETE_TODO':
return state.delete(action.id);
default:
return state;
}
}
  1. The reducer has the signature (state, action) => newState.
  2. The reducer does not mutate the state it is given, but returns a new one
export { default as todos } from './TodoReducer';

Annnnd… Action

...
import { createStore, combineReducers } from 'redux';
import { Provider } from 'react-redux';
import * as reducers from 'reducers';
app.use((req, res) => {
const location = createLocation(req.url);
const reducer = combineReducers(reducers);
const store = createStore(reducer);

match({ routes, location }, (err, redirectLocation, renderProps) => {
if (err) {
console.error(err);
return res.status(500).end('Internal server error');
}
if (!renderProps) return res.status(404).end('Not found.');

const InitialComponent = (
<Provider store={store}>
<RoutingContext {...renderProps} />
</Provider>
);
...
...
</Provider>
);
const initialState = store.getState();...
<title>Redux Demo</title>

<script type="application/javascript">
window.__INITIAL_STATE__ = ${JSON.stringify(initialState)};
</script>
...
import { createStore, combineReducers } from 'redux';
import { Provider } from 'react-redux';
import * as reducers from 'reducers';
import { fromJS } from 'immutable';
const history = createBrowserHistory();let initialState = window.__INITIAL_STATE__;// Transform into Immutable.js collections,
// but leave top level keys untouched for Redux
Object
 .keys(initialState)
 .forEach(key => {
initialState[key] = fromJS(initialState[key]);
});
const reducer = combineReducers(reducers);
const store = createStore(reducer, initialState);
render(
<Provider store={store}>
<Router children={routes} history={history} />
</Provider>,
document.getElementById('react-view')
);

Connecting the Dots

import React                  from 'react';
import TodosView from 'components/TodosView';
import TodosForm from 'components/TodosForm';
import { bindActionCreators } from 'redux';
import * as TodoActions from 'actions/TodoActions';
import { connect } from 'react-redux';
@connect(state => ({ todos: state.todos }))export default class Home extends React.Component {
render() {
const { todos, dispatch } = this.props;

return (
<div id="todo-list">
<TodosView todos={todos}
{...bindActionCreators(TodoActions, dispatch)} />
<TodosForm
{...bindActionCreators(TodoActions, dispatch)} />
</div>
);
}
}
dispatch(actionCreator());
import React from 'react';export default class TodosView extends React.Component {
handleDelete = (e) => {
const id = Number(e.target.dataset.id);

// Equivalent to `dispatch(deleteTodo())`
this.props.deleteTodo(id);
}
handleEdit = (e) => {
const id = Number(e.target.dataset.id);
const val = this.props.todos.get(id).text

// For cutting edge UX
let newVal = window.prompt('', val);
this.props.editTodo(id, newVal);
}

render() {
return (
<div id="todo-list">
{
this.props.todos.map( (todo, index) => {
return (
<div key={index}>
<span>{todo}</span>

<button data-id={index} onClick={this.handleDelete}>
X
</button>
<button data-id={index} onClick={this.handleEdit}>
Edit
</button>
</div>
);
})
}
</div>
);
}
}
import React from 'react';export default class TodosForm extends React.Component {
handleSubmit = () => {
let node = this.refs['todo-input'];

this.props.createTodo(node.value);

node.value = '';
}

render() {
return (
<div id="todo-form">
<input type="text" placeholder="type todo" ref="todo-input" />
<input type="submit" value="OK!" onClick={this.handleSubmit} />
</div>
);
}
}
...
import Home from 'components/Home';
export default (
<Route name="app" component={App} path="/">
<Route component={Home} path="home" />
</Route>
);
If you’re telling yourself Evernote is better, you’re lying

The final frontier: asynchronous actions

export default function promiseMiddleware() {
return next => action => {
const { promise, type, ...rest } = action;

if (!promise) return next(action);

const SUCCESS = type;
const REQUEST = type + '_REQUEST';
const FAILURE = type + '_FAILURE';
next({ ...rest, type: REQUEST }); return promise
 .then(res => {
next({ ...rest, res, type: SUCCESS });

return true;
})
 .catch(error => {
next({ ...rest, error, type: FAILURE });

// Another benefit is being able to log all failures here
console.log(error);
return false;
});
};
}
...
import { applyMiddleware } from 'redux';
import promiseMiddleware from 'lib/promiseMiddleware';
...
const store = applyMiddleware(promiseMiddleware)(createStore)(reducer);
import request from 'axios';const BACKEND_URL = 'https://webtask.it.auth0.com/api/run/wt-milomord-gmail_com-0/redux-tutorial-backend?webtask_no_cache=1';export function createTodo(text) {
return {
type: 'CREATE_TODO',
promise: request.post(BACKEND_URL, { text })
}
}
return state.concat(action.res.data.text);
Two words: Ta. Da.
export function getTodos() {
return {
type: 'GET_TODOS',
promise: request.get(BACKEND_URL)
}
}
case 'GET_TODOS':
return state.concat(action.res.data);
componentDidMount() {
this.props.getTodos();
}

Wait… Didn’t we break state rehydration?

static needs = [
TodoActions.getTodos
]
export default function fetchComponentData(dispatch, components, params) {
const needs = components.reduce( (prev, current) => {
return (current.needs || [])
 .concat((current.WrappedComponent ? current.WrappedComponent.needs : []) || [])
 .concat(prev);
}, []);

const promises = needs.map(need => dispatch(need(params)));
return Promise.all(promises);
}
...
import fetchComponentData from 'lib/fetchComponentData';
match({ routes, location }, (err, redirectLocation, renderProps) => {
if (err) {
console.error(err);
return res.status(500).end('Internal server error.');
}
if (!renderProps) return res.status(404).end('Not found.'); function renderView() {
// ... Rest of the old code goes here still
return HTML;
}

fetchComponentData(store.dispatch, renderProps.components, renderProps.params)
 .then(renderView)
 .then(html => res.end(html))
 .catch(err => res.end(err.message));
});
Breathtaking.

What have we learned?

--

--

--

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Milo Mordaunt

Milo Mordaunt

More from Medium

What is React JS?

react

useMemo and useCallback in React

Desk setup

How to write React useState()?

React JS / Components , props and state