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.