How to create a three layer application with React
Splitting a Single Page Application into layers has a set of advantages:
- a better separation of concerns
- the layer implementation can be replaced
- the UI layer can be hard to test. By moving the logic to other layers, it becomes easier to test.
Below we can see the diagram of an application split in the three main layers:
- UI (aka Presentation)
- Domain (aka Business)
- Data Access
The showcase
I’ll take the case of an application managing a list of to-dos. The user is able to see and search for to-dos.
Check the full implementation on git-hub.
UI Layer
The UI layer is responsible for displaying data on the page, and for handling user interactions. The UI Layer is made up of components.
I split the page in the following components:
TodoContainer
manages the communication betweenTodoSearch
.TodoList
and other external objectsTodoSearchForm
is the form for searching to-dosTodoList
displays the list of to-dosTodoListItem:
displays a single to-do in the list
TodoSearch
The component uses the handleChange
handler to read the input value on any change. TodoSearch
exposes a new property: onSearch
. It can be used by the parent component to handle the search click.
The component doesn’t communicate with any other external objects, except its parent. TodoSearch
is a presentation component.
export default class TodoSearch extends React.Component { constructor(props){ super(props); this.search = this.search.bind(this); this.handleChange = this.handleChange.bind(this); } componentWillMount() { this.setState({text: ""}); } search(){ const query = Object.freeze({ text: this.state.text }); if(this.props.onSearch) this.props.onSearch(query); } handleChange(event) { this.setState({text: event.target.value}); } render() { return <form> <input onChange={this.handleChange} value={this.state.text} /> <button onClick={this.search} type="button">Search</button> </form>; }}Copy the code
TodoList
TodoList
gets the list of todos
to render using a property. It sends the todos
, one by one, to the TodoListItem
.
TodoList
is a stateless functional component.
export default function TodoList(props) { function renderTodoItem(todo){ return <TodoListItem todo={todo} key={todo.id}></TodoListItem>; } return <div className="todo-list"> <ul> { props.todos.map(renderTodoItem) } </ul> </div>; }Copy the code
TodoListItem
TodoListItem
displays the todo
received as a parameter. It is implemented as a stateless functional component.
export default function TodoListItem(props){ return <li> <div>{ props.todo.title}</div> <div>{ props.todo.userName }</div> </li>; }Copy the code
TodoContainer
The TodoContainer
is connected to the todoStore
external object and subscribes to its change events.
It uses the onSearch
handler to read the search criteria from TodoSearch
. It then creates a filtered list using the todoStore
and sends the new list to the TodoList
component.
TodoContainer
keeps all the UI state, the query
object in this case.
import TodoList from "./TodoList.jsx"; import TodoSearch from "./TodoSearch.jsx"; export default class TodoContainer extends React.Component { constructor(props){ super(props); this.todoStore = props.stores.todoStore; this.search = this.search.bind(this); this.reload = this.reload.bind(this); } componentWillMount(){ this.todoStore.onChange(this.reload); this.todoStore.fetch(); this.query = null; this.setState({todos : []}); } reload(){ const todos = this.todoStore.getBy(this.query); this.setState({ todos }); } search(query){ this.query = query; this.reload(); } render() { return <div> <TodoSearch onSearch={this.search} /> <TodoList todos={this.state.todos} /> </div>; }}Copy the code
Domain Layer
The domain layer is made of domain stores. The main purposes of the domain store is to store the domain state and to keep it in sync with the server.
The application responsibilities are split between two domain stores:
TodoStore
manages the to-do data objectsUserStore
manages the user data objects
The domain store is a publisher. It emits events every time its state changes. Components can subscribe to these events and update the user interface.
TodoStore
is the single source of truth regarding to-dos.
TodoStore
import MicroEmitter from 'micro-emitter'; import partial from "lodash/partial"; export default function TodoStore(dataService, userStore){ let todos = []; const eventEmitter = new MicroEmitter(); const CHANGE_EVENT = "change"; function fetch() { return dataService.get().then(setLocalTodos); } function setLocalTodos(newTodos){ todos = newTodos; eventEmitter.emit(CHANGE_EVENT); } function toViewModel(todo){ return Object.freeze({ id : todo.id, title : todo.title, userName : userStore.getById(todo.userId).name }); } function descById(todo1, todo2){ return parseInt(todo2.id) - parseInt(todo1.id); } function queryContainsTodo(query, todo){ if(query && query.text){ return todo.title.includes(query.text); } return true; } function getBy(query) { const top = 25; const byQuery = partial(queryContainsTodo, query); return todos.filter(byQuery) .map(toViewModel) .sort(descById).slice(0, top); } function onChange(handler){ eventEmitter.on(CHANGE_EVENT, handler); } return Object.freeze({ fetch, getBy, onChange }); }Copy the code
TodoStore
is implemented using a factory function. The object has many methods but only three of them are public.
I favor factory functions over classes. For more on the topic, take a look at How to build reliable objects with factory functions in JavaScript.
Use is separated from construction. All dependencies of TodoStore
are declared as input parameters.
TodoStore
emits events on every change: eventEmitter.emit(CHANGE_EVENT)
. A micro event emitter library MicroEmitter
is used.
Bellow is an example of to-do domain data object:
{id : 1, title: "This is a title", userId: 10, completed: false }Copy the code
Data Access Layer
The data service encapsulates the communication with the server API.
TodoDataService
export default function TodoDataService(){ const url = "https://jsonplaceholder.typicode.com/todos"; function toJson(response){ return response.json(); }Copy the code
function get() { return fetch(url).then(toJson); } function add(todo) { return fetch(url, { method: "POST", body: JSON.stringify(todo), }).then(toJson); } return Object.freeze({ get, add }); }Copy the code
Both get
and add
public methods return a promise.
A promise is a reference to an asynchronous call. It may resolve or fail somewhere in the future.
Application Entry Point
The main.js
is the application a single entry point. This is the place where:
- all objects are created and dependencies injected (aka the Composition Root)
- all components are created
import React from "react"; import ReactDOM from 'react-dom'; import TodoDataService from "./dataaccess/TodoDataService"; import UserDataService from "./dataaccess/UserDataService"; import TodoStore from "./stores/TodoStore"; import UserStore from "./stores/UserStore"; import TodoContainer from "./components/TodoContainer.jsx"; (function startApplication(){ const userDataService = UserDataService(); const todoDataService = TodoDataService(); const userStore = UserStore(userDataService); const todoStore = TodoStore(todoDataService, userStore); const stores = { todoStore, userStore }; function loadStaticData(){ return Promise.all([userStore.fetch()]); } function mountPage(){ ReactDOM.render( <TodoContainer stores={stores} />, document.getElementById('root')); } loadStaticData().then(mountPage); }) ();Copy the code
Task Runner
All components and factory functions are exported from modules. Gulp with Browserify are used to bundle all modules together. There is only one entry point in the application, the main.js
file, so the scripts
task, running browserify, contains only this entry.
ESLint is used for linting. All the rules are defined in the .eslintrc.json
file.
Execute npm gulp
to run the gulp default task. It executes the eslint
and then scripts
tasks.
A watch task is defined to monitor .js
and .jsx
file changes and rerun eslint
and scripts
tasks.
var gulp = require('gulp')var eslint = require('gulp-eslint'); var babelify = require('babelify'); var browserify = require('browserify'); var source = require('vinyl-source-stream'); var distFolder = "./dist"; gulp.task('eslint', function () { gulp.src(["components/*.jsx", "dataaccess/*.js", "stores/*.js", "tools/*.js", "main.js"]) .pipe(eslint()) .pipe(eslint.format()); }); gulp.task('scripts', function () { return browserify({ entries: 'main.js' }) .transform(babelify.configure({ presets : ["es2015", "react"] })) .bundle() .pipe(source('scripts.js')) .pipe(gulp.dest(distFolder)); }); gulp.task('watch', function () { gulp.watch(["components/*.jsx", "dataaccess/*.js", "stores/*.js", "tools/*.js", "main.js"], [ "eslint", "scripts" ]); }); gulp.task( 'default', [ "eslint", "scripts" ] )Copy the code
Testing
The Jest framework is used for testing. All test files will be named with the .test.js
suffix. Execute npm test
to run all the tests.
The TodoStore
takes in all dependencies as parameters. We can mock the TodoDataService
and UserStore
dependencies and test the todoStore
object in isolation.
Below is a test for the todoStore.getBy()
method.
import TodoStore from ".. /stores/TodoStore"; test("TodoStore can filter by title text", function() { //arrage const allTodos = [ { id: 1, title : "title 1" }, { id: 2, title : "title 2" }, { id: 3, title : "title 3" } ]; const todoDataService = { get : function(){ return Promise.resolve(allTodos); }}; const userStore = { getById : function(){ return { name : "Test" }; }}; const todoStore = TodoStore(todoDataService, userStore); const query = { text: "title 1" }; const expectedOutputTodos = [ { id: 1, title : "title 1" , userName : "Test"} ]; //act todoStore.fetch().then(function makeAssertions(){ //assert expect(expectedOutputTodos).toEqual(todoStore.getBy(query)); }); });Copy the code
Conclusion
The three layers architecture offers a better separation and understanding of the layer’s purpose.
Components are used to split the page into smaller pieces that are easier to manage and reuse.
The domain store manages the domain state.
Data services communicates with external APIs.
Use is separated from construction. All objects and components are created in the main.js
. The rest of the application is designed assuming that all objects have been built.
Check out the full implementation on git-hub.
For more on the JavaScript functional side take a look at:
Discover the power of closures in JavaScript
Discover the power of first class functions
How point-free composition will make you a better functional programmer
How to make your code better with intention-revealing function names
Here are a few function decorators you can write from scratch
Make your code easier to read with Functional Programming