📜 ⬆️ ⬇️

Hierarchical dependency injection in React and MobX State Tree as a domain model

I had a chance somehow after several projects on React to work on the application under Angular 2. Frankly speaking, I was not impressed. But one moment was remembered - management of the logic and state of the application using Dependency Injection. And I wondered, is it convenient to manage state in React using DDD, a multi-layered architecture, and dependency injection?


If you are interested in how to do this, and most importantly, why - welcome under the cat!


To be honest, even on the backend DI is rarely used to its fullest. Unless in really big applications. And in small and medium ones, even with DI, each interface usually has only one implementation. But dependency injection still has its advantages:



But modern testing libraries for JS, such as Jest , allow writing mocks simply based on the ES6 modular system. So here we don’t get much profit from DI.


There remains a second point - the management of the scope and lifetime of objects. On the server, the lifetime is usually associated with the entire application (Singleton), or with the request. And on the client, the main unit of the code is the component. To him we will be attached.


If we need to use the state at the application level, the easiest way is to create a variable at the level of the ES6 module and import it where necessary. And if the state is needed only inside the component, we simply put it in this.state . For everything else, there is a Context . But Context is too low level:





The new Hook useContext() slightly corrects the situation for functional components. But we can’t get rid of the <Context.Provider> . Until we turn our context into a Service Locator, and its parent component into a Composition Root. But here it is not far to DI, so let's get started!


You can skip this part and go directly to the description of the architecture.

DI implementation


First we need React Context:


 export const InjectorContext= React.createContext(null); 

Since React uses the component's constructor for its needs, we will use Property Injection. To do this, we define the decorator @inject , which:



