📜 ⬆️ ⬇️

Four levels of one-page apps that you need to know about

image


In this article, we will develop a React application from scratch, discuss the domain and its services, storage, application services, and view.



Four levels of single page (SPA) applications


Every successful project needs a clear architecture that is understandable to all members of the development team. Let's say you recently came to the team. Timlid talks about the proposed architecture for the new application:



It describes the requirements:


Our application will display a list of articles. At the same time, users will be able to create and delete articles, as well as post likes.


And then the team leader asks you to implement it!


No questions, let's start with architecture


I chose the Create React App and Flow for type checking. In order not to inflate the code, our application will be without styles. And now let's talk about the declarative nature of modern frameworks, affecting the concept of state.


Modern frameworks are declarative.


React, Angular, Vue are declarative , they push us to use elements of functional programming.


Have you ever enjoyed your childhood “cartoons” drawn on the pages of a notebook or notebook, which had to be quickly turned over, with homemade filmmakers ?


Kineograph (Kineograph) - a device for creating an animated image consisting of separate frames, printed on sheets of paper, stitched into a notebook. The viewer, looking through the notebook in a special way, observes the effect of animation.


And here is part of the React description:


Create simple views for each state of your application, and React, when the data changes, will effectively update and draw only the necessary components.


And here is part of the description of Angular :


Quickly create functionality with simple, declarative templates. Extend the template language with your own components.


Familiar sounds?


The frameworks help us build applications from submissions. Views (views) personify the state. But what is a condition?


condition


The status displays all changed data in the application.


You clicked the URL - this is the state; made an Ajax call to get a list of movies - this is also a state; you put the information in the local storage - and this is the state.


The state is formed from immutable objects.


An immutable architecture has many advantages, one of which relates to the state level.


Here is a quote from React's Performance Optimization Guide:


Immutability makes change tracking cheaper. Any change always leads to the creation of a new object, so we only need to check if the object reference has changed.


Domain Level


The domain describes the state and contains the business logic. It represents the core of the application and should not depend on the level of the view. We need the ability to use your domain, regardless of the framework.



Domain Level


As we work with immutable architecture, the domain level will consist of entities and services of the domain. The use of an anemic domain model in OOP is controversial, especially in large applications, but it is quite suitable for working with immutable data. For me, at one time, was the opening course of Vladimir Horikov.


Since we need to display a list of articles, we first model the essence of the Article .


All future objects of type Article should be immutable. Flow can force this by making each property read-only (see the plus sign in front of each property).


Article.js:


 // @flow export type Article = { +id: string; +likes: number; +title: string; +author: string; } 

Now, using the “factory” function template, create an articleService . This moment is beautifully explained here .


Since we only need one articleService in the application, we export as a singleton. The createArticle method will allow you to create frozen objects like Article . Each new article will receive a unique automatically generated ID and 0 likes, and we specify only the author and title.


The Object.freeze() method freezes an object, that is, it prevents new properties from being added to the object. ( c )


The createArticle method returns a maybe-type Article .


Maybe-types ( optional types ) force you to check whether an Article object exists before performing operations on it.


If the field required to create an article fails validation, the createArticle method returns null. Someone will say that it is better to throw a user-defined exception. But if we make it do this, and the upper levels do not implement exception catching blocks, the program will crash during execution.


The updateLikes method will help update the number of likes of an existing article by returning a copy of it with a new counter.


Finally, the isTitleValid and isAuthorValid do not allow createArticle to work with corrupted data.


