Разработчики 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 это первые попытки нащупать контуры этой области. Если кому-то что-либо известно о подобных подходах в других языках и экосистемах — пишите в комментах, это может быть интересно.

Комментарии (45)


  1. mayorovp
    24.10.2017 14:45

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


    1. redyuf Автор
      24.10.2017 15:00
      +1

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

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

      Да и весь алгоритм в 300 строк получается.


      1. mayorovp
        24.10.2017 15:04

        А что не так с хендлером-то?


        1. redyuf Автор
          24.10.2017 16:01

          Не очень понял вопрос. Что вы считаете хэндлером в 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>
          }
          


          1. mayorovp
            24.10.2017 16:07
            +1

            У вас все еще много бойлерплейта. Я бы предпочел вот такой вариант:

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


            Осталось всего-то реализовать один декоратор, вместо написания новой библиотеки.


            1. redyuf Автор
              24.10.2017 16:21

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

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

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


              1. mayorovp
                24.10.2017 16:30

                Я не нашел реализации lazyFetch, сложно судить.

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


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

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


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

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


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

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


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

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


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

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


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

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


                1. redyuf Автор
                  24.10.2017 18:27

                  Ее в любом случае надо написать, независимо от используемой библиотеки, потому что эти танцы с 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>
                  )
                  


                  1. mayorovp
                    24.10.2017 18:34

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


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

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


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

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


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

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


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

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


                    1. redyuf Автор
                      24.10.2017 21:34

                      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 не было целей делать подобное в ядре, это библиотека просто работает с реактивным стейтом, не учитывая канал связи. Это неизбежно выливается в усложнение вышележащего уровня — хелперов. Усложнение как их реализации, так и их использования в прикладном коде.

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


                      1. mayorovp
                        25.10.2017 07:04

                        Как минимум, не получится сделать асинхронную неблокирующую загрузку, т.к. mobx не перехватывает внутри исключения

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


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

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


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

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


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

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


                        1. redyuf Автор
                          25.10.2017 18:26

                          Кто вам это сказал?
                          В принципе можно через 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)
                          


                          1. mayorovp
                            25.10.2017 18:43

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


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

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


                            1. redyuf Автор
                              25.10.2017 19:31

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

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


                              1. mayorovp
                                25.10.2017 19:59

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


                                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 собрана сборщиком мусора.


                                1. redyuf Автор
                                  25.10.2017 21:46

                                  Почти, но все-равно еще не совсем то.

                                  на 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)
                                  


                                  1. mayorovp
                                    25.10.2017 21:49

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


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


                                    1. redyuf Автор
                                      25.10.2017 23:07

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


                                      1. mayorovp
                                        26.10.2017 06:17

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


                                1. vintage
                                  26.10.2017 01:02

                                  но если ни на эти 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 )


                                  1. mayorovp
                                    26.10.2017 06:16

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


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


                                    1. vintage
                                      26.10.2017 08:45

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


                                      1. mayorovp
                                        26.10.2017 08:48

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


                                        1. vintage
                                          26.10.2017 09:54

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


                                          1. mayorovp
                                            26.10.2017 09:58

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


                                            1. vintage
                                              26.10.2017 11:02

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


                                              1. mayorovp
                                                26.10.2017 11:03

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


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


                                                1. vintage
                                                  26.10.2017 12:18

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


                                                  1. mayorovp
                                                    26.10.2017 12:59
                                                    +1

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


                                                    1. vintage
                                                      26.10.2017 15:10

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


                                                      1. mayorovp
                                                        26.10.2017 15:30

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


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


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


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


                                                        1. vintage
                                                          26.10.2017 19:27

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


                                                          1. mayorovp
                                                            27.10.2017 08:56

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


                      1. Amareis
                        25.10.2017 07:39

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

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


          1. mayorovp
            24.10.2017 16:13

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


            1. redyuf Автор
              24.10.2017 16:46

              Поленился сделать еще один фидл, взял из того, что было в статье, по-сути там разница в одной 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>
              })
              


  1. vintage
    24.10.2017 18:34

    кроме fetch, но над ним можно сделать обертку, позволяющую записать его в псевдосинхронном виде

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


    1. redyuf Автор
      24.10.2017 21:36

      Согласен. Я хотел с минимумом оберток, продемонстрировать общие принципы.


  1. serf
    24.10.2017 20:40

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


    1. redyuf Автор
      24.10.2017 21:57

      Если речь идет о экшенах/эффектах, это ближе к ФП, с его плюсами и минусами. MobX же и атомы — это более привычный многим ООП. Что-то проще делается в одном, что-то в другом, но развивать можно оба направления.

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

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

      Лучше накидайте пример на псевдофреймворке, как вы себе это представляете, а я попробую накидать аналогичный, на lom.


      1. serf
        24.10.2017 23:51

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


        1. redyuf Автор
          25.10.2017 19:56
          -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'));
          


  1. iosiff
    25.10.2017 18:24


  1. artalar
    25.10.2017 18:27

    А еще помимо

    pending
    (я использую
    loading
    ) можно хранить переменную состояния loaded. Ну и делать соответствующие проверки в компоненте.
    Еще, на всякий случай, подчеркну что стоит использовать componentDidMount для сайд эффектов


    1. redyuf Автор
      25.10.2017 18:35
      -1

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

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

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

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

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

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

      Ну и делать соответствующие проверки в компоненте.
      Присмотритесь к примерам в статье на lom_atom, там в компонентах проверок нет вовсе, как будто нет асинхронных запросов к серверу. При этом статусы и ошибки обрабатываются.