clearwater-rb / grand_central

State-management and action-dispatching for Ruby apps

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Allow Store to take a stack of middlewares?

ajjahn opened this issue · comments

Inspired by Redux's middleware implementation, I think it would be pretty powerful to allow a Store to take a list of middleware to wrap around the reducer block.

I propose adding the following to the Store initializer:

module GrandCentral
  class Store
    def initialize initial_state, middlewares = [], &reducer
      @state = initial_state
      @reducer = middlewares.inject(reducer) do |stack, middleware|
        middleware.new(stack, self)
      end
      @dispatch_callbacks = []
    end
  end
end

That would allow you to do something like this:

APIRequest = Action.with_attributes :endpoint
APISuccess = Action.with_attributes :data
APIFailure = Action.with_attributes :error

class APIFetcher
  def initialize(reducer, store)
    @reducer = reducer
    @store = store
  end

  def call(state, action)
    fetch(action.endpoint) if APIRequest === action
    @reducer.call(state, action)
  end

  def fetch(url)
    Browser::HTTP.get(url).then { |data|
      @store.dispatch(APISuccess.new(data))
    }.fail { |error|
      @store.dispatch(APIFailure.new(error))
    }
  end
end

class DispatchLogger
  def initialize(reducer, store)
    @reducer = reducer
    @store = store
  end

  def call(state, action)
    puts action.class.name
    @reducer.call(state, action)
  end
end

Store = GrandCentral::Store.new(initial_state, [APIFetcher, DispatchLogger]) do |state, action|
  case action
  when APIRequest
    # Do something here if you want...
  when APISuccess
    state.merge(data: action.data)
  when APIFailure
    state.merge(error: action.error)
  else
    state
  end
end

Thoughts?

Holy crap, I'm sorry this got so long. I didn't mean to write a novel here, I promise.

Redux is indeed what inspired GrandCentral, but I'm not sure if the middleware approach is ideal. It's always confused me, to be honest. :-) I mean, I understand the mechanics of it, but solving the problem of async dispatching with Redux middleware always felt clunky to me because the concept of middleware is to transform the data passed in or return early before making it to the end of the stack. However, it turns out what I usually want is to update the state to say "the things are loading" and treat the HTTP request as a side effect of the dispatch. Using middleware to do that never felt like that was the intention I was expressing.

I'm also not sure that the general API-fetch action type is the abstraction that is needed. It means UI components need to know about the URL, so if you change your API endpoints (say, you upgrade from a v1 to a v2 API), you have to change every component that references that. In Redux's case, this all can happen in your action creators, but in practice (at least for me), action creators weren't always used and that whole action typically went into the component. However, because GrandCentral actions are just objects, the action creators are the action classes. So instead of a generic action to fetch from the API, each type of fetch is its own action class.

What I usually do is chain the promise on the dispatch. GrandCentral::Store#dispatch returns the action dispatched and actions can define a promise method. If you call then, fail, or always on the action, it delegates it to the return value of promise, so this is totally a thing you can do:

Store.dispatch(FetchWidgets.new)
  .then { |response| Store.dispatch(LoadWidgets.new(response.json) }
  .fail { |exception| Store.dispatch(LoadWidgetsError.new(exception)) }

To make something like possible:

WidgetsAction = GrandCentral::Action.create

FetchWidgets = WidgetsAction.create do
  def promise
    Bowser::HTTP.fetch '/api/v1/widgets'
  end
end

LoadWidgets = WidgetsAction.with_attributes :json_array do
  def widgets
    json_array.map { |hash| Widget.new(hash) }
  end
end

LoadWidgetsError = WidgetsAction.with_attributes :error

class AppState < GrandCentral::Model
  attributes(:widgets, :widgets_error)
end

Store = GrandCentral::Store.new(AppState.new(widgets: [])) do |state, action|
  case action
  # We only need to handle FetchWidgets if we want to monitor whether the
  # request is currently in flight.
  when LoadWidgets
    state.update(widgets: action.widgets)
  when LoadWidgetsError
    state.update(widgets_error: action.error)
  else
    state
  end
end

This localizes the knowledge of the fetched data source to the actions themselves. The component need only care about the fact that it's fetching widgets asynchronously. The action itself determines how that's done, so if you change to fetching from IndexedDB instead (note: I don't think there's a Ruby IndexedDB wrapper yet 😃), all changes for that are localized to the action.

Alternatively, you can use an on_dispatch block to fire off things like that if you don't want to do them within the actions:

Store.on_dispatch do |old_state, new_state, action|
  if API::Fetch === action
    Bowser::HTTP.fetch(action.url)
      .then { |response| Store.dispatch API::Success.new(response.json) }
  end
end

I know this all needs to be documented, and I'm working on a docs site for Clearwater and all of the gems that go with it, but I'm honestly just horrible at writing documentation. Like, literally the worst.

I'm not saying middleware is a bad idea for GrandCentral or that I won't consider supporting it. I just don't want to encourage coupling the UI directly to an HTTP API as its primary use case. :-) If there's something else to do within an app that's harder to do without middleware, I'm happy to consider adding it.

I tend to be long winded as well. No big deal.

I totally agree UI shouldn't be coupled to API/Data layer. The APIFetcher example was just intended to demonstrate some middleware possibilities. In reality I wouldn't implement it as naively. Most likely there would be many request actions that would encapsulate their URLs, and any other behavior related to the request. The goal would be to further abstract the details of interacting with the data source.

You're right, the examples I gave could be implemented using the on_dispatch callback. I can think of a couple examples that middleware could do, but on_dispatch callback could not.*

  • Reject a dispatched action
  • Convert an action to another action - perhaps part of your app is still using legacy code
  • Augment an action
  • Forward actions on to another Application/Store

*Not neccessarily all good ideas.

I think dispatch returning an action that delegates to a promise is a nice feature, but to my eyes this still feels a little awkward:

Store.dispatch(FetchWidgets.new)
  .then { |response| Store.dispatch(LoadWidgets.new(response.json) }
  .fail { |exception| Store.dispatch(LoadWidgetsError.new(exception)) }

It seems like it would be pretty easy to end up with similar looking blocks like that sprinkled everywhere throughout the application.

In the end it may come down to personal preference. I can't make any impenetrable arguments as to why GrandCentral absolutely needs middleware support, but I'd welcome it indeed.

P.S. I have pretty much landed on using Clearwater & Grand Central for a decent sized project, so it's possible there will be more questions/suggestions/discussions like this one. I'd also be happy to contribute code or even documentation along the way. Just let me know what you need.

  • Reject a dispatched action
  • Convert an action to another action - perhaps part of your app is still using legacy code
  • Augment an action
  • Forward actions on to another Application/Store

*Not neccessarily all good ideas.

These might not be good ideas all the time but if there's a great use case for them I'd love to make it possible, even if middleware isn't the right way to go about it (and I'm not saying it's not 😄). Essentially, though, I'm trying hard to resist the temptation to put things in there because they might be useful to someone at some point. Everything in the Clearwater ecosystem (well, everything under the clearwater-rb name, anyway) at this point has come directly from a real problem I've solved that I've felt was general enough after mulling it over a lot.

Another hard thing is that I'm frequently tempted to implement something the same way as in contemporary JS, but the Ruby community is a fair bit more rigid on things and people expect things to be done a certain way.

It seems like it would be pretty easy to end up with similar looking blocks like that sprinkled everywhere throughout the application.

That's at least partially intentional. :-) It makes what is happening very explicit for when you're debugging later. Promises can be difficult to debug because they swallow exceptions, so if the promise is hidden away it can be even more difficult. When the promise is explicit, it's easier to realize "oh, right, I may have gotten a nil back from the server for this JSON hash key" or whatever. Not trivial, but definitely easier.

These are all good points, especially:

I'm trying hard to resist the temptation to put things in there because they might be useful to someone at some point.

That's the reason I came to clearwater/grand central in the first place.

I'll go ahead and close this.

I really appreciate the discussion we had here. 👍 Whenever someone else suggests something, it forces me to think about why I do or don't want something. I could totally say "I don't like it", but I don't think that's a good response to someone trying to help me write better software.

Although I've thought through what I feel are a lot of use cases for Clearwater/GrandCentral, I've still only got my own perspective, so I'm happy to get perspectives from others like yourself. Even if in the end I feel I need to say no, it may not be a permanent no (if a no is a mistake, it's much easier to fix than a yes) and I've at least had to consider a viewpoint other than my own.