ArticleService.js:


 // @flow import v1 from 'uuid'; import * as R from 'ramda'; import type {Article} from "./Article"; import * as validators from "./Validators"; export type ArticleFields = { +title: string; +author: string; } export type ArticleService = { createArticle(articleFields: ArticleFields): ?Article; updateLikes(article: Article, likes: number): Article; isTitleValid(title: string): boolean; isAuthorValid(author: string): boolean; } export const createArticle = (articleFields: ArticleFields): ?Article => { const {title, author} = articleFields; return isTitleValid(title) && isAuthorValid(author) ? Object.freeze({ id: v1(), likes: 0, title, author }) : null; }; export const updateLikes = (article: Article, likes: number) => validators.isObject(article) ? Object.freeze({ ...article, likes }) : article; export const isTitleValid = (title: string) => R.allPass([ validators.isString, validators.isLengthGreaterThen(0) ])(title); export const isAuthorValid = (author: string) => R.allPass([ validators.isString, validators.isLengthGreaterThen(0) ])(author); export const ArticleServiceFactory = () => ({ createArticle, updateLikes, isTitleValid, isAuthorValid }); export const articleService = ArticleServiceFactory(); 

Checks are very important for maintaining data consistency, especially at the domain level. Service Validators can be built from pure functions.


Validators.js:


 // @flow export const isObject = (toValidate: any) => !!(toValidate && typeof toValidate === 'object'); export const isString = (toValidate: any) => typeof toValidate === 'string'; export const isLengthGreaterThen = (length: number) => (toValidate: string) => toValidate.length > length; 

Exceptionally for demonstration purposes, these checks are filed with a pinch of salt. In JavaScript, it is not so easy to check if an object is really an object :)


Now we have set the domain level!


I am glad that we can use our code immediately, regardless of the framework. Now let's see how articleService helps to create an article about one of my favorite books, as well as to update the number of likes.


domain-demo.js:


 // @flow import {articleService} from "../domain/ArticleService"; const article = articleService.createArticle({ title: '12 rules for life', author: 'Jordan Peterson' }); const incrementedArticle = article ? articleService.updateLikes(article, 4) : null; console.log('article', article); /* const itWillPrint = { id: "92832a9a-ec55-46d7-a34d-870d50f191df", likes: 0, title: "12 rules for life", author: "Jordan Peterson" }; */ console.log('incrementedArticle', incrementedArticle); /* const itWillPrintUpdated = { id: "92832a9a-ec55-46d7-a34d-870d50f191df", likes: 4, title: "12 rules for life", author: "Jordan Peterson" }; */ 

Storage level


The data obtained when creating and updating articles embody the state of the application. You need to hold this data somewhere, the ideal candidate for this is the repository.



Storage level


You can simulate the state using an array of articles.


ArticleState.js:


 // @flow import type {Article} from "./Article"; export type ArticleState = Article[]; 

ArticleStoreFactory implements the “publish-subscribe” template and export articleStore as a singleton.


The repository contains articles and performs unchangeable add, delete, and update operations with them. Remember that the repository only operates on articles. Only articleService can create and update them. Interested parties can subscribe and unsubscribe in the articleStore , which stores in its memory a list of all subscribers and notifies them of changes.


ArticleStore.js :


 // @flow import {update} from "ramda"; import type {Article} from "../domain/Article"; import type {ArticleState} from "./ArticleState"; export type ArticleStore = { addArticle(article: Article): void; removeArticle(article: Article): void; updateArticle(article: Article): void; subscribe(subscriber: Function): Function; unsubscribe(subscriber: Function): void; } export const addArticle = (articleState: ArticleState, article: Article) => articleState.concat(article); export const removeArticle = (articleState: ArticleState, article: Article) => articleState.filter((a: Article) => a.id !== article.id); export const updateArticle = (articleState: ArticleState, article: Article) => { const index = articleState.findIndex((a: Article) => a.id === article.id); return update(index, article, articleState); }; export const subscribe = (subscribers: Function[], subscriber: Function) => subscribers.concat(subscriber); export const unsubscribe = (subscribers: Function[], subscriber: Function) => subscribers.filter((s: Function) => s !== subscriber); export const notify = (articleState: ArticleState, subscribers: Function[]) => subscribers.forEach((s: Function) => s(articleState)); export const ArticleStoreFactory = (() => { let articleState: ArticleState = Object.freeze([]); let subscribers: Function[] = Object.freeze([]); return { addArticle: (article: Article) => { articleState = addArticle(articleState, article); notify(articleState, subscribers); }, removeArticle: (article: Article) => { articleState = removeArticle(articleState, article); notify(articleState, subscribers); }, updateArticle: (article: Article) => { articleState = updateArticle(articleState, article); notify(articleState, subscribers); }, subscribe: (subscriber: Function) => { subscribers = subscribe(subscribers, subscriber); return subscriber; }, unsubscribe: (subscriber: Function) => { subscribers = unsubscribe(subscribers, subscriber); } } }); export const articleStore = ArticleStoreFactory(); 

