📜 ⬆️ ⬇️

ReactJS + MobX - experience using DI

It seems to me that the time has come to share the approach for writing the ReactJS App, I do not pretend to be unique.

You can skip the first paragraph . I’ve been working on web development for a long time, but for the last four years I've been sitting tight at ReactJS and I’m happy with everything, redux in my life, but about two years ago I met MobX, just a couple of months ago I tried to return to redux, but I don’t I could, I had a feeling that I was doing something extra, maybe something was wrong, many bytes on servers were already translated on this topic, the article is not about the coolness of one before the other, this is just an attempt to share its work, maybe someone really This approach will go down to the point.

Tasks that we will solve:


The structure of the project can be viewed at Github . Therefore, I’ll skip how to write a primitive application and the article will only highlight

We introduce such concepts as: data model, service, stor.

Let's get a simple model

TodoModel.ts
import { observable, action } from 'mobx'; export class TodoModel { @observable public id: number; @observable public text: string = ''; @observable public isCompleted: boolean = false; @action public set = (key: 'text' | 'isCompleted', value: any): void => { this[key] = value; }; } 


what you see as a set action, in a model is more an exception than a good tone, usually there is a basic model with primitive helpers in the project and from it I simply inherit, in models there should not be action games at all.

Now we need to learn how to work with this model, we will get the service:

TodoService.ts
 import { Service, Inject } from 'typedi'; import { plainToClass, classToClass } from 'class-transformer'; import { DataStorage } from '../storage/DataStorage'; import { action } from 'mobx'; import { TodoModel } from '../models/TodoModel'; const responseMock = { items: [ { id: 1, isCompleted: false, text: 'Item 1' }, { id: 2, isCompleted: true, text: 'Item 2' } ] }; @Service('TodoService') export class TodoService { @Inject('DataStorage') public dataStorage: DataStorage; @action public load = async () => { await new Promise(resolve => setTimeout(resolve, 300)); this.dataStorage.todos = plainToClass(TodoModel, responseMock.items); }; @action public save(todo: TodoModel): void { if (todo.id) { const idx = this.dataStorage.todos.findIndex(item => todo.id === item.id); this.dataStorage.todos[idx] = classToClass(todo); } else { const todos = this.dataStorage.todos.slice(); todo.id = Math.floor(Math.random() * Math.floor(100000)); todos.push(todo); this.dataStorage.todos = todos; } this.clearTodo(); } @action public edit(todo: TodoModel): void { this.dataStorage.todo = classToClass(todo); } @action public clearTodo(): void { this.dataStorage.todo = new TodoModel(); } } 


In our service there is a link to

DataStorage.ts
 import { Service } from 'typedi'; import { observable } from 'mobx'; import { TodoModel } from '../models/TodoModel'; @Service('DataStorage') export class DataStorage { @observable public todos: TodoModel[] = []; @observable public todo: TodoModel = new TodoModel(); } 


In this store, we will store the state of our application, there may be many such stores, but as practice has shown, there is no point in breaking into many small stores. In the stores as well as in the models there should not be action games.

We have almost everything ready, all that is left is to connect to our application, for this we need a bit of injector from mobx-react:

DI
 import { inject } from 'mobx-react'; export function DI(...classNames: string[]) { return (target: any) => { return inject((props: any) => { const data: any = {}; classNames.forEach(className => { const name = className.charAt(0).toLowerCase() + className.slice(1); data[name] = props.container.get(className); }); data.container = props.container; return data; })(target); }; } 


and get a container for our DI

browser.tsx
 import 'reflect-metadata'; import * as React from 'react'; import { hydrate } from 'react-dom'; import { renderRoutes } from 'react-router-config'; import { Provider } from 'mobx-react'; import { BrowserRouter } from 'react-router-dom'; import { Container } from 'typedi'; import '../application'; import { routes } from '../application/route'; hydrate( <Provider container={Container}> <BrowserRouter>{renderRoutes(routes)}</BrowserRouter> </Provider>, document.getElementById('root') ); 


For the browser, we always have one container, but for the server render, you need to look, it is better to organize your container for each request:

