Pull to refresh

Comments 47

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

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

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

Да и весь алгоритм в 300 строк получается.
А что не так с хендлером-то?
Не очень понял вопрос. Что вы считаете хэндлером в 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
У вас все еще много бойлерплейта. Я бы предпочел вот такой вариант:

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


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

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

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

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


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

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


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

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


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

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


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

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


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

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


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

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

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

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


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

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


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

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


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

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


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

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

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

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

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


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

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


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

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


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

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

Кто вам это сказал?
В принципе можно через 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. Если у вас есть понимание, как это сделать просто — попробуйте, всем будет интересно.

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


class Foo {
     @observable data;

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

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

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

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

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


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

Почти, но все-равно еще не совсем то.
на 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.

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


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

Не могли бы вы раскрыть свою мысль? В примере с mobx на сервер вроде как раз пропускаются оба значения.
но если ни на эти 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 )

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


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

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

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

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

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

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

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


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

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

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

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

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


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


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


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

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

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

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

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

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

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

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

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

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

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

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

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

Всегда думал, что упростить код можно, сократив его количество. В вашем случае упрощение выглядит как добавление новых декораторов и еще какой-то своей логики. Глядя на код совсем не очевидно как все работает. Почему нельзя загрузить данные в стор независимо от компонентов и брать эти данные в тех местах где они нужны? С чем связано это загадочное правило что компонент не должен знать откуда берутся данные и вообще он должен быть тупым по определению? Раз уж тупой, то и любая логика внутри него не должна существовать.
А еще помимо
pending
(я использую
loading
) можно хранить переменную состояния loaded. Ну и делать соответствующие проверки в компоненте.
Еще, на всякий случай, подчеркну что стоит использовать componentDidMount для сайд эффектов
Можно много ситуаций привести, где такой подход плохо себя проявит. Это происходит обычно на средних и больших масштабах приложений.

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

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

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

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

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

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

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


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

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

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

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

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