Our implementation of the repository is quite suitable as an illustration and helps to understand the concept itself. In real projects, I recommend using state management systems Redux , ngrx , MobX, or at least Observable - data services .


So, now we have configured domain and storage levels.


Let's create two articles, two subscribers to the repository, and see how subscribers will be notified of the changes.


store-demo.js:


 // @flow import type {ArticleState} from "../store/ArticleState"; import {articleService} from "../domain/ArticleService"; import {articleStore} from "../store/ArticleStore"; const article1 = articleService.createArticle({ title: '12 rules for life', author: 'Jordan Peterson' }); const article2 = articleService.createArticle({ title: 'The Subtle Art of Not Giving a F.', author: 'Mark Manson' }); if (article1 && article2) { const subscriber1 = (articleState: ArticleState) => { console.log('subscriber1, articleState changed: ', articleState); }; const subscriber2 = (articleState: ArticleState) => { console.log('subscriber2, articleState changed: ', articleState); }; articleStore.subscribe(subscriber1); articleStore.subscribe(subscriber2); articleStore.addArticle(article1); articleStore.addArticle(article2); articleStore.unsubscribe(subscriber2); const likedArticle2 = articleService.updateLikes(article2, 1); articleStore.updateArticle(likedArticle2); articleStore.removeArticle(article1); } 

Application Services


This level is useful for performing operations related to the state flow, such as Ajax calls for receiving data from the server, or state projections.



Application Services Level


For some reason, the designer requires that the names of the authors be written in capital letters. The requirement is stupid, and we do not want to spoil our model because of it. To work with this feature, create an ArticleUiService . The service takes a portion of the state — the author’s name — and projects it, returning to the caller the version written in capital letters.


ArticleUiService.js:


 // @flow export const displayAuthor = (author: string) => author.toUpperCase(); 

Here is a demo using this service.


app-service-demo.js:


 // @flow import {articleService} from "../domain/ArticleService"; import * as articleUiService from "../services/ArticleUiService"; const article = articleService.createArticle({ title: '12 rules for life', author: 'Jordan Peterson' }); const authorName = article ? articleUiService.displayAuthor(article.author) : null; console.log(authorName); // It will print JORDAN PETERSON if (article) { console.log(article.author); // It will print Jordan Peterson } 

Presentation level


Now we have a fully functional application, independent of the framework. It's ready for React to breathe life into it. The presentation layer consists of a presentational and container components. The mapping components are responsible for how the elements look, and the container components are responsible for how the elements work. More details are described in the article by Dan Abramov.



Presentation level


Create an App component consisting of the ArticleFormContainer and the ArticleListContainer .


App.js:


 // @flow import React, {Component} from 'react'; import './App.css'; import {ArticleFormContainer} from "./components/ArticleFormContainer"; import {ArticleListContainer} from "./components/ArticleListContainer"; type Props = {}; class App extends Component<Props> { render() { return ( <div className="App"> <ArticleFormContainer/> <ArticleListContainer/> </div> ); } } export default App; 

Now create an ArticleFormContainer . No matter, React, Angular - forms turn out difficult. I also recommend to get acquainted with the Ramda library and see how its methods complement the declarative nature of our code.


The form takes the data entered by the user and passes it to the articleService. Based on this data, the service creates an Article and adds it to the ArticleStore so that the article can take other components from there. All logic is initially stored in the submitForm method.


