πŸ“œ ⬆️ ⬇️

Create an isomorphic application on React and Flummox

Today I want to show how to use React, Flummox and Express to create a complete isomorphic application.

The idea of ​​isomorphism has long been in the air, but no one knew this word, so nothing really changed until airbnb arrived .

React appeared six months before, later Flux and many of its implementations appeared, one not worse than the other. But all these implementations focused only on work on the client side, they worked through singletons and, often, they could not be used properly on the server. I managed to try a few, but I did not like one. Until two months ago, I stumbled upon Flummox. According to the developer, Flummox was created just for isomorphic applications, it does not use the singletons inherent in other implementations, and is as simple as possible to use.
')
You are expected to have experience with React and have heard of Flux. So let's go ...
You can pick up the finished code here .

Step 0: Defining the Idea


Our idea is to create an application for recording other ideas. This will be a TODO list (like Todo MVC ) with data stored on the server. Requirements are:

The internal memory of the process will be used as a database, but it will emulate the use of an external database (data is returned via Promise ).
At the same time, we will learn how you can already touch ES2015 and ES2016 (hereinafter, for brevity, I will call them ES6 / ES7) in my application.

Step 1: Install the necessary packages


For the server, we will use Express to avoid headaches with low-level components, Flummox to operate on data and React to work comfortably with the DOM tree, and in order to run all this we need Babel .

In this step, we will initialize our Express application and install the basic components.

$ express $ npm install react flummox isomorphic-fetch todomvc-app-css react-router --save $ npm install babel webpack babel-core babel-loader brfs transform-loader --save-dev 

What did we just put in addition to React:

To run, we will use babel-node , since It allows you to broadcast ES6 / ES7 code to ES5 on the fly. Therefore, we add a launch command to package.json :

 "scripts": { "start": "babel-node --stage 0 ./bin/www" } 

Step 2: Skeleton Application


Let's start writing the code of our application. To do this, create a directory structure in which our files will be located:

 . β”œβ”€β”€ bin β”œβ”€β”€ client β”œβ”€β”€ public β”‚ └── js β”œβ”€β”€ server β”‚ └── storages β”œβ”€β”€ shared β”‚ β”œβ”€β”€ actions β”‚ β”œβ”€β”€ components β”‚ β”œβ”€β”€ handlers β”‚ └── stores └── utils 

Now you need to define the structure of the application and create basic components: TodoList , TodoInput and TodoItem - a list, a new task entry field and a separate list item (separate task), respectively. Components will be located in the shared/components folder, stores (stores) in the shared/stores folder, and actions (actions) in the shared/actions folder.
The application logic is divided into server, client and general, and is located in the server , client and shared folders, respectively. The shared folder just contains all the isomorphic components that will be used on the client and the server.

The code of the main components needed to display:

shared / components / TodoList.js
 import React from 'react'; import TodoItem from './TodoItem'; class TodoList extends React.Component { onToggleStatus(id, completed) { this.props.onToggleStatus(id, completed); } onDeleteTask(id) { this.props.onDeleteTask(id); } render() { return ( <ul className="todo-list"> {this.props.tasks.map(task => <TodoItem key={task.id} task={task} onToggleStatus={this.onToggleStatus.bind(this, task.id)} onDeleteTask={this.onDeleteTask.bind(this, task.id)} /> )} </ul> ); } } export default TodoList; 


shared / components / TodoItem.js
 import React from 'react'; class TodoItem extends React.Component { constructor(props) { super(props); this.state = props.task; } handleToggleStatus() { let completed = this.refs.completed.getDOMNode().checked; this.props.onToggleStatus(completed); this.setState({completed}); } handleDeleteTask() { this.props.onDeleteTask(); } render() { return ( <li className={this.state.completed ? 'completed' : ''}> <div className="view"> <input className="toggle" type="checkbox" defaultChecked={this.state.completed} onChange={this.handleToggleStatus.bind(this)} ref="completed" /> <label>{this.state.text}</label> <button className="destroy" onClick={this.handleDeleteTask.bind(this)} /> </div> <input className="edit" defaultValue={this.state.text} /> </li> ); } } export default TodoItem; 


