The developers of React have a functional approach, but with the advent of MobX, it is possible to work with the state in a more or less familiar OOP style. Mobx tries not to impose any architecture, allowing you to work with the reactive state, as with ordinary objects. At the same time, he makes automatic linking of calculations, when it suffices to write C = A + B
, so that when A
is updated, C
updated.
In HelloWorld, this looks simple, but if we add fetch, the display of load statuses and error handling, we see that a lot of copy-paste is obtained, and helpers like when, fromPromise or lazyObservable start to leak into the code. And it is already impossible to write code as if there is no asynchrony. I want to analyze some similar examples in MobX and try to improve its basic concept by developing the idea of pseudo-sync.
Consider the simplest to-do list on MobX and React.
const {action, observable, computed} = mobx; const {observer} = mobxReact; const {Component} = React; let tid = 0 class Todo { id = ++tid; @observable title; @observable finished = false; constructor(title) { this.title = title; } } function fetchSomeTodos(genError) { return new Promise((resolve) => setTimeout(() => { resolve([ new Todo('Get Coffee'), new Todo('Write simpler code') ]) }, 500)) } class TodoList { @observable todos = []; @computed get unfinishedTodoCount() { return this.todos.filter(todo => !todo.finished).length; } @action fetchTodos() { fetchSomeTodos() .then(todos => { this.todos = todos }) } } const TodoView = observer(({todo}) => { return <li> <input type="checkbox" checked={todo.finished} onClick={() => todo.finished = !todo.finished} />{todo.title} </li> }) TodoView.displayName = 'TodoView' @observer class TodoListView extends Component { componentDidMount() { this.props.todoList.fetchTodos() } render() { const {todoList} = this.props return <div> <ul> {todoList.todos.map(todo => <TodoView todo={todo} key={todo.id} /> )} </ul> Tasks left: {todoList.unfinishedTodoCount} </div> } } const store = new TodoList() ReactDOM.render(<TodoListView todoList={store} />, document.getElementById('mount'))
In the simple case, the component should start downloading data through componentWillMount. Each time, creating a new component that uses todoList, the programmer needs to remember that todoList.todos needs to be loaded. If this is not done, then who will guarantee that someone up there has already downloaded this data?
You can, of course, better separate the state and the UI without componentWillMount for download purposes. This is what the MobX author Michel Weststrate says in the article How to decouple state and UI . When you open the page, all the data needed to render it is requested from the server. And the author suggests transferring the responsibility for initializing this download to the router.
import { createHistory } from 'history'; import { Router } from 'director'; export function startRouter(store) { // update state on url change const router = new Router({ "/document/:documentId": (id) => store.showDocument(id), "/document/": () => store.showOverview() }).configure({ notfound: () => store.showOverview(), html5history: true }).init() }
This approach causes a problem - the router must know what exactly the data components need to be on the page being opened. Calling the store.showOverview
method at this point in the code breaks the encapsulation. What will happen if during refactoring a new component was added to the page, which needs to get something from the server, and the download is not added to the router? It is easy to make a mistake here, since the details of working with the stor are spread over different places of the application.
The fetchTodos () call does not have to be in componentWillMount. It can be disguised as a HOC, a router, a call to onClick in some button, or even directly called in index.js, as in the example with redux-saga :
... import rootSaga from './sagas' const store = configureStore(window.__INITIAL_STATE__) store.runSaga(rootSaga) ...
Where store.runSaga(rootSaga)
immediately starts loading all the necessary data for the application.
The essence is the same - in the code there will be a place where the programmer must initiate the download. And this place will be outside the model or the fact that instead of it (for example, the saga), although by its very meaning the fact of the initialization call is just an internal detail of working with the network. If you remove the asynchrony, it becomes unnecessary. Moreover, the loading in such solutions does not occur after the component accesses this data, but in advance.
In MobX, errors and download status will not get onto the interface by themselves. To display them, we need to create an error property in the store for each loaded entity. In each component with todoList.todos, you need to do the processing of this property, which in most cases will be the same - show the text or the stack trace in dev-mode. If the programmer forgets to process them - the user will not see anything, even the words "Something went wrong."
class TodoList { @observable todos = [] @observable error: ?Error @observable pending = false @action fetchTodos(genError) { this.pending = true this.error = null fetchSomeTodos(genError) .then(todos => { this.todos = todos; this.pending = false }) .catch(error => { this.error = error; this.pending = false }) } } @observer class TodoListView extends Component { componentWillMount() { this.props.todoList.fetchTodos() } render() { const {todoList} = this.props return <div> {todoList.pending ? 'Loading...' : null} {todoList.error ? todoList.error.message : null} ... </div> } }
There is a lot of sample code in the previous example, both in the stack and in the component. To reduce copy-paste, you can use the fromPromise helper from mobx-utils , which, together with the value, gives the download status to this value. Here is an example of the demonstration of his work:
class TodoList { @observable todoContainer constructor() { this.fetchTodos() } // ... @action fetchTodos(genError) { this.todoContainer = fromPromise(fetchSomeTodos(genError)) } } const StatusView = ({fetchResult}) => { switch(fetchResult.state) { case "pending": return <div>Loading...</div> case "rejected": return <div>Ooops... {JSON.stringify(fetchResult.value)}</div> } } const TodoListView = observer(({todoList}) => { const todoContainer = todoList.todoContainer return <div> {todoContainer.state === 'fulfilled' ? ... : <StatusView fetchResult={todoContainer}/> } ... </div> })
We already have a todoContainer property that contains the value and status. It is easier to process it in a component. In the example above, the fetchTodos call is made in the TodoList constructor. Unlike the routing example, this makes it possible to better encapsulate implementation details without exposing fetchTodos to the outside. The fetchTodos method remains a private part of the TodoList implementation.
Cons of this approach:
new TodoList()
sends request to the server class TodoList { //... @computed get unfinishedTodoCount() { return this.todoContainer.value ? this.todoContainer.value.filter(todo => !todo.finished).length : [] } //... }
To make the loading from the last example lazy, after rendering the component (and not in new TodoList), you can wrap fromPromise into the lazyObservable helper from mobx-utils. The download will begin after the component executes todoContainer.current ().
class TodoList { constructor() { this.todoContainer = lazyObservable(sink => sink(fromPromise(fetchSomeTodos()))) } @computed get unfinishedTodoCount() { const todos = this.todoContainer.current() return todos && todos.status === 'fulfilled' ? todos.filter(todo => !todo.finished).length : [] } } const StatusView = ({fetchResult}) => { if (!fetchResult || fetchResult.state === 'pending') return <div>Loading...</div> if (fetchResult.state === 'rejected') return <div>{fetchResult.value}</div> return null } const TodoListView = observer(({todoList}) => { const todoContainer = todoList.todoContainer const todos = todoContainer.current() return <div> {todos && todos.state === 'fulfilled' ? <div> <ul> {todos.value.map(todo => <TodoView todo={todo} key={todo.id} /> )} </ul> Tasks left: {todoList.unfinishedTodoCount} </div> : <StatusView fetchResult={todos}/> } <button onClick={() => todoContainer.refresh()}>Fetch</button> </div> })
The lazyObservable helper solves the problem of laziness, but it does not save from the template code in the component. Yes, and the construction of lazyObservable(sink => sink(fromPromise(fetchSomeTodos())))
is not as easy as fetchSomeTodos().then(todos => this.todos = todos)
in the first version of the list.
Remember the idea of "writing as if there is no asynchrony." What if go further MobX? Maybe someone already did it?
So far, in my opinion, mol_atom has advanced farthest . This library is part of the mol framework from vintage . Here, in Habré, the author has written many articles about him and about the principles of his work (for example, Object Reactive Programming or PIU). Mol is interesting for his original ideas that are not found anywhere else. The problem is that he has his own ecosystem. You can't take mol_atom and start using it in a project with a reactor, webpack, etc. So I had to write my own implementation, lom_atom. Essentially, this is mol_atom adaptation, sharpened for use with the reactor.
Consider a similar example with a todo list on lom. To begin, look at the component page.
/** @jsx lom_h */ //... class TodoList { @force $: TodoList @mem set todos(next: Todo[] | Error) {} @mem get todos() { fetchSomeTodos() .then(todos => { this.$.todos = todos }) .catch(error => { this.$.todos = error }) throw new mem.Wait() } // ... } function TodoListView({todoList}) { return <div> <ul> {todoList.todos.map(todo => <TodoView todo={todo} key={todo.id} /> )} </ul> Tasks left: {todoList.unfinishedTodoCount} </div> }
The following happens here.
get todos()
will work, and the code that loads data from the server will be executed.throw new mem.Wait()
.this.$.todos = todos
(this. $ - means entry to the cache is performed, bypassing the set todos() {}
call).ErrorableView can be this content:
function ErrorableView({error}: {error: Error}) { return <div> {error instanceof mem.Wait ? <div> Loading... </div> : <div> <h3>Fatal error !</h3> <div>{error.message}</div> <pre> {error.stack.toString()} </pre> </div> } </div> }
No matter what component and what data it uses, the default behavior is the same for all: with any exception, either a twist (in the case of mem.Wait) or an error text is shown. This behavior saves code and nerves, but sometimes it needs to be overridden. To do this, you can set a custom ErrorableView:
function TodoListErrorableView({error}: Error) { return <div>{error instanceof mem.Wait ? 'pending...' : error.message}</div> } //... TodoListView.onError = TodoListErrorableView
You can simply catch the exception inside the TodoListView by wrapping in try / catch todoList.todos. The exception thrown in a component drops only it, while drawing ErrorableView.
function TodoView({todo}) { if (todo.id === 2) throw new Error('oops') return <li>...</li> }
In this example, we will see Fatal error only in place of the second todo.
Such an exception-based approach provides the following benefits:
get todos()
handler.Compared to MobX, the boilerplate has become much smaller. Each line is a line of business logic.
And what will happen if in one component you display several loadable entities, that is, besides todos, for example, there are still users.
class TodoList { @force $: TodoList @mem set users(next: {name: string}[] | Error) {} @mem get users() { fetchSomeUsers() .then(users => { this.$.users = users }) .catch(error => { this.$.users = error }) throw new mem.Wait() } //... } function TodoListView({todoList}) { const {todos, users} = todoList //... todos.map(...) users.map(...) }
If at the first render of TodoListView todos and users are not loaded, instead of them, proxy objects will come to the component. That is, when we write const {todos, users} = todoList
, get todos()
and get users()
are executed, their parallel loading is initiated, mem.Wait is thrown, mem wraps the exception in a proxy. In the component, when accessing the todos.map properties or users.map, the mem.Wait exception is thrown and an ErrorableView is rendered. After loading, the component will be rendered again, but with real data in todos and users.
This is what the mol is called synchronous code, but non-blocking requests .
In this approach, however, there is also a minus - you must first pull todos and users from todoList and then work with them, otherwise there will be a consistent download and optimization will not work.
The examples above are pretty straightforward. The decorator mem is such a smart cache, that is, if todos loaded once, then the second time mem will give them away from the cache.
Once there is a cache, then it should be possible to write to the cache, bypassing the set todos
handler. So there is a cache invalidation problem. You need a way to automatically reset the value if the dependency has changed, you also need to be able to manually reset the value, if you need to rewind data by pressing the button, and so on.
Cleaning when a dependency changes and updating a component is solved in the same way as MobX. And the problem of manual cache management is solved through the force decorator. His work is demonstrated by the following example:
class TodoList { @force forced: TodoList // .. } function TodoListView({todoList}) { return <div> ... <button onClick={() => todoList.forced.todos}>Reset</button> </div>
When you click the Reset button, todoList.forced.todos is requested, which unconditionally executes get todos
and refills the cache. When assigning a value to todoList.forced.todos, the value is written to the cache, bypassing the set todos
handler.
Remember the above was the code with this.$.todos = todos
?
/** @jsx lom_h */ //... class TodoList { @force $: TodoList @mem set todos(next: Todo[] | Error) {} @mem get todos() { fetchSomeTodos() .then(todos => { this.$.todos = todos }) .catch(error => { this.$.todos = error }) throw new mem.Wait() } // ... }
A cache entry is a private detail of get todos
. When fetch receives data in it, it will write it to the cache directly, bypassing the set todos
call. No entry to todoList. $. Todos is allowed. But resetting the cache (reading todoList. $. Todos) may well be initiated from the outside to repeat the request.
The way it looks with force now is not the most intuitive solution, but it does not add helpers to the code, it practically does not distort the interface of class properties (it’s not necessary to do everything with methods), that is, it remains unobtrusive. And it is very easy to solve a whole class of tasks that inevitably arise in MobX-like approaches. The main thing here is to understand some of the rules:
todoList.$.todos
.set todos
(it can contain data in different api, validation), we do todoList.todos = newTodos
.set todos
, we do todoList. $. Todos. This can only be done inside get/set todos
.In lom_atom, there are no observable wrappers for object properties and arrays, as in MobX. But there is a simple key-value dictionary. For example, if each todo needed to separately load the description by todoId, instead of a property, you can use the method, where the first argument is the key to which the description is cached, the second is the description itself.
class TodoList { // ... @force forced: TodoList @mem.key description(todoId: number, todoDescription?: Description | Error) { if (todoDescription !== undefined) return todoDescription // set mode fetchTodoDescription(todoId) .then(description => this.forced.description(todoId, description)) .catch(error => this.forced.description(todoId, error)) throw new mem.Wait() } } function TodoListView({todoList}) { return <div> <ul> {todoList.todos.map(todo => <TodoView todo={todo} desc={todoList.description(todo.id)} reset={() => todoList.forced.description(todo.id)} key={todo.id} /> )} </ul> // ... </div> }
If you run todoList.description(todo.id)
, the method will work as a getter, similar to get todos
.
Since the method is one and the function 2 is get / set, there is a branch inside:
if (todoDescription !== undefined) return todoDescription // set mode
That is, if todoDescription !== undefined
, then the method is called as a setter: todoList.description(todo.id, todo)
. The key can be any serializable type, objects and arrays will be serialized into keys with some loss of performance.
Why did I start talking about MobX at the beginning? The fact is that there is usually nothing in the business requirements about asynchrony - these are private details of the implementation of working with data, they try to abstract away from it in every way — through streams, promises, async / await, fibers, etc. The abstractions are easier to win on the web and less intrusive. For example, async / await is less intrusive compared to promises, since this is a language construct, the usual try / catch works, it is not necessary to pass functions to then / catch. In other words, the async / await code looks more like non-asynchronous code.
As an antipode of this approach, RxJS can be mentioned. Here you need to plunge into functional programming, introduce a heavyweight library into the language and learn its API. You build a stream of simple calculations, inserting them into a huge number of library expansion points, or replacing all operations with functions. If still RxJS was in the standard of the language, however along with it there are most, pull-stream, beacon, ramda and many others in a similar style. And each introduces its own specification for the implementation of the OP, which can no longer be changed without rewriting the business logic.
Mobx does not introduce new specifications to describe observable structures. Native classes remain, and decorators work transparently and do not distort the interface. Its API is much simpler due to automatic data binding, there are no numerous wrappers visible over the data.
Updating data, handling statuses and errors in components is also trickling asynchrony: infrastructure, which in most cases is indirectly related to the subject area. Applications without Fetch on MobX look simple, but it’s worth adding this necessary layer, like asynchronous ears start to stick out of each service or more or less complex component. Either we have a template code, or helpers, cluttering up business logic and degrading the purity of the idea of "writing as if there is no asynchrony." The data structure is complicated, along with the data itself, the details of the implementation of the communication channel are leaked into the components: errors and data loading statuses.
As an alternative to MobX, lom_atom tries to solve these problems at the core, without bringing in helpers. To adapt to the components of the reactor, reactive-di is used (similar in meaning to mobx-react). I talked about it in my first article , about trying to develop the idea of reactor contexts, getting more flexible components in customization, a reusable alternative to HOC and better integration of components with flow-types and, in perspective, cheap SOLID.
I hope, I was able to show with the example of atoms how a small refinement of the basic concept can significantly simplify the code in typical tasks for the web and save the components from knowing the details of data acquisition. And this is a small part of what the PIU can do. In my opinion, this is a whole field of programming with its patterns, strengths and weaknesses. And such things as mol_atom, MobX, delegated-properties in Kotlin are the first attempts to find the contours of this area. If someone is aware of similar approaches in other languages and ecosystems - write in the comments, it may be interesting.
Source: https://habr.com/ru/post/340840/
All Articles