ArticleFormContainer.js:


 // @flow import React, {Component} from 'react'; import * as R from 'ramda'; import type {ArticleService} from "../domain/ArticleService"; import type {ArticleStore} from "../store/ArticleStore"; import {articleService} from "../domain/ArticleService"; import {articleStore} from "../store/ArticleStore"; import {ArticleFormComponent} from "./ArticleFormComponent"; type Props = {}; type FormField = { value: string; valid: boolean; } export type FormData = { articleTitle: FormField; articleAuthor: FormField; }; export class ArticleFormContainer extends Component<Props, FormData> { articleStore: ArticleStore; articleService: ArticleService; constructor(props: Props) { super(props); this.state = { articleTitle: { value: '', valid: true }, articleAuthor: { value: '', valid: true } }; this.articleStore = articleStore; this.articleService = articleService; } changeArticleTitle(event: Event) { this.setState( R.assocPath( ['articleTitle', 'value'], R.path(['target', 'value'], event) ) ); } changeArticleAuthor(event: Event) { this.setState( R.assocPath( ['articleAuthor', 'value'], R.path(['target', 'value'], event) ) ); } submitForm(event: Event) { const articleTitle = R.path(['target', 'articleTitle', 'value'], event); const articleAuthor = R.path(['target', 'articleAuthor', 'value'], event); const isTitleValid = this.articleService.isTitleValid(articleTitle); const isAuthorValid = this.articleService.isAuthorValid(articleAuthor); if (isTitleValid && isAuthorValid) { const newArticle = this.articleService.createArticle({ title: articleTitle, author: articleAuthor }); if (newArticle) { this.articleStore.addArticle(newArticle); } this.clearForm(); } else { this.markInvalid(isTitleValid, isAuthorValid); } }; clearForm() { this.setState((state) => { return R.pipe( R.assocPath(['articleTitle', 'valid'], true), R.assocPath(['articleTitle', 'value'], ''), R.assocPath(['articleAuthor', 'valid'], true), R.assocPath(['articleAuthor', 'value'], '') )(state); }); } markInvalid(isTitleValid: boolean, isAuthorValid: boolean) { this.setState((state) => { return R.pipe( R.assocPath(['articleTitle', 'valid'], isTitleValid), R.assocPath(['articleAuthor', 'valid'], isAuthorValid) )(state); }); } render() { return ( <ArticleFormComponent formData={this.state} submitForm={this.submitForm.bind(this)} changeArticleTitle={(event) => this.changeArticleTitle(event)} changeArticleAuthor={(event) => this.changeArticleAuthor(event)} /> ) } } 

Please note that the ArticleFormContainer returns exactly the form that the user sees, that is, the submitted ArticleFormComponent . This component displays container-passed data and generates events like changeArticleTitle , changeArticleAuthor and submitForm .


ArticleFormComponent.js :


 // @flow import React from 'react'; import type {FormData} from './ArticleFormContainer'; type Props = { formData: FormData; changeArticleTitle: Function; changeArticleAuthor: Function; submitForm: Function; } export const ArticleFormComponent = (props: Props) => { const { formData, changeArticleTitle, changeArticleAuthor, submitForm } = props; const onSubmit = (submitHandler) => (event) => { event.preventDefault(); submitHandler(event); }; return ( <form noValidate onSubmit={onSubmit(submitForm)} > <div> <label htmlFor="article-title">Title</label> <input type="text" id="article-title" name="articleTitle" autoComplete="off" value={formData.articleTitle.value} onChange={changeArticleTitle} /> {!formData.articleTitle.valid && (<p>Please fill in the title</p>)} </div> <div> <label htmlFor="article-author">Author</label> <input type="text" id="article-author" name="articleAuthor" autoComplete="off" value={formData.articleAuthor.value} onChange={changeArticleAuthor} /> {!formData.articleAuthor.valid && (<p>Please fill in the author</p>)} </div> <button type="submit" value="Submit" > Create article </button> </form> ) }; 