Add a handler for all (except the API) incoming requests that will be processed by the reactor's router (see below):

Code
 app.use(async function (req, res, next) { let flux = new Flux(); //   ,       let router = Router.create({ routes: routes, location: req.url }); let {Handler, state} = await new Promise((resolve, reject) => { router.run((Handler, state) => resolve({Handler, state}) ); }); //  , .  β„–4 await performRouteHandlerStaticMethod(state.routes, 'routerWillRun', {state, flux}); //     let html = React.renderToString( <FluxComponent flux={flux}> <Handler {...state} /> </FluxComponent> ); //      , ..    res.send(` <!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>HabraIsoTODO</title> <link rel="stylesheet" href="/css/index.css"> </head> <body> <div id="app"> ${html} </div> </body> </html>` ); }); 


Great, now we have a main client request handler.

Here we use new features that will become available in ES7 - async / await . They allow you to save the code from callback-hell (which previously had to be solved using the wonderful async module or similar).

Tip: wrap all operations inside this handler in a try-catch block to catch errors. Since if something breaks inside, then without try-catch you will not see an error message.

Step 3: API


Add a little API that allows the client and server to interact. We will use the REST approach, since It fits perfectly into this task. Define the base paths:

 GET /api/tasks #   POST /api/tasks #   PUT /api/tasks #    GET /api/tasks/active #   GET /api/tasks/completed #   DELETE /api/tasks/completed #   PUT /api/tasks/:id #    DELETE /api/tasks/:id #    

Then we write them in the form of routes:

server / routes.js
 import {Router} from 'express'; import MemoryStorage from './storages/MemoryStorage'; import http from 'http'; let router = new Router(); let storage = new MemoryStorage(); router.get('/tasks', async (req, res) => { res.json(await storage.list()); }); router.post('/tasks', async (req, res, next) => { if (!req.body.text || !req.body.text.length) { let err = new Error(http.STATUS_CODES[400]); err.status = 400; return next(err); } let task = await storage.save({ text: req.body.text.substr(0, 256), completed: false }); res.status(201).send(task); }); router.put('/tasks', async (req, res) => { let completed = req.body.completed; let tasks = (await storage.list()).map(task => { return storage.update(task.id, { text: task.text, completed: Boolean(completed) }); }); res.status(201).json(await Promise.all(tasks)); }); router.get('/tasks/active', async (req, res) => { res.json(await storage.list((task) => !task.completed)); }); router.get('/tasks/completed', async (req, res) => { res.json(await storage.list((task) => task.completed)); }); router.delete('/tasks/completed', async (req, res, next) => { let deleted = []; try { let items = await storage.list((task) => task.completed); items.forEach(async (item) => { deleted.push(item.id); await storage.remove(item.id); }); res.status(200).json({deleted}); } catch (err) { next(err); } }); router.get('/tasks/:id', async (req, res, next) => { let id = req.params.id; try { var item = await storage.fetch(id); res.status(200).send(item); } catch (err) { return next(err); } }); router.put('/tasks/:id', async (req, res, next) => { let id = req.params.id; try { var item = await storage.fetch(id); } catch (err) { return next(err); } let updated = item; Object.keys(req.body).forEach((key) => { updated[key] = req.body[key]; }); let task = await storage.update(id, updated); res.status(200).json(task); }); router.delete('/tasks/:id', async (req, res, next) => { let id = req.params.id; try { let removed = await storage.remove(id); res.status(200).send({id, removed}); } catch (err) { return next(err); } }); export default router; 


Now we will mount routes to the main application:

 import api from './server/routes'; // ... app.use('/api', api); 

Since the data must be stored somewhere, then let's create a storage:

server / storages / MemoryStorage.js
 import http from 'http'; function clone(obj) { return JSON.parse(JSON.stringify(obj)); } export default class MemoryStorage { constructor() { this._items = { 1: { id: 1, text: 'Rule the World', completed: false }, 2: { id: 2, text: 'Be an Awesome', completed: true } }; } count() { return new Promise((resolve) => { resolve(Object.keys(this._items).length); }); } save(item) { return new Promise((resolve) => { let obj = clone(item); obj.id = Math.round(Math.random() * 10000000).toString(36); this._items[obj.id] = obj; resolve(obj); }); } fetch(id) { return new Promise((resolve, reject) => { if (!this._items[id]) { let err = new Error(http.STATUS_CODES[404]); err.status = 404; return reject(err); } resolve(this._items[id]); }); } update(id, item) { return new Promise((resolve, reject) => { let obj = clone(item); let existed = this._items[id]; if (!existed) { let err = new Error(http.STATUS_CODES[404]); err.status = 404; return reject(err); } obj.id = existed.id; this._items[obj.id] = obj; resolve(obj); }); } remove(id) { return new Promise((resolve, reject) => { if (!this._items[id]) { let err = new Error(http.STATUS_CODES[404]); err.status = 404; return reject(err); } delete this._items[id]; resolve(true); }); } list(check) { return new Promise((resolve) => { let items = Object.keys(this._items).map((key) => this._items[key]).reduce((memo, item) => { if (check && check(item)) { memo.push(item); } else if (!check) { memo.push(item); } return memo; }, []); resolve(items); }); } } 


This component will store our tasks in the process memory. The attentive reader may have noticed that we return the Promise from all methods. This is exactly the place where work with external DB is emulated.

You can also see that this component throws HTTP errors in some methods. You can't do this in the real world, because the repository doesn't need to know anything about HTTP.

Step 4: Components and Storage


The API is nice, of course, but we still need to connect everything with the components. To do this, we will create a set of Actions and Store, which will communicate with the server, returning the state for drawing into our components.

First, let's declare our Actions and Store in the main Flux class:

shared / flux.js
 import {Flux} from 'flummox'; import TodoListAction from './actions/TodoActions'; import TodoListStore from './stores/TodoStore'; export default class extends Flux { constructor() { super(); this.createActions('todo', TodoListAction); this.createStore('todo', TodoListStore, this); } } 


Here we registered our actions and storage under the name todo . By this name we can get them anywhere in the application.

Now let's announce the actions and the repository:

shared / actions / TodoActions.js
 import {Actions} from 'flummox'; import fetch from 'isomorphic-fetch'; //     , ..         location.host const API_HOST = 'http://localhost:3000'; class TodoListActions extends Actions { async getTasks() { return (await fetch(`${API_HOST}/api/tasks`, { headers: { 'Accept': 'application/json' } })).json(); } async getActiveTasks() { return (await fetch(`${API_HOST}/api/tasks/active`, { headers: { 'Accept': 'application/json' } })).json(); } async getCompletedTasks() { return (await fetch(`${API_HOST}/api/tasks/completed`, { headers: { 'Accept': 'application/json' } })).json(); } async deleteCompletedTasks() { return (await fetch(`${API_HOST}/api/tasks/completed`, { method: 'DELETE', headers: { 'Accept': 'application/json' } })).json(); } async createTask(task) { return (await fetch(`${API_HOST}/api/tasks`, { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify(task) })).json(); } async deleteTask(id) { return (await fetch(`${API_HOST}/api/tasks/${id}`, { method: 'DELETE', headers: { 'Accept': 'application/json' } })).json(); } async toggleTask(id, completed) { return (await fetch(`${API_HOST}/api/tasks/${id}`, { method: 'PUT', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({completed}) })).json(); } async toggleAll(completed) { return (await fetch(`${API_HOST}/api/tasks`, { method: 'PUT', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, body: JSON.stringify({completed}) })).json(); } } export default TodoListActions; 


shared / stores / TodoStore.js
 import {Store} from 'flummox'; class TodoListStore extends Store { constructor(flux) { super(); let actions = flux.getActionIds('todo'); //       this.register(actions.getTasks, this.handleNewTasks); this.register(actions.getActiveTasks, this.handleNewTasks); this.register(actions.getCompletedTasks, this.handleNewTasks); this.register(actions.createTask, this.handleNewTask); this.register(actions.toggleTask, this.handleUpdateTask); this.register(actions.toggleAll, this.handleNewTasks); this.register(actions.deleteTask, this.handleDeleteTask); this.register(actions.deleteCompletedTasks, this.handleDeleteTasks); } handleNewTask(task) { if (task && task.id) { this.setState({ tasks: this.state.tasks.concat([task]) }); } } handleNewTasks(tasks) { this.setState({ tasks: tasks ? tasks : [] }); } handleUpdateTask(task) { let id = task.id; this.setState({ tasks: this.state.tasks.map(t => { return (t.id == id) ? task : t; }) }); } handleDeleteTask(task) { let id = task.id; this.setState({ tasks: this.state.tasks.map(t => { if (t.id != id) { return t; } }).filter(Boolean) }); } handleDeleteTasks({deleted}) { this.setState({ tasks: this.state.tasks.filter(task => deleted.indexOf(task.id) < 0 ) }); } } export default TodoListStore; 


In fact, only Action communicates with the server, and the Store only stores data and links it to components.

In the storage designer ( TodoStore ), we register handlers that will be automatically called when data is received from the server.

Now, after calling the method from Action, it will automatically update the state of the Store, and that in turn will update the state of the component.

Step 5: Routing


Routing is one of the most important components of any modern application. Client routing is given to the component of the reactor and it already decides what to show.
react-router allows you to set paths in a declarative style, which is exactly in the spirit of React. Let's declare the paths we need:

client / routes.js
 import React from 'react'; import {Route, DefaultRoute, NotFoundRoute} from 'react-router'; import AppHandler from '../shared/handlers/AppHandler'; import TodoHandler from '../shared/handlers/TodoHandler'; export default ( <Route handler={AppHandler}> <DefaultRoute handler={TodoHandler} /> <Route name="all" path="/" handler={TodoHandler} action="all" /> <Route name="active" path="/active" handler={TodoHandler} action="active" /> <Route name="completed" path="/completed" handler={TodoHandler} action="completed" /> </Route> ); 


As you can see, each route has its own handler. Handlers in our application will load data and be so-called. "smart" (see attached links) components. There will be two of them:

shared / handlers / AppHandler.js
 import React from 'react'; import {RouteHandler} from 'react-router'; class AppHandler extends React.Component { render() { return ( <div> <section className="todoapp"> <RouteHandler {...this.props} key={this.props.pathname} /> </section> </div> ); } } export default AppHandler; 


shared / handlers / TodoHandler.js
 import React from 'react'; import Flux from 'flummox/component'; import TodoList from '../components/TodoList'; import TodoInput from '../components/TodoInput'; import ItemsCounter from '../components/ItemsCounter'; import ToggleAll from '../components/ToggleAll'; class TodoHandler extends React.Component { static async routerWillRun({flux, state}) { let action = state.routes[state.routes.length - 1].name; let todoActions = flux.getActions('todo'); switch (action) { case 'active': await todoActions.getActiveTasks(); break; case 'completed': await todoActions.getCompletedTasks(); break; case 'all': default: await todoActions.getTasks(); break; } } async handleNewTask(text) { let actions = this.props.flux.getActions('todo'); await actions.createTask({text}); } async handleToggleStatus(id, status) { let actions = this.props.flux.getActions('todo'); await actions.toggleTask(id, status); } async handleToggleAll(status) { let actions = this.props.flux.getActions('todo'); await actions.toggleAll(status); } async handleDeleteTask(id) { let actions = this.props.flux.getActions('todo'); await actions.deleteTask(id); } async handleDeleteCompletedTasks(id) { let actions = this.props.flux.getActions('todo'); await actions.deleteCompletedTasks(); } render() { return ( <div> <header className="header"> <h1>todos</h1> <TodoInput handleNewTask={this.handleNewTask.bind(this)} /> </header> <section className="main"> <Flux connectToStores={['todo']}> <ToggleAll onToggleStatus={this.handleToggleAll.bind(this)} /> </Flux> <Flux connectToStores={['todo']}> <TodoList onToggleStatus={this.handleToggleStatus.bind(this)} onDeleteTask={this.handleDeleteTask.bind(this)} /> </Flux> </section> <footer className="footer"> <Flux connectToStores={['todo']}> <ItemsCounter count={0} /> </Flux> <ul className="filters"> <li> <a href="/">All</a> </li> <li> <a href="/active">Active</a> </li> <li> <a href="/completed">Completed</a> </li> </ul> <button className="clear-completed" onClick={this.handleDeleteCompletedTasks.bind(this)}> Clear completed </button> </footer> </div> ); } } export default TodoHandler; 


The TodoHandler component is smart and you may have noticed the routerWillRun static method, this is where the initial data loading takes place in the Store. How to call this method will be shown below. The remaining components, respectively, will be "stupid" and only react in a certain way to changes in the surrounding world and events from the user.

It also shows that some components are wrapped in a <Flux /> component. Its connectToStores property establishes a connection between the repository and the child component. Everything that is in the state repository becomes available in the props child component.

Step 6: Rendering the home page


It is time to render our components. But in order to do this correctly, we need to preload all existing tasks. As you remember, tasks are loaded via the HTTP API. But we have a TodoAction for this, which describes the getTasks method. In the Flummox example, a method is described with the terribly long name performRouteHandlerStaticMethod , which should trigger the loading of data for the storage using the routerWillRun method described above.

Add it to yourself.

utils / performRouteHandlerStaticMethod.js
 export default async function performRouteHandlerStaticMethod(routes, methodName, ...args) { return Promise.all(routes .map(route => route.handler[methodName]) .filter(method => typeof method === 'function') .map(method => method(...args)) ); } 


It needs to be added to the server and client parts of the application.

 import performRouteHandlerStaticMethod from '../utils/performRouteHandlerStaticMethod'; await performRouteHandlerStaticMethod(state.routes, 'routerWillRun', {state, flux}); 


How it looks you can see here and here .

Now, when this handler is routerWillRun , the routerWillRun method will be called, which will load the necessary data into the Store and it will be displayed in the component.

Step 7: Build the client side


We knowingly installed a webpack . He will help us build our application to work on the client. To do this, let's configure it.

webpack.config.js
 var path = require('path'); var webpack = require('webpack'); var DEBUG = process.env.NODE_ENV !== 'production'; var plugins = [ new webpack.optimize.OccurenceOrderPlugin() ]; if (!DEBUG) { plugins.push( new webpack.optimize.UglifyJsPlugin() ); } module.exports = { cache: DEBUG, debug: DEBUG, target: 'web', devtool: DEBUG ? '#inline-source-map' : false, entry: { client: ['./client/app.js'] }, output: { path: path.resolve('public/js'), publicPath: '/', filename: 'bundle.js', pathinfo: false }, module: { loaders: [ { test: /\.js/, loaders: ['transform?brfs', 'babel-loader?stage=0'] }, { test: /\.json$/, loaders: ['json-loader'] } ] }, plugins: plugins, resolve: { extensions: ['', '.js', '.json', '.jsx'] } }; 


The application will be bundle.js in the bundle.js file, so it must be connected on the client:

 <script type="text/javascript" src="/js/bundle.js"></script> 


Add a command to build in package.json :

 "scripts": { "build": "webpack" } 

Now you can run the assembly:

 $ npm run build 

After some time, the file /public/js/bundle.js will appear, which is the client version of our application.

Step 8: Let's see what happened


We have just created an isomorphic application. Now we can run it npm start and see what happened.

Afterword


I tried to convey my thoughts as clearly as possible, so some things are greatly simplified and made not quite right. Also, in order not to confuse the code, in some places there are no checks for errors.

I would also like to thank Ivan Krechetov for reading the draft and recommendations for improving the quality of the code.

Useful reading:



Happy coding!

Source: https://habr.com/ru/post/256979/


All Articles