📜 ⬆️ ⬇️

DI for fully reusable JSX components

Dependency inception


Hi, my name is Sergey and I am interested in the problem of re-using components on the web. Looking at how trying to apply SOLID to a reactor, I decided to continue this topic and show how to achieve good reusability by developing the idea of dependency injection or DI.


DI as a basis for building a framework, applied to the web, is quite a young approach. So that here it would be clearer about what it is about, I will start with things familiar to react-developers.


From Contexts to DI


I am sure that many used contexts when working with react. If not directly, then surely through connect in redux or inject in mobx-react. The bottom line is that in one component (MessageList) we declare something in context, and in the other (Button) we say that we want to get this something out of context.


const PropTypes = require('prop-types'); const Button = ({children}, context) => <button style={{background: context.color}}> {children} </button>; Button.contextTypes = {color: PropTypes.string}; class MessageList extends React.Component { getChildContext() { return {color: "purple"}; } render() { return <Button>Ok</Button>; } } 

Those. once in the parent component context.color is set, and then it is automatically forwarded to any underlying components, in which color is declared through contextTypes. Thus, the Button can be customized without passing through the properties in the hierarchy. And at any level of the hierarchy, you can use getChildContext() ... override the color for all child components.


This approach better isolates components from each other, simplifying their configuration and reuse. In the example above, it is enough to determine the color in the parent component and all buttons change color. Moreover, the Button component may be in another library, which, however, does not need to be refactored.


However, for the reactor, due to the lack of reasonableness, this approach is still poorly developed. The developers do not recommend using it directly:


It is an experimental API.

written in the documentation. It has been experimental in its current form for quite a long time and the feeling that the development has reached a dead end. The contexts in the components are linked to the infrastructure (getChildContext), pseudotyping via PropTypes, and more like a service locator , which some consider antipattern . The role of contexts, in my opinion, is underestimated and in the reactor is secondary: localization and temization , as well as linking to libraries like redux and mobx.


In other frameworks, similar tools are better developed. For example, in vue: provide / inject , and in angular, its angular di is already a full featured DI with support for typescript types. In fact, starting with Angular second version, the developers tried to rethink the backend experience (where DI has been around for a long time) in relation to the frontend. And what if you try to develop a similar idea for the reactor and its clones, what problems would be solved?


To nail or not, that is the question


In a full reaction / redux application, not everything is done through redux actions. The state of some insignificant ticks easier to implement through setState. It turns out - through redux is cumbersome, and through setState is not universal, but simpler, because he is always at hand. The article You Might Not Need Redux by a famous author, as if says “if you don’t need scaling - do not use redux”, confirming this duality. The problem is that it is not needed now, and tomorrow it may be necessary to fasten the state of the checkmark to a screw.


Another article by the same author, Presentational and Container Components , says roughly that "All components are equal (Presentational), but some are more equal (Container)" and are carved in granite (nailed to redux, mobx, relay, setState). The customization of the Container component is complicated - it is not intended to reuse it, it is already nailed to the state implementation and context.


In order to somehow simplify the creation of a Container-component, a HOC was invented, but in fact little has changed. Just began to combine a pure component through connect / inject with something like redux, mobx, relay. And the resulting monolithic Container used in the code.


In other words, we say Presentational and Container, and we mean - reusable and non-reusable. The first is convenient to customize. all the extension points are in properties, and the second is refactoring, because the properties are smaller, due to its hacking to the state and some logic. This is a kind of compromise in solving two opposite problems, the price for which is the separation of components into two types and the sacrifice of the principle of openness / closeness .