Now we have a form for creating articles, the list has come. ArticleListContainer subscribes to ArticleStore , gets all articles and displays ArticleListComponent .


ArticleListContainer.js:


 // @flow import * as React from 'react' import type {Article} from "../domain/Article"; import type {ArticleStore} from "../store/ArticleStore"; import {articleStore} from "../store/ArticleStore"; import {ArticleListComponent} from "./ArticleListComponent"; type State = { articles: Article[] } type Props = {}; export class ArticleListContainer extends React.Component<Props, State> { subscriber: Function; articleStore: ArticleStore; constructor(props: Props) { super(props); this.articleStore = articleStore; this.state = { articles: [] }; this.subscriber = this.articleStore.subscribe((articles: Article[]) => { this.setState({articles}); }); } componentWillUnmount() { this.articleStore.unsubscribe(this.subscriber); } render() { return <ArticleListComponent {...this.state}/>; } } 

ArticleListComponent is the component responsible for the presentation. It gets the available articles through the properties and ArticleContainer components of ArticleContainer .


ArticleListComponent.js:


 // @flow import React from 'react'; import type {Article} from "../domain/Article"; import {ArticleContainer} from "./ArticleContainer"; type Props = { articles: Article[] } export const ArticleListComponent = (props: Props) => { const {articles} = props; return ( <div> { articles.map((article: Article, index) => ( <ArticleContainer article={article} key={index} /> )) } </div> ) }; 

ArticleContainer transfers the article data to the ArticleComponent . It also implements the likeArticle and removeArticle .


The likeArticle method updates the number of likes by replacing the article in the repository with an updated copy, and the removeArticle method removes the article from the repository.


ArticleContainer.js:


 // @flow import React, {Component} from 'react'; import type {Article} from "../domain/Article"; import type {ArticleService} from "../domain/ArticleService"; import type {ArticleStore} from "../store/ArticleStore"; import {articleService} from "../domain/ArticleService"; import {articleStore} from "../store/ArticleStore"; import {ArticleComponent} from "./ArticleComponent"; type Props = { article: Article; }; export class ArticleContainer extends Component<Props> { articleStore: ArticleStore; articleService: ArticleService; constructor(props: Props) { super(props); this.articleStore = articleStore; this.articleService = articleService; } likeArticle(article: Article) { const updatedArticle = this.articleService.updateLikes(article, article.likes + 1); this.articleStore.updateArticle(updatedArticle); } removeArticle(article: Article) { this.articleStore.removeArticle(article); } render() { return ( <div> <ArticleComponent article={this.props.article} likeArticle={(article: Article) => this.likeArticle(article)} deleteArticle={(article: Article) => this.removeArticle(article)} /> </div> ) } } 

ArticleContainer transfers these articles to the ArticleComponent , which displays them. Also, this method, by executing the corresponding callbacks, notifies the container component about pressing the Like or Delete buttons.


Remember the insane requirement, how should the name of the author look like? ArticleComponent uses ArticleUiService from the application level to project a portion of the state from its original value (string without capital letters) to the desired, written in capital letters.


ArticleComponent.js:


 // @flow import React from 'react'; import type {Article} from "../domain/Article"; import * as articleUiService from "../services/ArticleUiService"; type Props = { article: Article; likeArticle: Function; deleteArticle: Function; } export const ArticleComponent = (props: Props) => { const { article, likeArticle, deleteArticle } = props; return ( <div> <h3>{article.title}</h3> <p>{articleUiService.displayAuthor(article.author)}</p> <p>{article.likes}</p> <button type="button" onClick={() => likeArticle(article)} > Like </button> <button type="button" onClick={() => deleteArticle(article)} > Delete </button> </div> ); }; 

Fine!


Now we have a fully functional React-application with a reliable and understandable architecture. Anyone new to the team can read this article and confidently connect to our work :)


The finished application is here , and the GitHub repository is here .


')

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


All Articles