server.tsx
 import * as express from 'express'; import * as React from 'react'; import { Container } from 'typedi'; import '../application'; // @ts-ignore import * as mustacheExpress from 'mustache-express'; import * as path from 'path'; import { renderToString } from 'react-dom/server'; import { StaticRouter } from 'react-router'; import { Provider } from 'mobx-react'; import * as uuid from 'uuid'; import { renderRoutes, matchRoutes } from 'react-router-config'; import { routes } from '../application/route'; const app = express(); const ROOT_PATH = process.env.ROOT_PATH; const currentPath = path.join(ROOT_PATH, 'dist', 'server'); const publicPath = path.join(ROOT_PATH, 'dist', 'public'); app.engine('html', mustacheExpress()); app.set('view engine', 'html'); app.set('views', currentPath + '/views'); app.use(express.static(publicPath)); app.get('/favicon.ico', (req, res) => res.status(500).end()); app.get('*', async (request, response) => { const context: any = {}; const id = uuid.v4(); const container = Container.of(id); const branch = matchRoutes(routes, request.url); const promises = branch.map(({ route, match }: any) => { return route.component && route.component.loadData ? route.component.loadData(container, match) : Promise.resolve(null); }); await Promise.all(promises); const markup = renderToString( <Provider container={container}> <StaticRouter location={request.url} context={context}> {renderRoutes(routes)} </StaticRouter> </Provider> ); Container.remove(id); if (context.url) { return response.redirect( context.location.pathname + context.location.search ); } return response.render('index', { markup }); }); app.listen(2016, () => { // tslint:disable-next-line console.info("application started at 2016 port"); }); 


The server render is actually a delicate thing, on the one hand, I want to let everything go through it, but it has only one business task, to give content to bots , so it’s better to check for something like that “has the user been authorized at least once on the site” , and skip server render with the creation of containers on the server.

Well, now to our components:

MainRoute.tsx
 import * as React from 'react'; import { TodoService } from '../service/TodoService'; import { observer } from 'mobx-react'; import { DI } from '../annotation/DI'; import { DataStorage } from '../storage/DataStorage'; import { Todo } from '../component/todo'; import { Form } from '../component/form/Form'; import { ContainerInstance } from 'typedi'; interface IProps { todoService?: TodoService; dataStorage?: DataStorage; } @DI('TodoService', 'DataStorage') @observer export class MainRoute extends React.Component<IProps> { public static async loadData(container: ContainerInstance) { const todoService: TodoService = container.get('TodoService'); await todoService.load(); } public componentDidMount() { this.props.todoService.load(); } public render() { return ( <div> <Form /> <ul> {this.props.dataStorage.items.map(item => ( <li key={item.id} ><Todo model={item} /></li> ))} </ul> </div> ); } } 


Here everything turns out very logical and beautiful, our “render” view for drawing takes data from our site, the component hooks say at what point in time we should load the data.

Todo.tsx
 import * as React from 'react'; import { TodoModel } from '../../models/TodoModel'; import { TodoService } from '../../service/TodoService'; import { DI } from '../../annotation/DI'; import { observer } from 'mobx-react'; interface IProps { model: TodoModel; todoService?: TodoService; } @DI('TodoService') @observer export class Todo extends React.Component<IProps> { public render() { const { model, todoService } = this.props; return ( <> <input type='checkbox' checked={model.isCompleted} onChange={e => model.set('isCompleted', e.target.checked)} /> <h4>{model.text}</h4> <button type='button' onClick={() => todoService.edit(model)}>Edit</button> </> ); } } 


Form.tsx
 import * as React from 'react'; import { observer } from 'mobx-react'; import { DI } from '../../annotation/DI'; import { TodoService } from '../../service'; import { DataStorage } from '../../storage'; import { TextField } from '../text-field'; interface IProps { todoService?: TodoService; dataStorage?: DataStorage; } @DI('TodoService', 'DataStorage') @observer export class Form extends React.Component<IProps> { public handleSave = (e: any) => { e.preventDefault(); this.props.todoService.save(this.props.dataStorage.todo); }; public handleClear = () => { this.props.todoService.clearTodo(); }; public render() { const { dataStorage } = this.props; return ( <form onSubmit={this.handleSave}> <TextField name='text' model={dataStorage.todo} /> <button>{dataStorage.todo.id ? 'Save' : 'Create'}</button> <button type='button' onClick={this.handleClear}> Clear </button> </form> ); } } 


In my opinion, working with forms is much more convenient through the models / dtoshki, you can use the usual native forms, and updating the data model and everyone who listens to it will be updated instantly.

This is how I use this combination of libraries: react, class-transformer, mobx, typedi

We now use this approach in terms of sales, these are very large projects, with common common components and services.

If this approach is interesting, I will tell you how in the same way we do model validation before sending to the server, how we handle server errors and how we synchronize our state between browser tabs.

In fact, everything is very bonal: "class-validator", "localStorage + window.addEventListener ('storage')"

Thank you for reading :-)

Example

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