IjzerenHein / firestorter

Use Google Firestore in React with zero effort, using MobX 🤘
http://firestorter.com
MIT License
378 stars 50 forks source link

Optimistic rendering pattern #99

Closed RWOverdijk closed 4 years ago

RWOverdijk commented 4 years ago

This is an architectural question.

I'm working on a shopping cart. Adding an item to the cart will call a firebase function to add it to my order for me (because I don't trust the client) which is working fine. The server makes the change, I get the new value because of onSnapshot.

However, when a user presses the "add" button a bunch of times the quantity takes some time to go up (api needs to alter the doc first).

So my first thought is to use optimistic rendering and just assume the API call was a success and everything happened as it should. Then once the api is done processing the API call(s) I update the document/class with the new values (in case something did fail, I can correct what I assumed to be right).

Maybe I'm overcomplicating things and you have a much better solution.... Do you? 😄

PS: Firestorter works really great for my project (once I embraced the way it works and stopped trying to put it in an architecture it shouldn't be in).

RWOverdijk commented 4 years ago
import { Document, Mode } from 'firestorter'
import { computed, observable, onBecomeObserved, onBecomeUnobserved } from 'mobx'
import { ApiService } from '../../services/ApiService'
import { boundMethod } from 'autobind-decorator'
import { findOrder } from './index'
import { currency } from '../../lib/i18nUtils'

export class CustomOrder {
  @observable items = {}

  _queue = [];

  _observing = false;

  _draft;

  _observerListener;

  _unobserveListener;

  constructor (venue) {
    this.init(venue)

    this._observerListener = onBecomeObserved(this, 'items', this.observeOrder)
    this._unobserveListener = onBecomeUnobserved(this, 'items', this.unobserveOrder)
  }

  async init(venue) {
    const orderId = await ApiService.getOrder(venue);
    this._order = new Document(`order/${orderId}`, { mode: Mode.Off })

    if (this._observing) this.observeOrder();
  }

  @computed
  get total () {
    const total = Object.values(this.items).reduce((accumulator, item) => {
      return accumulator + parseInt(item.price, 10)
    }, 0)

    return currency(total);
  }

  @computed
  get empty() {
    return Object.keys(this.items).length === 0
  }

  release () {
    if (typeof this._unobserveListener === 'function') this._unobserveListener();
    if (typeof this._observerListener === 'function') this._observerListener();
  }

  @boundMethod
  observeOrder () {
    this._observing = true;

    if (!this._order) return;

    this._orderObserver = this._order.ref.onSnapshot((snapshot) => {
      this._draft = snapshot.data();

      this.applyDraft()
    })
  }

  @boundMethod
  unobserveOrder () {
    this._observing = false;

    if (this._orderObserver) {
      this._orderObserver();
    }

    this._orderObserver = null
  }

  async updateItems () {
    const order = await this._order.fetch();

    this.items = order.items || {};
  }

  removeQuantity (id, amount = 1) {
    if (this.items[id]) {
      this.items[id].quantity -= amount

      if (this.items[id].quantity <= 0) {
        delete this.items[id]
      }
    }

    this.enqueue(ApiService.removeQuantityFromOrder(this.data.venue, id, amount))
  }

  removeItem (id) {
    delete this.items[id]

    this.enqueue(ApiService.removeItemFromOrder(this.data.venue, id))
  }

  addItem (id, item) {
    if (this.items[id]) {
      this.items[id].quantity++
    } else {
      this.items[id] = { ...item, quantity: 1 }
    }

    this.enqueue(ApiService.addItemToOrder(this.data.venue, id))
  }

  applyDraft() {
    if (this._queue.length === 0 && this._draft) {
      const { items } = this._draft;

      delete this._draft;

      this.items = items || {};
    }
  }

  async enqueue(promise) {
    queue.push(promise)

    try {
      await promise
    } catch (error) {
    } finally {
      this._queue.splice(this._queue.indexOf(promise), 1)

      this.applyDraft()
    }
  }
}

@IjzerenHein I've made something that works for me right now (although it's not perfect).

The idea is that I manage my data locally like I expect my API to do if all went well. Once all queued API calls have finished I update the data with what I get onSnapshot.

I also have listeners to check if the items are being observed or not so I don't listen for changes needlessly (basically what the "auto" mode does in firestorter I guess?)

I have one question that remains, which is how I can get rid of the release() method. I don't know when I can safely assume I'm being destroyed so I can remove the listeners in mobx.

anyway... Thoughts? Suggestions? Do you see a patter we could use to add something like this to firestorter?

omarsourour commented 2 years ago

@RWOverdijk Did you ever find a way to support optimistic updates natively or cleanly using firestorter?

RWOverdijk commented 2 years ago

No. But I also don't use it anymore so maybe someone else has a better answer.