Длинные уши асинхронности


    Разработчики React тяготеют к функциональному подходу, но с появлением MobX, появилась возможность работать с состоянием в более-менее привычном ООП-стиле. Mobx старается не навязывать какую либо архитектуру, позволяя работать с реактивным состоянием, как с обычными объектами. При этом он делает автоматическое связывание вычислений, когда достаточно написать C = A + B, чтобы при обновлении A, обновился и C.


    В HelloWorld это выглядит просто, но если мы добавим fetch, отображение статусов загрузки и обработку ошибок, мы увидим, что получается много копипаста, а в код начинают просачиваться хелперы вроде when, fromPromise или lazyObservable. И уже не получается писать код так, как будто нет асинхронности. Я хочу разобрать некоторые подобные примеры в MobX и попытаться улучшить его базовую концепцию, развив идею псевдосинхронности.


    Загрузка данных


    Рассмотрим простейший список дел на MobX и 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'))

    fiddle


    В простом случае компонент через componentWillMount должен сам начать загрузку данных. Каждый раз, создавая новый компонент, используюший todoList, программисту надо держать в голове, что todoList.todos надо загрузить. Если этого не сделать, то кто даст гарантию, что кто-то там наверху уже загрузил эти данные?


    Можно, конечно, лучше разделить состояние и UI без componentWillMount для целей загрузки. О чем и говорит автор MobX Michel Weststrate в статье How to decouple state and UI. При открытии страницы все данные, необходимые для ее рендеринга, запрашиваются с сервера. А ответственность по инициализации этой загрузки автор предлагает перенести на роутер.


    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()
    }

    Такой подход порождает проблему — роутер должен знать, что конкретно из данных требуется компонентам, которые будут на открываемой странице. Вызов метода store.showOverview в этом месте кода нарушает инкапсуляцию. Что будет, если в ходе рефакторинга на страницу добавили новый компонент, которому надо что-то получить с сервера, а в роутер не добавили загрузку? Ошибиться здесь легко, так как детали работы со стором размазаны по разным местам приложения.


    Вызов fetchTodos() не обязательно должен быть в componentWillMount. Он может быть замаскирован за HOC, за роутером, за вызовом onClick в какой-нибудь кнопке, даже напрямую вызываться в index.js, как в примере с redux-saga:


    ...
    import rootSaga from './sagas'
    const store = configureStore(window.__INITIAL_STATE__)
    store.runSaga(rootSaga)
    ...

    Где store.runSaga(rootSaga) сразу запускает загрузку всех необходимых для работы приложения данных.


    Суть одна — в коде будет место, где программист должен инициировать загрузку. И это место будет вне модели или того что вместо неё (например саги), хотя по смыслу сам факт вызова инициализации — лишь внутренняя деталь работы с сетью. Если убрать асинхронность, то она становится ненужной. Причем загрузка в подобных решениях происходит не по факту обращения компонента к этим данным, а заранее.


    Обработка ошибок при асинхронной загрузке


    В MobX ошибки и статус загрузки сами собой никак не попадут на интерфейс. Чтобы их отобразить, нам для каждой загружаемой сущности надо создать свойство error в сторе. В каждом компоненте с todoList.todos необходимо cделать обработку этого свойства, которая в большинстве случаев будет одинаковой — показать надпись или stack trace в dev-режиме. Если программист забудет их обработать — пользователь не увидит ничего, даже надписи «Что-то пошло не так».


    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>
        }
    }

    fiddle


    Используем fromPromise


    Шаблонного кода в предыдущем примере много, как в сторе, так и в компоненте. Для уменьшения копипаста можно использовать хелпер fromPromise из mobx-utils, который вместе со значением отдает статус загрузки этого значения. Вот пример демонстрации его работы:


    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>
    })

    fiddle


    У нас уже есть свойство todoContainer, которое содержит значение и статус. Обработать в компоненте его уже проще. В примере выше вызов fetchTodos делается в конструкторе стора TodoList. В отличие от примера с роутингом, это позволяет лучше инкапсулировать детали реализации, не выставляя fetchTodos наружу. Метод fetchTodos остается приватной деталью реализации TodoList.


    Минусы такого подхода:


    1. Нарушается ленивость загрузки, new TodoList() отсылает запрос к серверу
    2. В компоненте все равно надо вставлять проверки на состояние загрузки и показывать соответствующее сообщение.
    3. Ладно если только в компоненте. В реальном приложении источников данных может быть много и не все они напрямую прокидываются в компонент, некоторые преобразуются через вычисляемые (computed) значения. В каждом таком значении надо постоянно проверять статус до каких-либо действий с данными. Как в методе unfinishedTodoCount из примера выше

    class TodoList {
        //...
        @computed get unfinishedTodoCount() {
            return this.todoContainer.value
                ? this.todoContainer.value.filter(todo => !todo.finished).length
                : []
        }
        //...
    }

    Используем lazyObservable


    Чтобы загрузка из последнего примера происходила лениво, по факту рендеринга компонента (а не в new TodoList) можно обернуть fromPromise в хелпер lazyObservable из mobx-utils. Загрузка начнется, после того, как в компоненте выполнится 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>
    })

    fiddle


    Хелпер lazyObservable решает проблему ленивости, но не спасает от шаблонного кода в компоненте. Да и конструкция lazyObservable(sink => sink(fromPromise(fetchSomeTodos()))) уже не так просто выглядит как fetchSomeTodos().then(todos => this.todos = todos) в первой версии списка.


    Альтернатива


    Помните идею «пишем так, как будто нет асинхронности». Что если пойти дальше MobX? Может кто-то уже это сделал?


    Пока, на мой взгляд, дальше всех продвинулся mol_atom. Эта библиотека является частью фреймворка mol от vintage. Здесь, на хабре, автор написал много статей о нем и о принципах его работы (например, Объектное Реактивное Программирование или ОРП). Mol интереснен своими оригинальными идеями, которых нет нигде больше. Проблема в том, что у него полностью своя экосистема. Нельзя взять mol_atom и начать использовать в проекте с реактом, вебпаком и т. д. Поэтому пришлось написать свою реализацию, lom_atom. По сути это адаптация mol_atom, заточенная для использования с реактом.


    Ленивая актуализация


    Рассмотрим аналогичный пример с todo-листом на lom. Для начала посмотрим на стор с компонентом.


    /** @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>
    }

    fiddle


    Происходит тут следующее.


    1. Рендерится TodoListView.
    2. Этот компонент обратится к todoList.todos, сработает get todos() и выполнится код, загружающий данные с сервера.
    3. Данные еще не пришли, а компонент надо показать прямо сейчас. Тут мы можем либо возвратить какое-то значение по умолчанию либо, как в примере, бросить исключение: throw new mem.Wait().
    4. Декоратор mem его перехватывает и todos в TodoListView приходит прокси.
    5. При обращении к любому его свойству бросается исключение внутри TodoListView.
    6. Так как переопределенный createElement оборачивает этот компонент, а обертка эта перехватывает исключения, то будет показан ErrorableView, который задается настройкой библиотеки.
    7. Когда данные приходят с сервера, выполняется this.$.todos = todos (this.$ — означает запись в кэш, минуя вызов set todos() {}).

    ErrorableView может быть такого содержания:


    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>
    }

    Неважно какой компонент и какие данные в нем используются, поведение по умолчанию для всех одинаково: при любом исключении показывается либо крутилка (в случе mem.Wait), либо текст ошибки. Такое поведение сильно экономит код и нервы, но иногда его надо переопределить. Для этого можно задать кастомный ErrorableView:


    function TodoListErrorableView({error}: Error) {
      return <div>{error instanceof mem.Wait ? 'pending...' : error.message}</div>
    }
    //...
    TodoListView.onError = TodoListErrorableView

    fiddle


    Можно просто перехватить исключение внутри TodoListView, обернув в try/catch todoList.todos. Исключение, бросаемое в компоненте, роняет только его, рисуя ErrorableView.


    function TodoView({todo}) {
        if (todo.id === 2) throw new Error('oops')
        return <li>...</li>
    }

    fiddle


    В этом примере мы увидим Fatal error только на месте второго todo.


    Такой подход на исключениях дает следующие преимущества:


    1. Любое исключение будет обработано автоматически (нет больше this.error в TodoList) и пользователь увидит сообщение об ошибке.
    2. Исключения не ломают всё приложение, а только компонент, где оно произошло.
    3. Статусы загрузки обрабатываются автоматически, аналогично исключениям (нет больше this.status в TodoList).
    4. Идея настолько простая, что для превращения асинхронного кода в псевдосинхронный не нужно хелперов вроде fromPromise или lazyObservable. Все асинхронные операции инкапсулированы в обработчике get todos().
    5. Код выглядит практически синхронным (кроме fetch, но над ним можно сделать обертку, позволяющую записать его в псевдосинхронном виде).

    По сравнению с MobX бойлерплейта стало гораздо меньше. Каждая строчка — это строчка бизнес-логики.


    Неблокирующая загрузка


    А что будет, если в одном компоненте отобразить несколько загружаемых сущностей, то есть кроме todos, например, есть еще 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(...)
    }

    fiddle


    Если при первом рендере TodoListView todos и users не будут загружены, вместо них в компонент придут прокси-объекты. То есть когда мы пишем const {todos, users} = todoList, выполняются get todos() и get users(), инициируется их параллельная загрузка, бросается mem.Wait, mem оборачивает исключение в прокси. В компоненте, при обращении к свойствам todos.map или к users.map, выбросится исключение mem.Wait и отрендерится ErrorableView. После загрузки компонент еще раз отрендерится, но уже с реальными данными в todos и users.


    Это то, что в mol называется синхронный код, но неблокирующие запросы.


    У такого подхода правда есть и минус — необходимо сперва вытащить из todoList todos и users и только потом с ними работать, иначе будет последовательная загрузка и оптимизации не получится.


    Управление кэшем


    Примеры выше довольно простые. Декоратор mem это такой умный кэш, то есть если todos один раз загрузились, то во второй раз mem отдаст их из кэша.


    Раз есть кэш, значит должна быть возможность писать в кэш, минуя обработчик set todos. Значит есть проблема инвалидации кэша. Нужен способ автоматически сбрасывать значение, если зависимость изменилась, также нужно уметь вручную сбрасывать значение, если надо по нажатию кнопки перевытянуть данные и т. д.


    Очистка при изменении зависимости и обновление компонента решаются аналогично MobX. А проблема ручного управления кэшем решена через декоратор force. Его работу демонстрирует следующий пример:


    class TodoList {
        @force forced: TodoList
        // ..
    }
    function TodoListView({todoList}) {
      return <div>
        ...
        <button onClick={() => todoList.forced.todos}>Reset</button>
      </div>

    fiddle


    При нажатии кнопки Reset запрашивается todoList.forced.todos, который безусловно выполняет get todos и заново заполняет кэш. При присвоении значения к todoList.forced.todos значение же запишется в кэш, минуя обработчик set todos.


    Помните выше был код с 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()
        }
        // ...
    }

    Запись в кэш — это приватная деталь get todos. Когда fetch в нем получит данные, то запишет их в кэш напрямую, минуя вызов set todos. Извне запись в todoList.$.todos не допускается. А вот сброс кэша (чтение todoList.$.todos) вполне может быть инициирован извне, что бы повторить запрос.


    То, как это сейчас выглядит с force, не самое интуитивно понятное решение, но оно не привносит хелперов в код, оно практически не искажает интерфейс свойств класса (не надо все делать методами), то есть остается ненавязчивым. И очень просто решает целый класс задач, которые неизбежно возникают в MobX-подобных подходах. Тут главное понять некоторые правила:


    • Чтение todoList.todos берет из кэша.
    • Если хотим сбросить значение кэша, делаем чтение из todoList.$.todos.
    • Если хотим записать новое значение и чтобы при этом выполнился set todos (в нем может быть сохранение данных в разные апи, валидация), делаем todoList.todos = newTodos.
    • Если хотим записать значение напрямую в кэш, не выполняя set todos, делаем todoList.$.todos. Это можно делать только внутри get/set todos.

    Словари


    В lom_atom нет observable-оберток свойств-объектов и массивов, как в MobX. Но есть простой key-value словарь. Например, если к каждому todo понадобилось отдельно подгружать описание по todoId, вместо свойства можно использовать метод, где первый аргумент — ключ на который кэшируется описание, второй — само описание.


    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>
    }

    fiddle


    Если выполнить todoList.description(todo.id), то метод сработает как геттер, аналогично get todos.
    Так как метод один, а функции 2 — get/set, то внутри есть ветвление:


    if (todoDescription !== undefined) return todoDescription // set mode

    То есть если todoDescription !== undefined, значит метод вызван как сеттер: todoList.description(todo.id, todo). Ключ может быть любым сериализуемым типом, объекты и массивы будут сериализованы в ключи с некоторой потерей производительности.


    Почему MobX?


    Зачем я в начале завел разговор о MobX? Дело в том, что обычно в бизнес-требованиях ничего нет про асинхронность — это приватные детали реализации работы с данными, от неё пытаются всячески абстрагироваться — через потоки, промисы, async/await, волокна и т. д. Причем в вебе выигрывают абстракции проще и менее навязчивее. Например, async/await менее навязчив, по сравнению с промисами, так как это конструкция языка, работает привычный try/catch, не надо передавать функции в then/catch. Иными словами, код на async/await выглядит больше похожим на код без асинхронности.


    Как антипод этого подхода, можно упомянуть RxJS. Здесь уже надо окунаться в функциональное программирование, привносить в язык тяжеловесную библиотеку и изучать её API. Вы выстраиваете поток простых вычислений, вставляя их в огромное количество точек расширения библиотеки, или заменяете все операции на функции. Если бы еще RxJS был в стандарте языка, однако наряду с ним есть most, pull-stream, beacon, ramda и многие другие в схожем стиле. И каждый привносит свою спецификацию для реализации ФП, сменить которую уже не получится без переписывания бизнес-логики.


    Mobx же не привносит новых спецификаций для описания observable-структур. Остаются нативные классы, а декораторы работают прозрачно и не искажают интерфейс. API его гораздо проще за счет автоматического связывания данных, нет многочисленных видимых оберток над данными.


    Почему не MobX?


    Актуализация данных, обработка статусов и ошибок в компонентах — это тоже просачивающаяся асинхронность: инфраструктура, которая в большинстве случаев имеет косвенное отношение к предметной области. Приложения без fetch на MobX выглядят просто, однако стоит добавить этот необходимый слой, как уши асинхронности начинают торчать из каждого сервиса или более-менее сложного компонента. Либо у нас шаблонный код, либо хелперы, захламляющие бизнес логику и ухудшающие чистоту идеи «пишем так, как будто нет асинхронности». Структура данных усложняется, вместе с самими данными в компоненты просачиваются детали реализации канала связи: ошибки и статусы загрузки данных.


    Как альтернатива MobX, lom_atom пытается решить эти проблемы в основе, без привнесения хелперов. Для адаптации к компонентамам реакта используется reactive-di (по смыслу аналогичен mobx-react). О нем я рассказывал в своей первой статье, как о попытке развить идею контекстов реакта, получив более гибкие в настройке компоненты, переиспользуемую альтернативу HOC и лучшую интеграцию компонент с flow-типами и, в перспективе, дешевый SOLID.


    Итог


    Надеюсь, я смог показать на примере атомов, как небольшая доработка базовой концепции может существенно упростить код в типовых задачах для веба и избавить компоненты от знания деталей получения данных. И это небольшая часть того, что может ОРП. На мой взгляд, это целая область программирования со своими паттернами, достоинствами и недостатками. А такие вещи как mol_atom, MobX, delegated-properties в Kotlin это первые попытки нащупать контуры этой области. Если кому-то что-либо известно о подобных подходах в других языках и экосистемах — пишите в комментах, это может быть интересно.

    QIWI
    124.60
    Ведущий платёжный сервис нового поколения в России
    Share post

    Comments 47

      +1

      Звучит так как будто в mobx вам не хватало всего-то нескольких декораторов, а вы сделали полностью свою библиотеку

        +1
        Там не хватало некоторых принципиальных фичей на базовом уровне, декораторами это не сделать. Основное отличие в идее, что свойство в классе — это не просто данные или computed-функция, а данные+хэндлер, который срабатывает на чтение и запись.

        Еще обработка исключений построена иначе. Если начать рефакторить mobx, то каша из топора получится.

        Да и весь алгоритм в 300 строк получается.
          0
          А что не так с хендлером-то?
            0
            Не очень понял вопрос. Что вы считаете хэндлером в MobX?
            В MobX observable свойство — это обычно только данные. Mobx решает задачу как обновить состояние, что б точно перерендерилось необходимое.
            Но не решает задачу, как абстрагировать компоненты от способа загрузки данных. Есть куча хелперов поверх mobx, которые пытаются это делать, но они не решают проблему бойлерплейта и инкапсуляции, компоненты все-равно знают о статусах.

            В атомах, хэндер — часть спецификации ядра, на которую возложена задача абстракции канала связи, fetch. За сцену убираются детали, вроде pending/success/error.

            В итоге получается значительно меньше шаблонного кода. Сравните 2 эквивалентных примера:

            Пример на lom_atom
            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()
                }
                @mem get unfinishedTodoCount() {
                    return this.todos.filter(todo => !todo.finished).length;
                }
            }
            
            function TodoListView({todoList}) {
              return <div>
                <ul>
                  {todoList.todos.map(todo =>
                     <TodoView todo={todo} key={todo.id} />
                  )}
                </ul>
              </div>
            }
            
            fiddle

            Пример на MobX
            class TodoList {
                @observable todos = [];
                @observable pending = false
                @computed get unfinishedTodoCount() {
                    return this.todos.filter(todo => !todo.finished).length;
                }
                @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 })
                }
            }
            
            const TodoListView = observer(({todoList}) => {
                return <div>
                    {todoList.pending ? 'Loading...' : null}
                    {todoList.error ? todoList.error.message : null}
                    <ul>
                        {todoList.todos.map(todo => 
                            <TodoView todo={todo} key={todo.id} />
                        )}
                    </ul>
                    Tasks left: {todoList.unfinishedTodoCount}
                    <br/><button onClick={() => {todoList.fetchTodos(true)}}>Fetch with error</button>
                </div>
            })
            
            fiddle
              +2
              У вас все еще много бойлерплейта. Я бы предпочел вот такой вариант:

              class TodoList {
                  @lazyFetch get todos() {
                      return fetchSomeTodos();
                  }
                  @computed get unfinishedTodoCount() {
                      return this.todos.filter(todo => !todo.finished).length;
                  }
              }
              


              Осталось всего-то реализовать один декоратор, вместо написания новой библиотеки.
                0
                Основной бойлерплейт в компонентах и computed-свойствах. Моя позиция такова: error и status — просачивающиеся за пределы модели приватные детали работы с каналом связи. Как можно от них избавиться совсем?

                Я не нашел реализации lazyFetch, сложно судить. Но тут проблем больше, чем кажется на первый взгляд. Например, только некоторые из них:

                А если надо запустить загрузку и отдать дефолтное значение?
                Что будет, если в случае ошибки сперва будет запрошен unfinishedTodoCount?
                Как заставить принудительно еще раз вытянуть значение с сервера?
                Что будет, если у нас не промисы, а например Observable или вообще колбэки?
                Как провернуть такую же штуку, только для сохранения данных, причем разделять запись в кэш и запись на сервер?
                Что будет если где-то в observable или computed произошло исключение? Как нарисовать вместо сбойного компонента крестик, не руша все приложение?
                  +1
                  Я не нашел реализации lazyFetch, сложно судить.

                  Ее я предлагаю написать. Ее в любом случае надо написать, независимо от используемой библиотеки, потому что эти танцы с @force выглядят как "О_О что тут вообще происходит?!"


                  А если надо запустить загрузку и отдать дефолтное значение?

                  … то fromResource из mobx-utils подходит идеально. Как раз под эту задачу создавался.


                  Что будет, если в случае ошибки сперва будет запрошен unfinishedTodoCount?

                  То же самое что и в lom_atom.


                  Как заставить принудительно еще раз вытянуть значение с сервера?

                  Тут уже из самой постановки задачи торчат уши чего-то ненормального.


                  Что будет, если у нас не промисы, а например Observable или вообще колбэки?

                  То все упрощается.


                  Как провернуть такую же штуку, только для сохранения данных, причем разделять запись в кэш и запись на сервер?

                  Вы тоже на этот вопрос ответа не дали.


                  Что будет если где-то в observable или computed произошло исключение? Как нарисовать вместо сбойного компонента крестик, не руша все приложение?

                  Так же как это делаете вы.

                    0
                    Ее в любом случае надо написать, независимо от используемой библиотеки, потому что эти танцы с force выглядят как «О_О что тут вообще происходит?!»
                    Я преследую цель — придумать спецификацию, которая бы не зависела от внешних библиотек или хелперов, не усложняла бы интерфейс чистых классов. Force не самое удачное решение, но в этом смысле он менее всех захламляет предметную область. Может придумаю что-нибудь получше.
                    то fromResource из mobx-utils подходит идеально. Как раз под эту задачу создавался.
                    Вообще мне не очень понятно разделение на lazyObservable и fromResource. Они частично копируют функциональность друг друга, при этом каждый что-то свое добавляет. Оба работают с даннымми в схожем стиле через sink, при этом в lazyObservable — можно отслеживать статусы и делать refresh, но нельзя задать unsubscriber, fromResource — можно задать unsubscriber, но нельзя отслеживать статусы и делать refresh.
                    Так или иначе, можно добиться конечно, но ценой привнесения реализации хелпера со своей спецификацией. А если надо ослеживать статус и ошибку, придется усложнять интерфейс данных, добавляя status/error.
                    Сравните:
                    на fromResource
                    function createObservableUser(dbUserRecord) {
                      let currentSubscription;
                      return fromResource(
                        (sink) => {
                          // sink the current state
                          sink(dbUserRecord.fields)
                          // subscribe to the record, invoke the sink callback whenever new data arrives
                          currentSubscription = dbUserRecord.onUpdated(() => {
                            sink(dbUserRecord.fields)
                          })
                        },
                        () => {
                          // the user observable is not in use at the moment, unsubscribe (for now)
                          dbUserRecord.unsubscribe(currentSubscription)
                        }
                      )
                    }
                    
                    // usage:
                    const myUserObservable = createObservableUser(myDatabaseConnector.query("name = 'Michel'"))
                    
                    // use the observable in autorun
                    autorun(() => {
                      // printed everytime the database updates its records
                      console.log(myUserObservable.current().displayName)
                    })
                    
                    // ... or a component
                    const userComponent = observer(({ user }) =>
                      <div>{user.current().displayName}</div>
                    )
                    


                    на lom_atom
                    class UserStore {
                      @force $
                      constructor(dbUserRecord) { this.dbUserRecord = dbUserRecord }
                      @mem set user() {}
                      @mem get user() {
                        const {dbUserRecord} = this
                        const currentSubscription = dbUserRecord.onUpdated(() => {
                          this.$.user = {...this.$.user, current: dbUserRecord.fields}
                        })
                    
                        return {
                          current: dbUserRecord.fields,
                          destructor() {
                            dbUserRecord.unsubscribe(currentSubscription)
                          }
                        }
                      }
                    }
                    
                    // usage:
                    const myUserObservable = new UserStore(myDatabaseConnector.query("name = 'Michel'"))
                    // ...
                    // ... or a component
                    const userComponent = observer(({ user }) =>
                      <div>{user.current.displayName}</div>
                    )
                    

                    Если закрыть глаза на force (пока), то код вполне читабелен. В отличие от fromResource те же действия выражаются нативными кострукциями языка. Мне было интересно попытаться сделать на таком принципе.
                    То же самое что и в lom_atom.
                    Не то же самое, в MobX, детали, вроде value и status просачиваются в computed и в компоненты, сравните:
                    computed на MobX
                    class TodoList {
                        constructor() {
                          this.todoContainer = fromResource(
                            sink => sink(fromPromise(fetchSomeTodos()))
                          )
                        }
                    
                        @computed get unfinishedTodoCount() {
                            return this.todoContainer.current().value.filter(todo => !todo.finished).length
                        }
                    }
                    
                    const TodoListView = observer(({todoList}) => {
                        const todoContainer = todoList.todoContainer
                        const todos = todoContainer.current()
                        const unfinished = todoList.unfinishedTodoCount
                        return <div>
                            {todos && todos.state === 'fulfilled' 
                                ? <div>
                                    <ul>
                                    {todos.value.map(todo => 
                                        <TodoView todo={todo} key={todo.id} />
                                    )}
                                    </ul>
                                    Tasks left: {unfinished}
                                </div>
                                : <ErrorableView fetchResult={todos}/>
                            }
                        </div>
                    })
                    
                    fiddle
                    computed на lom_atom
                    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()
                        }
                        @mem get unfinishedTodoCount() {
                            return this.todos.filter(todo => !todo.finished).length;
                        }
                    }
                    
                    function TodoListView({todoList}) {
                      const unfinished = todoList.unfinishedTodoCount
                      return <div>
                        <ul>
                          {todoList.todos.map(todo => 
                             <TodoView todo={todo} key={todo.id} />
                          )}
                        </ul>
                        Tasks left: {unfinished}
                      </div>
                    }
                    
                    fiddle
                    В lom_atom не надо обрабатывать случаи загрузки и ошибки в unfinishedTodoCount. Если в MobX забыть это сделать в unfinishedTodoCount, приложение сломается, что и видно в первом примере.
                    Тут уже из самой постановки задачи торчат уши чего-то ненормального.
                    Не всегда апи бывает идеальным, например в сохранили todo через POST /todo, а после надо перевытянуть весь список /todos, т.к. другого способа получить обновленную сущность с серверным id нет.
                    Бывают случаи восстановления после ошибки, когда пользователь нажимает на кнопку Repeat и надо заново перевытянуть данные. В том же lazyObservable есть refresh().
                    Вы тоже на этот вопрос ответа не дали.
                    Не хотел здесь это затрагивать. Тема для отдельной статьи, если сформулируете задачу, то могу на ее основе написать с примерами.
                    Так же как это делаете вы.
                    В том то и дело, автоматизировать обработку ошибок и статусов на mobx мне пока не удалось. Либо все упирается в большую сложность оберток поверх mobx. Либо уходом от mobx, небольшой доработкой базовой спецификации и гораздо меньшей сложностью реализации далее.

                      +1

                      Кто вызывает destructor? Что произойдет если вызвать store.user.destructor() а потом снова прочитать store.user?


                      Не то же самое, в MobX, детали, вроде value и status просачиваются в computed

                      Это детали реализации fromPromise. Никто не запрещает вам сделать свой вариант который бы кидал исключение.


                      В lom_atom не надо обрабатывать случаи загрузки и ошибки в unfinishedTodoCount.

                      Потому что их обрабатывает какая-то магия на стороне вида. Добавляем такую же магию в mobx — и все работает.


                      В том же lazyObservable есть refresh().

                      Вот вы и нашли решение.


                      В том то и дело, автоматизировать обработку ошибок и статусов на mobx мне пока не удалось.

                      А в чем, собственно, проблема?

                        0
                        destructor косвенно вызывает компонент в componentWillUnmount. Также, если компонент перерендерился в очередной раз и данные эти не запросил.

                        Если вызвать из приложения напрямую — будет плохо, закроется ресурс, это также как у результата fromResource dispose вызвать. К сожалению, пока не понятно, как это инкапсулировать, не усложняя абстракции.

                        Иными словами, к такому виду привел поиск универсального минимального решения. Кстати первого не меня, а vintage, я пробовал реализовать аналог по-своему и на обсерваблах. Но отказался от этой идеи, Дмитрий убедил меня. Есть много нюансов, например, непонятно как с Observable форсировать обновление. Если интересно, есть epic issue, где я пробовал аргументировать разные варианты.

                        Это детали реализации fromPromise. Никто не запрещает вам сделать свой вариант который бы кидал исключение.
                        Как минимум, не получится сделать асинхронную неблокирующую загрузку, т.к. mobx не перехватывает внутри исключения и не преобразует их в Proxy. Не будут работать некоторые оптимизации обновления состояния, например, в ситуации, когда мы записываем значение в свойство, а хэндлер его отвергает и возвращает свое, мы снова записываем первое значение, в этот момент не должно вызываться обновление компонента. Как в этом тесте.

                        Общий приницип реализовать наверное можно, вопрос какой ценой. Да и зачем, если связку observable, computed, fromPromise, autorun, можно заменить на один mem, да и благодаря асинхронной природе модификации состояния атомов, actions не нужны ради оптимизации обновлений стейта.

                        Потому что их обрабатывает какая-то магия на стороне вида. Добавляем такую же магию в mobx — и все работает.
                        Вообще, может быть, будет время — поэкспериментирую. Но далеко не факт, кажущаяся простота, однако когда начинаешь делать, всплывает много деталей. Различие в обработке исключений может стать препятствием.

                        Вот вы и нашли решение.
                        Я все пытаюсь объяснить про интерфейсы, в идеале стоит стремиться к тому, что бы детали способа получения данных не протекали в компоненты. lazyObservable возвратит данные, запакованные в метаданные. Метаданные — детали канала связи, как вот тот refresh. С хорошей абстракцией, код TodoListView не должен меняться, если данные в todoList.todos были сперва захардкожены, а потом его решили получать с сервера. Идеального решения тут пока нет, но в lom_atom это свойство любого mem-значения, не нужно специально использовать хелпер, подобный lazyObservable.

                        А в чем, собственно, проблема?
                        Кроме вышесказанного могу добавить, что у mobx не было целей делать подобное в ядре, это библиотека просто работает с реактивным стейтом, не учитывая канал связи. Это неизбежно выливается в усложнение вышележащего уровня — хелперов. Усложнение как их реализации, так и их использования в прикладном коде.

                        Примитивность основы плохо сказывается на целостности экосистемы — появляется много решений, которые частично дублируют функциональность друг друга. Я исхожу из предположения, что кроме реактивности, абстракция работы с каналом связи — должна быть фундаментальным свойством фреймворка (а лучше языка). Эти вещи тесно связаны.
                          0
                          Как минимум, не получится сделать асинхронную неблокирующую загрузку, т.к. mobx не перехватывает внутри исключения

                          Кто вам это сказал?


                          С хорошей абстракцией, код TodoListView не должен меняться, если данные в todoList.todos были сперва захардкожены, а потом его решили получать с сервера.

                          Надо просто выдерживать одинаковый интерфейс. Механика зависимых свойств позволяет это делать.


                          Не будут работать некоторые оптимизации обновления состояния, например, в ситуации, когда мы записываем значение в свойство, а хэндлер его отвергает и возвращает свое, мы снова записываем первое значение, в этот момент не должно вызываться обновление компонента.

                          Будут. Работают.


                          Кроме вышесказанного могу добавить, что у mobx не было целей делать подобное в ядре, это библиотека просто работает с реактивным стейтом, не учитывая канал связи. Это неизбежно выливается в усложнение вышележащего уровня — хелперов. Усложнение как их реализации, так и их использования в прикладном коде.

                          Я перестал вас понимать.

                            0
                            Кто вам это сказал?
                            В принципе можно через lazyObservable, но как это сделать без оберток над данными, не усложняя код?

                            Надо просто выдерживать одинаковый интерфейс. Механика зависимых свойств позволяет это делать.
                            Вот слово «просто» бы раскрыть, какой это будет интерфейс? Если это интерфейс вроде {status, value, refresh}, предметная область будет замусорена такими обертками поверх данных.
                            Будут. Работают.
                            Какая-то не конструктивная беседа получается, давайте конкретнее, может добавите пример какой-нибудь?

                            Как в mobx предложить значение? Мы его записываем в observable-свойство, но в реальности оно попадает в функцию, которая решает что с ним делать, вместо него записывает нормализованное значение и сохраняет на сервер.
                            Пример на lom_atom
                            let val = { foo : [777] }
                            let called = 0
                            class A {
                                @mem foo(next?: Object): Object {
                                    called++
                                    if (next === undefined) return undefined
                                    // save to server
                                    return val
                                }
                            }
                            // ...
                            const a = new A()
                            assert(a.foo(), undefined)
                            assert(called, 1)
                            
                            a.foo({foo: [666]})
                            assert(called, 2)
                            assert(a.foo().foo[0], 777)
                            
                            a.foo({foo: [666]})
                            assert(called, 2)
                            assert(a.foo().foo[0], 777)
                            
                            fiddle
                            Вот, что я смог придумать на MobX и он вовсе не оптимизирует второе присвоение с тем же значением, called будет 3, а не 2 в конце. В случае сохранения на сервер, это означает что каждый раз будет вызываться тяжелая логика «save to server» с одним и тем же значением:
                            Пример на mobx
                            let val = { foo : [777] }
                            let called = 0
                            class A {
                                @observable foo: Object
                                constructor() {
                                  intercept(this, 'foo', (change) => {
                                    called++
                                    if (change.newValue) {
                                      // save to server
                                      change.newValue = val
                                    }
                                    return change
                                  })
                            
                                  autorun(() => {
                                    ;this.foo;
                                    called++
                                  })
                                }
                            }
                            // ...
                            const a = new A()
                            assert(a.foo, undefined)
                            assert(called, 1)
                            
                            a.foo = {foo: [666]}
                            assert(called, 2)
                            assert(a.foo.foo[0], 777)
                            
                            a.foo = {foo: [666]}
                            assert(called, 2)
                            assert(a.foo.foo[0], 777)
                            
                            fiddle
                            Я перестал вас понимать.
                            Про реактивность на атомах у Дмитрия есть теория. Проталкивание, вытягивание, двусторонние каналы, вот это вот всё.

                            MobX не реализует эту теорию, он пошел по пути развития хелперов над достаточно простым ядром, которое работает с данными, а не хэндлерами. В его основе нет вышеназванных понятий. Я считаю, что реализовывать эту теорию поверх mobx — это как каша из топора, будет надстройка по-сложности сравнимая с самим mobx. Это не путь mobx. Если у вас есть понимание, как это сделать просто — попробуйте, всем будет интересно.
                              0

                              Зачем делать сохранение на сервер в перехватчике? Для этого вообще-то реакции существуют.


                              class Foo {
                                   @observable data;
                              
                                   constructor() {
                                        reaction(() => this.data, data => {
                                            if (data !== undefined) {
                                                //save to server
                                            }
                                        });
                                   }
                              }

                              Ну или через autorun можно.

                                0
                                Это другой случай. Идея в том, что мы предлагаем значение, а хэндлер уже решает, что с ним делать. В моем примере хэндлер отклонял значение и записывал свое — { foo: [777] }.

                                reaction не позволяет модифицировать записанное значение.
                                Кстати, а такой код утечки памяти не создает? reaction возвращает disposer.
                                  0

                                  Да пожалуйста:


                                  class Foo {
                                      private @observable _suggestedData;
                                      private @observable _realData;
                                  
                                      get data() { return this._realData; }
                                      set data(value) { this._suggestedData = value; }
                                  
                                      constructor() {
                                          reaction(() => this._suggestedData, data => {
                                                if (data !== undefined) {
                                                    //save to server
                                                    this._realData = { foo: [777] };
                                                }
                                            }, { compareStructural: true });
                                      }
                                  }

                                  Нет, утечку такой код не вызовет. Disposer отписывает реакцию от тех observable на которые она подписалась — но если ни на эти observable ни на реакцию не осталось ссылок, то реакция будет и без всяких dispose собрана сборщиком мусора.

                                    0
                                    Почти, но все-равно еще не совсем то.
                                    на lom
                                    let val = { foo : [777] }
                                    let called = 0
                                    class A {
                                        @mem foo(next?: Object): Object {
                                            called++
                                            if (next === undefined) return undefined
                                            return val
                                        }
                                    }
                                    
                                    function assert(a, b) {
                                      if (a !== b) throw new Error('Assert error: ' + a + ' != ' + b)
                                    }
                                    
                                    const a = new A()
                                    assert(a.foo(), undefined)
                                    assert(called, 1)
                                    
                                    a.foo({foo: [666]})
                                    assert(called, 2)
                                    assert(a.foo().foo[0], 777)
                                    
                                    a.foo({foo: [666]})
                                    assert(called, 2)
                                    assert(a.foo().foo[0], 777)
                                    
                                    a.foo({foo: [777]})
                                    assert(called, 2)
                                    assert(a.foo().foo[0], 777)
                                    
                                    fiddle

                                    На MobX:
                                    let val = { foo : [777] }
                                    let called = 1
                                    class A {
                                        @observable _suggestedData;
                                        @observable _realData;
                                        get foo() { return this._realData; }
                                        set foo(value) { this._suggestedData = value; }
                                    
                                        constructor() {
                                            reaction(() => this._suggestedData, data => {
                                                  if (data !== undefined) {
                                                      called++
                                                      //save to server
                                                      this._realData = { foo: [777] };
                                                  }
                                              }, { compareStructural: true });
                                        }
                                    }
                                    // ...
                                    a.foo = {foo: [666]}
                                    assert(called, 2)
                                    assert(a.foo.foo[0], 777)
                                    a.foo = {foo: [777]}
                                    assert(called, 3)
                                    assert(a.foo.foo[0], 777)
                                    
                                    fiddle

                                    assert(called, 3) вызовется 3й раз, если мы засетим значение {foo: [777]}, а по-логике не должен, т.к. это значение выставит хэндлер.

                                    В общем то да, можно на mobx сделать и аналогичную логику. Только вот как это автоматизировать, что б не было шаблонного кода. Мне эта задача не кажется простой.

                                    Идея mobx все же немного другая — это достаточно низкоуровневый конструктор с кучей хелперов, из которых можно гибко выстроить сложную кастомную логику.

                                    Атомы — это больше соглашения, приняв которые, можно с минимумом бойлерплейта решать типовые задачи в веб без привнесения хелперов, без ручного программирования деталей таких вот крайних ситуаций.

                                    Все же сложновато их сравнивать с аналогичной псевдо-оберткой над mobx.
                                      0

                                      Нее, не путайте. Либо мы пропускаем повторную отправку на сервер {foo:[666]} как повторяющегося значения — либо пропускаем отправку {foo:[777]} как текущего значения.


                                      Пропуск обоих значений — грубая ошибка.

                                        –1
                                        Не могли бы вы раскрыть свою мысль? В примере с mobx на сервер вроде как раз пропускаются оба значения.
                                          0

                                          "Пропуск" — "пропуск обработки", а не "пропуск на сервер".

                                      0
                                      но если ни на эти observable ни на реакцию не осталось ссылок

                                      В общем случае вы не можете это гарантировать. Например, если где-то в глубине computed свойств кто-то подпишется на любое глобальное состояние (например, на размер экрана), то такая реакция будет жить вечно и постоянно выполнять бесполезную работу. Поэтому лучше выстраивать все computed-ы приложения (а реакция — тот же computed) в одно большое дерево, которое чётко определяет какие вычисления ещё кому-то нужны, а какие — уже нет


                                      Ваш код можно было бы переписать куда короче:


                                      class Foo {
                                      
                                        @ $mol_mem
                                        data( next : any , force? : $mol_atom_force ) {
                                          const resource = $mol_http.resource( '/data' )
                                          return resource.json( next , force )
                                        }
                                      
                                      }

                                      Кода меньше, а делает больше:


                                      1. Загрузка данных с сервера с кешированием: foo.data()
                                      2. Перезагрузка данных с сервера с обновлением кеша: foo.data( undefined , $mol_atom_force_update )
                                      3. Сохранение данных на сервер с кешированием: foo.data({ foo : [666] })
                                      4. Пересохранение данных на сервер с обновлением кеша: foo.data( { foo : [666] } , $mol_atom_force_update )
                                        0

                                        Конкретно в этом случае — гарантировать это я могу. Потому что обращаюсь только к местным свойствам.


                                        А в вашем коде я не вижу константы 777. Без нее код и на mobx был намного меньше.

                                          0

                                          Константа — это то, что возвращает сервер, а вернуть он может не то, что мы посылали. Так как вы реализуете 4 упомянутых мной сценария?

                                            0

                                            Нее, погодите. В примерах выше она устанавливалась синхронно, а не асинхронно. Она не может быть ответом сервера.

                                              0
                                              1. В случае $mol_atom и lom_atom — может. Я привёл пример полностью рабочего кода.
                                              2. В случае MobX там будет асинхронная установка.
                                                0

                                                Если данные всегда проходят через сервер — тогда все еще проще.

                                                  0

                                                  Настолько проще, что код написать лень?

                                                    0

                                                    Да. Поясняю: исходная задача выглядела как задача с магическим двусторонним потоком данных к свойству, когда надо было перехватывать изменение значения и что-то хитрое было с ним делать.


                                                    Уточненная вами задача сводится к обычному одностороннему потоку данных, который все писать умеют.

                                                      0

                                                      Не сводится, прочитайте по внимательнее все 4 кейса использования. Для каждого из них вам придётся завести отдельный observable.

                                                        +1

                                                        Ну и что? Если задача, требующая всех 4х кейсов, встречается регулярно — можно построить абстракцию.

                                                          0

                                                          Да почти всегда при работе с удалёнными запросами нужны все 4 кейса. Какую абстракцию вы построите?

                                                            0

                                                            Например, класс с 4 методами. По методу на предложенный вами кейс. И интерфейс к нему. И, если понадобится, стандартные обертки над этим интерфейсом.


                                                            Получится нормальный самодокументируемый код. Вместо этой магии с next : any , force? : $mol_atom_force, значение которой без копания в мутных статьях невозможно даже понять.


                                                            На выходе, ежели мне понадобится такая абстракция, будет что-то вроде


                                                            class Foo {
                                                                data: IChannel<Data> = http.resource("/data").asJson
                                                            }
                                                              0

                                                              Чудесно. Зависимые от data сущности вы тоже будете заворачивать в IChannel?

                                                                0

                                                                Как именно зависимые? Да, буду если понадобится.

                              –3
                              Примитивность основы плохо сказывается на целостности экосистемы

                              Наконец-то я нашел хорошо сформулированную причину ущербности JS :)

                    0

                    Кстати, почему у вас сравниваемые виды имеют разную функциональность? Какой смысл в таком сравнении?

                      0
                      Поленился сделать еще один фидл, взял из того, что было в статье, по-сути там разница в одной button.

                      Если уж совсем быть точным, то вот совсем эквивалентный пример на MobX и lazyObservable, с ленивой загрузкой и абстракцией от способа получения данных.

                      Пример на MobX
                      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 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>
                                  : <ErrorableView fetchResult={todos}/>
                              }
                          </div>
                      })
                      
                      fiddle
              0
              кроме fetch, но над ним можно сделать обертку, позволяющую записать его в псевдосинхронном виде

              Лучше именно так и делать, чтобы происходила автоматическая отмена запросов, при уходе на другую страницу. Кроме того она позволила бы делать 1 запрос вместо 2, если на странице расположить два туду-листа.

                0
                Согласен. Я хотел с минимумом оберток, продемонстрировать общие принципы.
                –1
                Я с MobX пока плотно не работает, но трогал. А если сделать что-то вроде side effects подхода? Допустим имеем некое простое observable поле — в упрощении счетчик или просто флаг, а вообще в этом поле хранить состояние/прогресс асинхронного метода допустим (влючае состяние ошибки), чтобы если что допустим его отменить или навинтить тротлинг/denouncing, тогда это наверно массив будет. Изменяя это поле (выполняя action в понятиях MobX, а по аналогии с Redux диспатчим экшен) мы тригаем некий effect, который есть reaction в понятих MobX. Эта реакция (которая может быть асинхронной) в итоге меняет некий стейт, можно если нужно придумать как сделать соответствие меняемого стейта и счетчика который тригает реакцию.
                  +1
                  Если речь идет о экшенах/эффектах, это ближе к ФП, с его плюсами и минусами. MobX же и атомы — это более привычный многим ООП. Что-то проще делается в одном, что-то в другом, но развивать можно оба направления.

                  Условно можно сказать, что тут эффекты внутри самого свойства. По сути, это спецификация, позволяющая с минимальным бойлерплейтом описывать observable-свойства с эффектами и управлять этими эффектами. Накрутить в них тротлинг не проблема, также как и сделать отмену. Вообще отмена автоматически происходит, когда компоненты больше эти данные не выводят.

                  Зачем диспатчить экшен, когда можно просто изменить свойство в объекте, если конечно вам не нужно версионирование и фишки, вроде time travel. Экшены можно накручивать поверх, как например в mobx-state-tree.

                  Лучше накидайте пример на псевдофреймворке, как вы себе это представляете, а я попробую накидать аналогичный, на lom.
                    +1
                    Я просто к тому что MobX тем и хорош что он предсказуем если использовать его как синхронный стор. А вот если в сам стор запихивать асинхронные геттеры, то это будет уже не совсем просто стор, но некий самомодифицируемый и не всегда прдскауемым образом гибрид. Так вот просто вариант когда в самом сторе храним только стейт асинхронного эффекта/реакции/экшены, а сами асинхронные эфекты живут отдельно от стора. Ну то есть мы не делаем например «get users.then()» в сторе, но работаем со стором только синхронным образом, а асинхронщину вешаем как реакции на изменение в упрощении счетчиков/флагов на которые можно реактнуться (а по сути стейтов эффектов асинхронных). То есть то что я называю счетчиком, это по сути редакс экшен, реакцией на который будет асинхронное действие которое в итоге модифицирует стор. То есть в упрощении в сторе делаем массив actions, в который засылаем экшены с payload если требуется, далее вешаем MobX reaction на этот массив, и эта реакция и есть эффект. Это я написал наверно запутанно, но на самом деле это банальная идея.
                      –1
                      Может я что-то не так понял, пример бы многое прояснил. Сложность то никуда не девается, если не в свойствах, то где-то рядом будет накручена. Реакции и перехватчики могут запросто модифицировать стейт как угодно, совершенно непредсказуемым образом с точки зрения внешнего наблюдателя.

                      Зачем городить эффекты, экшены, реакции, выстреливать store.fetchMessage() в componentDidMount, если достаточно просто обратиться к свойству store.message, в котором сработает логика обновления этого свойства.

                      Под объект мы маскируем не только реактивность, но и канал связи. Это сильно связанные задачи, код упрощается за счет автоматизации типовых задач.

                      Вот пример, который асинхронно загружает данные, обрабатывает ошибки и рисует статус загрузки:
                      Пример загрузки на lom №1
                      // ...
                      function fetchMessage() {
                          return new Promise(resolve => {
                              setTimeout(() => resolve('message'), 1500)
                          })
                      }
                      
                      class Store {
                          @force $: Store
                          @mem set message(next: string | Error) {}
                          @mem get message() {
                              fetchMessage()
                                 .then(message => { this.$.message = message })
                                 .catch(error => { this.$.message = error })
                              throw new mem.Wait()
                          }
                      }
                      
                      function HelloView({store}) {
                         return <div>
                              <input
                                  value={store.message}
                                  onInput={({target}) => { store.message = target.value }}
                              />
                              <br/>{store.message}
                          </div>
                      }
                      
                      const store = new Store();
                      
                      ReactDOM.render(<HelloView store={store} />, document.getElementById('mount'));
                      
                      fiddle

                      Кстати, lom_atom не навязывает такой подход, его можно использовать как обычный стор:
                      Пример загрузки на lom без свойств-хэндлеров
                      // ...
                      function fetchMessage() {
                          return new Promise(resolve => {
                              setTimeout(() => resolve('message'), 1500)
                          })
                      }
                      
                      class Store {
                          @mem message: string = ''
                          fetchData() {
                            fetchMessage()
                             .then(message => { this.message = message })
                             .catch(error => { this.message = error })
                            this.message = new mem.Wait()
                          }
                      }
                      
                      function HelloView({store}) {
                         return <div>
                              <input
                                  value={store.message}
                                  onChange={({target}) => { store.message = target.value }}
                              />
                              <br/>{store.message}
                          </div>
                      }
                      
                      const store = new Store();
                      store.fetchData()
                      ReactDOM.render(<HelloView store={store} />, document.getElementById('mount'));
                      
                      fiddle
                  0
                    0
                    А еще помимо
                    pending
                    (я использую
                    loading
                    ) можно хранить переменную состояния loaded. Ну и делать соответствующие проверки в компоненте.
                    Еще, на всякий случай, подчеркну что стоит использовать componentDidMount для сайд эффектов
                      –1
                      Можно много ситуаций привести, где такой подход плохо себя проявит. Это происходит обычно на средних и больших масштабах приложений.

                      Например, нарушение инкапсуляции — данные сегодня были захардкожены, а завтра их решили получать с сервера (список юридической отвественности, например АО, ИП и т.д.). Факт того, что данные асинхронны, протекает в компоненты в виде вызовов fetchSome в componentDidMount и в виде структур данных {status, value, refresh}. Т.к. заранее предугадать это было нельзя, то надо рефачить компонент, который мог использоваться в 10 проектах, соотвественно они будут затронуты со всеми вытекающими.

                      Дублирование кода — у вас одни и те же данные могут быть в разных компонентах использованы, получается в каждом надо вызывать componentDidMount. Иначе нет гарантии, что данные актуальны.

                      Нарушение единственной ответственности (SRP). Основная задача компонента — генерить верстку. Но реактовый компонент в виде класса — это все вместе: стейт, логика и верстка, т.е. у него много отвественностей. В небольших масштабах это удобно — просто и все под рукой. Однако с ростом кодовой базы много проблем возникает в поддержке и кастомизации, как в вышеописанном примере.

                      Работа с данными и сервером — зона ответственности слоя данных, моделей, например. Неважно кто запросил данные, сам факт запроса означает, что данные надо вытянуть с сервера, если они не актуальны. Правильное распределение отвественности позволяет избавиться от дублирования кода за счет автоматизации. Актуализация становится автоматической, код упрощается.

                      В свете такого разделения отвественности, компонент реакта должен быть только тупой. Во всяких Vue, Angular-ах, WebComponents, верстка — отдельная сущность. При проектировании реакта не заглядывали так далеко, он был нужен для быстрой разработки небольших приложений и с этой задачей хорошо справляется, хоть и не соблюдает все эти мудреные принципы.

                      Ну и делать соответствующие проверки в компоненте.
                      Присмотритесь к примерам в статье на lom_atom, там в компонентах проверок нет вовсе, как будто нет асинхронных запросов к серверу. При этом статусы и ошибки обрабатываются.
                      0
                      Только наткнулся на эту статью, и похоже перед «Альтернатива» не хватает одного известного инструмента, Relay:

                      • нет места в приложении, отделённого от компонента, где надо инициировать загрузку (как в примере с ридакс-сагой), описание необходимых данных указываются декларативно только в том компоненте, где они нужны
                      • нет нарушения ленивости загрузки; не надо заботиться, как и когда данные будут загружены
                      • мутации дают простую обработку, когда данные ещё не пришли, обработку ошибок и статусов


                      А вот этого:
                      У такого подхода правда есть и минус — необходимо сперва вытащить из todoList todos и users и только потом с ними работать, иначе будет последовательная загрузка и оптимизации не получится.
                      у Relay нет.

                      Ну и беглый поиск показывает, что и тут тоже не без проблем.
                        0
                        В этой статье я не планировал сравнивать все подходы для работы с состоянием. Мне интересно развивать идею маскировки реактивности под классы, которую использует mobx. Альтернативу его я и описывал: либу в 10кил, которая почти как mobx, только автоматизирует наиболее частые задачи синхронизации данных. Задача атомов узкоспециализированная: работа с реактивностью, кэш и инкапсуляция канала связи.

                        Relay, Apollo — достаточно тяжелые решения. Кроме того, что могут атомы, они дают optimistic updates, проверку целостности по схеме, сервер. Но если эти навороты не нужны, кода будет значительно меньше.

                        Т.к. атомы — легковесная абстракция между fetch и компонентами, то вместо fetch вполне может быть какой-нибудь graphql-js или apollo-client. Тут как раз больше возможностей для масштабирования.

                        На мой взгляд, логично переложить часть функциональности из Relay-подобных решений, в mobx-подобные решения. Общеизвестных библиотек для работы с состоянием все-таки меньше чем фреймворков для рендеринга. Вместо биндингов к реакту и прочим, можно было бы сделать пару биндингов от graphql к этим библиотекам.

                      Only users with full accounts can post comments. Log in, please.