For example, as in the article Replace and Conquer - the SOLID approach , where it is proposed to make most of the components as simple as possible, impairing their integrity. But, complex components from simple ones will still need to be assembled somewhere, and the question remains how to customize them. Those. The problem is transferred to another level.


 <ModalBackdrop onClick={() => this.setState({ dialogOpen: false })} /> <ModalDialog open={this.state.dialogOpen} > <ModalDialogBox> <ModalDialogHeaderBox> <ModalDialogCloseButton onClick={() => this.setState({ dialogOpen: false })} /> <ModalDialogHeader>Dialog header</ModalDialogHeader> </ModalDialogHeaderBox> <ModalDialogContent>Some content</ModalDialogContent> <ModalDialogButtonPanel> <Button onClick={() => this.setState({ dialogOpen: false })} key="cancel"> {resources.Navigator_ButtonClose} </Button> <Button disabled={!this.state.directoryDialogSelectedValue} onClick={this.onDirectoryDialogSelectButtonClick} key="ok"> {resources.Navigator_ButtonSelect} </Button> </ModalDialogButtonPanel> </ModalDialogBox> </ModalDialog> </ModalBackdrop> 

If we nevertheless agree that we don’t customize these end components, then in reality we get a large number of template code, when, for the sake of replacing one Button, the entire component is rebuilt. Full SOLID with this approach is impossible. There will always be binding components to the state that cannot be expanded without modification and template components without logic inside and which are difficult to use.


Prototype


By developing the idea of ​​dependency injection, you can solve some of these problems. Let us analyze the solution based on the following example:


 // @flow // @jsx lom_h // setup... class HelloService { @mem name = '' } function HelloView(props: {greet: string}, service: HelloService) { return <div> {props.greet}, {service.name} <br/><input value={service.name} onChange={(e) => { service.name = e.target.value }} /> </div> } // HelloView.deps = [HelloService] ReactDOM.render(<HelloView greet="Hello"/>, document.getElementById('mount')) 

fiddle


Here there is one universal form of the component as a function, regardless of whether it works with the state or not. Contexts use types. Descriptions of dependencies are automatically generated from them using babel-plugin-transform-metadata . Similarly, the typescript that does this, however, is only for classes. Although you can manually describe the arguments: HelloView.deps = [HelloService]


Lifecycle


But what about the life cycle of the component? Is it really necessary to have low-level work with it in the code? Using HOC, they are trying to remove these lifecycle methods from the core code, for example, as in relay / graphql .


The idea is that updating the data is not the responsibility of the component. If your data is loaded after access to this data (for example, lazyObservable from mobx-utils is used ), then componentDidMount is not needed in this case. If you need to tie the jquery plugin, then there is a refs property in the element, etc.


Assume that the universal component, free of vendor lock-in of the reactor, is now there. Suppose we even allocated it to a separate library. It remains to decide how to expand and customize what comes into context. After all, HelloService is a kind of default implementation.


Go there - I do not know where, bring it - I do not know what


What if the components, due to frequent requirements changes, are the part of the application where encapsulation begins to interfere. Not by itself of course, but in the form as it is implemented today in almost all frameworks: in the form of a template, a composition of functions, or JSX.


Imagine for a second that in the case of any component it is impossible to say in advance that it will have its own customization. And we need a way to change any internal part of the component without refactoring (the principle of openness / closeness), while not worsening its readability, not complicating its original implementation and not investing in reusability initially (everything cannot be foreseen).