inject.js
 import "reflect-metadata"; export function inject(target, key) { //  static cotextType target.constructor.contextType = InjectorContext; //    const type = Reflect.getMetadata("design:type", target, key); //  property Object.defineProperty(target, key, { configurable: true, enumerable: true, get() { //  Injector       const instance = getInstance(getInjector(this), type); Object.defineProperty(this, key, { enumerable: true, writable: true, value: instance }); return instance; }, // settet     Dependency Injection set(instance) { Object.defineProperty(this, key, { enumerable: true, writable: true, value: instance }); } }); } 

Now we can define dependencies between arbitrary classes:


 import { inject } from "react-ioc"; class FooService {} class BarService { @inject foo: FooService; } class MyComponent extends React.Component { @inject foo: FooService; @inject bar: BarService; } 

For those who do not accept decorators, we define the function inject() with the following signature:


 type Constructor<T> = new (...args: any[]) => T; function inject<T>(target: Object, type: Constructor<T> | Function): T; 

inject.js
 export function inject(target, keyOrType) { if (isFunction(keyOrType)) { return getInstance(getInjector(target), keyOrType); } // ... } 

This will allow you to define dependencies explicitly:


 class FooService {} class BarService { foo = inject(this, FooService); } class MyComponent extends React.Component { foo = inject(this, FooService); bar = inject(this, BarService); //   static contextType = InjectorContext; } 

What about the functional components? For them, we can implement Hook useInstance()


hooks.js
 import { useRef, useContext } from "react"; export function useInstance(type) { const ref = useRef(null); const injector = useContext(InjectorContext); return ref.current || (ref.current = getInstance(injector, type)); } 

 import { useInstance } from "react-ioc"; const MyComponent = props => { const foo = useInstance(FooService); const bar = useInstance(BarService); return <div />; } 

Now we define how our Injector may look, how to find it, and how to resolve dependencies. The injector should contain a link to the parent, an object cache for already resolved dependencies, and a dictionary of rules for those not yet allowed.


injector.js
 type Binding = (injector: Injector) => Object; export abstract class Injector extends React.Component { //    Injector _parent?: Injector; //    _bindingMap: Map<Function, Binding>; //      _instanceMap: Map<Function, Object>; } 

For the React components, the Injector is available through the this.context field, and for dependency classes, we can temporarily place the Injector in a global variable. To speed up the search for the injector for each class, we will cache the link to the Injector in a hidden field.


injector.js
 export const INJECTOR = typeof Symbol === "function" ? Symbol() : "__injector__"; let currentInjector = null; export function getInjector(target) { let injector = target[INJECTOR]; if (injector) { return injector; } injector = currentInjector || target.context; if (injector instanceof Injector) { target[INJECTOR] = injector; return injector; } return null; } 

To find a specific binding rule, we need to go up the injector tree using the getInstance() function


injector.js
 export function getInstance(injector, type) { while (injector) { let instance = injector._instanceMap.get(type); if (instance !== undefined) { return instance; } const binding = injector._bindingMap.get(type); if (binding) { const prevInjector = currentInjector; currentInjector = injector; try { instance = binding(injector); } finally { currentInjector = prevInjector; } injector._instanceMap.set(type, instance); return instance; } injector = injector._parent; } return undefined; } 

We turn finally to the registration of dependencies. To do this, we need a HOC provider() , which accepts an array of dependency bindings for their implementations, and registers a new Injector via InjectorContext.Provider


provider.js
 export const provider = (...definitions) => Wrapped => { const bindingMap = new Map(); addBindings(bindingMap, definitions); return class Provider extends Injector { _parent = this.context; _bindingMap = bindingMap; _instanceMap = new Map(); render() { return ( <InjectorContext.Provider value={this}> <Wrapped {...this.props} /> </InjectorContext.Provider> ); } static contextType = InjectorContext; static register(...definitions) { addBindings(bindingMap, definitions); } }; }; 

Also, a set of bindings functions that implement various strategies for creating dependency instances.


bindings.js
 export const toClass = constructor => asBinding(injector => { const instance = new constructor(); if (!instance[INJECTOR]) { instance[INJECTOR] = injector; } return instance; }); export const toFactory = (depsOrFactory, factory) => asBinding( factory ? injector => factory(...depsOrFactory.map(type => getInstance(injector, type))) : depsOrFactory ); export const toExisting = type => asBinding(injector => getInstance(injector, type)); export const toValue = value => asBinding(() => value); const IS_BINDING = typeof Symbol === "function" ? Symbol() : "__binding__"; function asBinding(binding) { binding[IS_BINDING] = true; return binding; } export function addBindings(bindingMap, definitions) { definitions.forEach(definition => { let token, binding; if (Array.isArray(definition)) { [token, binding = token] = definition; } else { token = binding = definition; } bindingMap.set(token, binding[IS_BINDING] ? binding : toClass(binding)); }); } 

Now we can register dependency bindings at the level of an arbitrary component as a set of pairs [<>, <>] .


 import { provider, toClass, toValue, toFactory, toExisting } from "react-ioc"; @provider( //    [FirstService, toClass(FirstServiceImpl)], //     [SecondService, toValue(new SecondServiceImpl())], //    [ThirdService, toFactory( [FirstService, SecondService], (first, second) => ThirdServiceFactory.create(first, second) )], //      [FourthService, toExisting(FirstService)] ) class MyComponent extends React.Component { // ... } 

Or in abbreviated form for classes:


 @provider( // [FirstService, toClass(FirstService)] FirstService, // [SecondService, toClass(SecondServiceImpl)] [SecondService, SecondServiceImpl] ) class MyComponent extends React.Component { // ... } 

Since the lifetime of a service is determined by the component provider in which it is registered, for each service we can define a cleaning method .dispose() . In it, we can unsubscribe from some events, close sockets, etc. When you remove a provider from the DOM, it will call .dispose() on all the services it creates.


provider.js
 export const provider = (...definitions) => Wrapped => { // ... return class Provider extends Injector { // ... componentWillUnmount() { this._instanceMap.forEach(instance => { if (isObject(instance) && isFunction(instance.dispose)) { instance.dispose(); } }); } // ... }; }; 

For code separation and lazy loading, we may need to invert the method of registering services with providers. With this we can help decorator @registerIn()


provider.js
 export const registrationQueue = []; export const registerIn = (getProvider, binding) => constructor => { registrationQueue.push(() => { getProvider().register(binding ? [constructor, binding] : constructor); }); return constructor; }; 

injector.js
 export function getInstance(injector, type) { if (registrationQueue.length > 0) { registrationQueue.forEach(registration => { registration(); }); registrationQueue.length = 0; } while (injector) { // ... } 

 import { registerIn } from "react-ioc"; import { HomePage } from "../components/HomePage"; @registerIn(() => HomePage) class MyLazyLoadedService {} 


So, for 150 lines and 1 KB of code, you can implement an almost complete hierarchical DI-container .


Application architecture


Finally, let's get to the main point - how to organize the application architecture. There are three possible options depending on the size of the application, the complexity of the subject area and our laziness.


1. The Ugly


We have the same Virtual DOM, which means it should be fast. At least under this sauce React was served at the dawn of a career. So just remember the link to the root component (for example, using the @observer decorator). And we will call on it .forceUpdate() after each action affecting shared services (for example, using the @action decorator)


observer.js
 export function observer(Wrapped) { return class Observer extends React.Component { componentDidMount() { observerRef = this; } componentWillUnmount() { observerRef = null; } render() { return <Wrapped {...this.props} />; } } } let observerRef = null; 

action.js
 export function action(_target, _key, descriptor) { const method = descriptor.value; descriptor.value = function() { let result; runningCount++; try { result = method.apply(this, arguments); } finally { runningCount--; } if (runningCount === 0 && observerRef) { observerRef.forceUpdate(); } return result; }; } let runningCount = 0; 

 class UserService { @action doSomething() {} } class MyComponent extends React.Component { @inject userService: UserService; } @provider(UserService) @observer class App extends React.Component {} 

It will even work. But ... You understand :-)


2. The Bad


We are not satisfied with rendering everything for every sneeze. But we still want to use nearly ordinary objects and arrays for storing state. Let's take MobX !


We get several data stores with standard actions:


 import { observable, action } from "mobx"; export class UserStore { byId = observable.map<number, User>(); @action add(user: User) { this.byId.set(user.id, user); } // ... } export class PostStore { // ... } 

Business logic, I / O and others are placed in the services layer:


 import { action } from "mobx"; import { inject } from "react-ioc"; export class AccountService { @inject userStore userStore; @action updateUserInfo(userInfo: Partial<User>) { const user = this.userStore.byId.get(userInfo.id); Object.assign(user, userInfo); } } 

And distribute them into components:


 import { observer } from "mobx-react"; import { provider, inject } from "react-ioc"; @provider(UserStore, PostStore) class App extends React.Component {} @provider(AccountService) @observer class AccountPage extends React.Component{} @observer class UserForm extends React.Component { @inject accountService: AccountService; } 

The same for functional components and without decorators
 import { action } from "mobx"; import { inject } from "react-ioc"; export class AccountService { userStore = inject(this, UserStore); updateUserInfo = action((userInfo: Partial<User>) => { const user = this.userStore.byId.get(userInfo.id); Object.assign(user, userInfo); }); } 

 import { observer } from "mobx-react-lite"; import { provider, useInstance } from "react-ioc"; const App = provider(UserStore, PostStore)(props => { // ... }); const AccountPage = provider(AccountService)(observer(props => { // ... })); const UserFrom = observer(props => { const accountService = useInstance(AccountService); // ... }); 

It turns out a classic three-tier architecture.


3. The Good


Sometimes the subject area becomes so complex that it is already inconvenient to work with it using simple objects (or an anemic model in terms of DDD). This is especially noticeable when the data has a relational structure with many links. In such cases, the MobX State Tree library comes to the rescue, allowing you to apply the principles of the Domain-Driven Design in the frontend application architecture.


Designing a model begins with a description of the types:


 // models/Post.ts import { types as t, Instance } from "mobx-state-tree"; export const Post = t .model("Post", { id: t.identifier, title: t.string, body: t.string, date: t.Date, rating: t.number, author: t.reference(User), comments: t.array(t.reference(Comment)) }) .actions(self => ({ voteUp() { self.rating++; }, voteDown() { self.rating--; }, addComment(comment: Comment) { self.comments.push(comment); } })); export type Post = Instance<typeof Post>; 

models / User.ts
 import { types as t, Instance } from "mobx-state-tree"; export const User = t.model("User", { id: t.identifier, name: t.string }); export type User = Instance<typeof User>; 

models / Comment.ts
 import { types as t, Instance } from "mobx-state-tree"; import { User } from "./User"; export const Comment = t .model("Comment", { id: t.identifier, text: t.string, date: t.Date, rating: t.number, author: t.reference(User) }) .actions(self => ({ voteUp() { self.rating++; }, voteDown() { self.rating--; } })); export type Comment = Instance<typeof Comment>; 

And the type of data storage:


 // models/index.ts import { types as t } from "mobx-state-tree"; export { User, Post, Comment }; export default t.model({ users: t.map(User), posts: t.map(Post), comments: t.map(Comment) }); 

Entity types contain the state of the domain model and basic operations with it. More complex scenarios, including I / O, are implemented in the services layer.


services / DataContext.ts
 import { Instance, unprotect } from "mobx-state-tree"; import Models from "../models"; export class DataContext { static create() { const models = Models.create(); unprotect(models); return models; } } export interface DataContext extends Instance<typeof Models> {} 

services / AuthService.ts
 import { observable } from "mobx"; import { User } from "../models"; export class AuthService { @observable currentUser: User; } 

services / PostService.ts
 import { inject } from "react-ioc"; import { action } from "mobx"; import { Post } from "../models"; export class PostService { @inject dataContext: DataContext; @inject authService: AuthService; async publishPost(postInfo: Partial<Post>) { const response = await fetch("/posts", { method: "POST", body: JSON.stringify(postInfo) }); const { id } = await response.json(); this.savePost(id, postInfo); } @action savePost(id: string, postInfo: Partial<Post>) { const post = Post.create({ id, rating: 0, date: new Date(), author: this.authService.currentUser.id, comments: [], ...postInfo }); this.dataContext.posts.put(post); } } 

The main feature of the MobX State Tree is effective work with data snapshots. At any time, we can get the serialized state of any entity, collection, or even the entire state of the application using the getSnapshot() function. And in the same way we can apply snapshot to any part of the model using applySnapshot() . This allows us in a few lines of code to initialize the state from the server, load it from LocalStorage, or even interact with it via Redux DevTools.


Since we use the normalized relational model, we need the normalizr library to load the data. It allows you to translate tree-like JSON into flat tables of objects, grouped by id , according to the data scheme. Just in that format that MobX State Tree is necessary in quality snapshot.


To do this, we define the schema of objects downloaded from the server:


 import { schema } from "normalizr"; const UserSchema = new schema.Entity("users"); const CommentSchema = new schema.Entity("comments", { author: UserSchema }); const PostSchema = new schema.Entity("posts", { //   - //      author: UserSchema, comments: [CommentSchema] }); export { UserSchema, PostSchema, CommentSchema }; 

And load the data into the repository:


 import { inject } from "react-ioc"; import { normalize } from "normalizr"; import { applySnapshot } from "mobx-state-tree"; export class PostService { @inject dataContext: DataContext; // ... async loadPosts() { const response = await fetch("/posts.json"); const posts = await response.json(); const { entities } = normalize(posts, [PostSchema]); applySnapshot(this.dataContext, entities); } // ... } 

posts.json
 [ { "id": 123, "title": "    React", "body": "  -     React...", "date": "2018-12-10T18:18:58.512Z", "rating": 0, "author": { "id": 12, "name": "John Doe" }, "comments": [{ "id": 1234, "text": "Hmmm...", "date": "2018-12-10T18:18:58.512Z", "rating": 0, "author": { "id": 12, "name": "John Doe" } }] }, { "id": 234, "title": "Lorem ipsum", "body": "Lorem ipsum dolor sit amet...", "date": "2018-12-10T18:18:58.512Z", "rating": 0, "author": { "id": 23, "name": "Marcus Tullius Cicero" }, "comments": [] } ] 

Finally, register the services in the respective components:


 import { observer } from "mobx-react"; import { provider, inject } from "react-ioc"; @provider(AuthService, PostService, [ DataContext, toFactory(DataContext.create) ]) class App extends React.Component { @inject postService: PostService; componentDidMount() { this.postService.loadPosts(); } } 

It turns out all the same three-layer architecture, but with the ability to save state and run-time data type checking (in DEV-mode). The latter allows you to be sure that, if no exception occurred, the state of the data warehouse corresponds to the specification.







For those who were interested, a link to github and demo .


')

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


All Articles