For example, without DI, you can design, implying customization through inheritance. Those. split up the content into small methods, losing in visibility and hierarchy. The author writes about the disadvantages of this approach in the article Ideal UI framework :


 class MyPanel extends React.Component { header() { return <div class="my-panel-header">{this.props.head}</div> } bodier() { return <div class="my-panel-bodier">{this.props.body}</div> } childs() { return [ this.header() , this.bodier() ] } render() { return <div class="my-panel">{this.childs()}</div> } 

 class MyPanelExt extends MyPanel { footer() { return <div class="my-panel-footer">{this.props.foot}</div> } childs() { return [ this.header() , this.bodier() , this.footer() ] } } 

I must say that this author ( @vintage ) came up with the tree format, which allows us to describe the above example while preserving the hierarchy. Despite the fact that many people criticize this format, it has the advantage of just redefining even the smallest details without special partitioning and refactoring. In other words, this is a free (almost, apart from comprehending a new unusual concept) letter O in SOLID.


It is impossible to fully transfer this principle to JSX, but you can try to partially implement it through DI. The point is that any component in the hierarchy is also an extension point, a slot, if we argue in terms of vue. And we in the parent component can change its implementation, knowing its identifier (the original implementation or interface). This is how many dependency containers work, allowing you to associate implementations with interfaces.


In js / ts, at runtime, without complicating or embedding string keys that degrade the security of the code, you cannot refer to the interface. Therefore, the following example will not work in flow or typescript (but a similar one will work in C # or Dart):


 interface ISome {} class MySome implements ISome {} const map = new Map() map.set(ISome, MySome) 

However, you can refer to an abstract class or function.


 class AbstractSome {} class MySome extends AbstractSome {} const map = new Map() map.set(AbstractSome, MySome) 

Since When objects and components are created inside a DI container, and there can be a similar map inside, then any implementation can be overridden. And since components, except for the most primitive ones, are functions, then they can be substituted for functions with the same interface, but with a different implementation.


For example, TodoResetButtonView is part of TodoView. It is required to override the TodoResetButtonView on the custom implementation.


 function TodoResetButtonView({onClick}) { return <button onClick={onClick}>reset</button> } function TodoView({todo, desc, reset}) { return <li> <input type="checkbox" checked={todo.finished} onClick={() => todo.finished = !todo.finished} />{todo.title} #{todo.id} ({desc.title}) <TodoResetButtonView>reset</TodoResetButtonView> </li> } 

Suppose we have no way to edit TodoView (it’s in another library and we don’t want to touch it, violating the open / close principle and re-testing 11 other projects that used it with the old button).


Therefore, we create a new button and clone the existing TodoView, replacing it in the clone. This inheritance, only visibility is not violated - the hierarchy remains and you do not need to specifically design TodoView so that you can replace the button.


 function ClonedTodoResetButtonView({onClick}) { return <button onClick={onClick}>cloned reset</button> } const ClonedTodoView = cloneComponent(TodoView, [ [TodoResetButtonView, ClonedTodoResetButtonView] ], 'ClonedTodoView') const ClonedTodoListView = cloneComponent(TodoListView, [ [TodoView, ClonedTodoView] ], 'ClonedTodoListView') ReactDOM.render(<ClonedTodoListView todoList={store} />, document.getElementById('mount')); 

fiddle


Sometimes it is necessary to redefine not only components, but also their dependencies:


 class AbstractHelloService { name: string } function HelloView(props: {greet: string}, service: AbstractHelloService) { return <div> {props.greet}, {service.name} <br/><input value={service.name} onChange={(e) => { service.name = e.target.value }} /> </div> } class AppHelloService { @mem name = 'Jonny' } function AppView() { return <HelloView greet="Hello"/> } AppView.aliases = [ [AbstractHelloService, AppHelloService] ] 

fiddle


HelloView will receive an instance of the AppHelloService class. Since AppView.aliases for all child components overrides AbstractHelloService.


Of course, there is also a minus of the “everything customizes” approach through inheritance. Since the framework provides more extension points, then more responsibility for customization is shifted to the one who uses the component, rather than designs it. Redefining parts of the "table" component, without realizing the meaning, can accidentally turn it into a "list", and this is a bad sign, since is a distortion of the original meaning (the LSP principle is violated).


State separation


By default, the state in the dependencies of the component will be allocated for each component. However, the general principle is that everything defined in the components above takes precedence over the underlying dependencies. Those. if the dependency is first used in the parent component, then it will live with it and all the underlying components that requested it will receive the parent instance.


 class HelloService { @mem name = 'John' } function HelloView(props: {greet: string}, service: HelloService) { return <div> {props.greet}, {service.name} <br/><input value={service.name} onChange={(e) => { service.name = e.target.value }} /> </div> } class AppHelloService { @mem name = 'Jonny' } function AppView(_, service: HelloService) { return <div> <HelloView greet="Hello"/> <HelloView greet="Hi"/> </div> } 

fiddle


In this configuration, both HelloViews share a shared instance of HelloService. However, without HelloService in AppView, for each child component will be its own copy.


 function AppView() { return <div> <HelloView greet="Hello"/> <HelloView greet="Hi"/> </div> } 

A similar principle, when it is possible to control which component the object belongs to, is used in the hierarchical DI Angular.


Styles


I am not claiming that the css-in-js approach is the only correct one to use on the web. But even here you can apply the idea of ​​dependency injection. The problem is similar to the one described above with redux / mobx and contexts. For example, as in many similar libraries, jss styles are nailed to a component through an injectSheet wrapper, and the component is associated with a specific implementation of styles, with react-jss:


 import React from 'react' import injectSheet from 'react-jss' const styles = { button: { background: props => props.color }, label: { fontWeight: 'bold' } } const Button = ({classes, children}) => ( <button className={classes.button}> <span className={classes.label}> {children} </span> </button> ) export default injectSheet(styles)(Button) 

However, this direct dependence on jss and others like him can be removed by transferring this responsibility to DI. In the application code, it is sufficient to define a function with styles, as a component dependency, and mark it accordingly.


 // ... setup import {action, props, mem} from 'lom_atom' import type {NamesOf} from 'lom_atom' class Store { @mem red = 140 } function HelloTheme(store: Store) { return { wrapper: { background: `rgb(${store.red}, 0, 0)` } } } HelloTheme.theme = true function HelloView( _, {store, theme}: { store: Store, theme: NameOf<typeof HelloTheme> } ) { return <div className={theme.wrapper}> color via css {store.red}: <input type="range" min="0" max="255" value={store.red} onInput={({target}) => { store.red = Number(target.value) }} /> </div> } 

fiddle


Such an approach for styles has all the advantages of DI, thus it provides the theme and reactivity. Unlike variables in css , types in flow / ts work here. Of the minuses - the overhead of generating and updating css.


Total


In an attempt to adapt the idea of ​​introducing dependencies for components, the library turned out to be reactive-di . Simple examples in the article are based on it, but there are more complex ones , with loading, processing of loading statuses, errors, etc. There is a todomvc benchmark for react, preact, inferno. In which you can appreciate the overhead from the use of reactive-di. True, by 100 todos, the measurement error I had was greater than this overhead.


It turned out simplified Angular. However, there are a number of features, reactive-di


  1. Able to integrate with the reactor and its clones, while remaining compatible with legacy components in a clean reactor
  2. Allows you to write on some pure components without wrapping them in mobx / observe or similar.
  3. Works well with types in flowtype not only for classes, but also for component functions.
  4. Unobtrusive: no heaps of decorators are required in the code, components are abstracted from react, it can be changed to its implementation without affecting the main code
  5. Easy to configure, no dependency registration required, provide / inject-like constructions
  6. Allows you to add content to the component without modifying it, preserving the hierarchy of its internals
  7. Allows unobtrusively, through interfaces, to integrate css-in-js solutions into components

Why is the idea of ​​contexts still not developed in this way? Most likely, the unpopularity of DI on the frontend is explained by the non-ubiquitous dominance of flow / ts and the lack of standard interface support at the metadata level. Attempts to copy complex implementations from other backend-oriented languages ​​(like InversifyJS Ninject clone from C #) without deep rethinking. And also while an insufficient emphasis: for example, some similarity of DI is in react and vue, but there these implementations are an inseparable part of the framework and their role is secondary.


A good DI is another half of the solution. In the examples above, the @mem decorator is often flashed, which is necessary to manage the condition built on the ORP idea. With mem, you can write code in a pseudo-sync style, with simple, in comparison with mobx, error handling and loading statuses. I will tell you about him in the next article.


')

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


All Articles