clearwater-rb / grand_central

State-management and action-dispatching for Ruby apps
23 stars 3 forks source link

Add Elm-style action dispatching #12

Closed jgaskins closed 7 years ago

jgaskins commented 7 years ago

Because three PRs open with different ideas to remove knowledge of a store from other things like Clearwater components (#2, #6, and #7) isn't enough, here's a fourth! This PR makes actions dispatchable by sending call to the class itself. Consider the following action:

AddTodo = GrandCentral::Action.with_attributes(:name)
AddTodo.store = my_store

Then these two lines become equivalent:

AddTodo.call 'test'
my_store.dispatch AddTodo.new('test')

In a Clearwater app, this gets really powerful:

TodoList = Struct.new(:todos, :new_todo_text) do
  include Clearwater::Component

  def render
    form({ onsubmit: AddTodo[new_todo_text] }, [
      input(value: new_todo_text, oninput: SetText),
      # ...
    ])
  end
end

In this component, the action classes themselves handle their own dispatching. Notice we don't need to explicitly prevent the form submission or get the text input value from the input event. Both of these are handled for us. That's a little magic, but it does it in a way that keeps grand_central from depending on anything outside of itself. It's merely an optimization when it is used in this context.

The idea is that you're probably using a single store for all actions you define for your app anyway, so this optimizes that particular setup. If you aren't using that setup, you'll just need to keep doing what you've been doing all along.

"But what about that AddTodo[new_todo_text]?", you may be asking. That, my friend, is a partially applied invocation (but in a more OO way — don't judge me). It means that if your action takes some arguments to its initializer that are known ahead of time and others that aren't known until you're ready to dispatch the action, you can specify the ones you do know up front. Since we already know the text of the new todo, we can go ahead and apply that. This is great for the Clearwater use case because the action may be based on something passed into the component, but may also need information from a UI event.

jgaskins commented 7 years ago

@ajjahn You've weighed in on a couple of the other dispatching-from-component PRs, was wondering if you might have some feedback here, as well.

jgaskins commented 7 years ago

Also, that MyActionClass.store = my_store propagates to all subclasses, as well, so you can define a subclass of GrandCentral::Model from which all your other actions will derive and they will all automatically use the store set by that class.

ajjahn commented 7 years ago

This is interesting. I believe out of #2, #6, and #7, I like this the best. I do, however, like how #7 allows you to make objects able to pretty much stand in place of a store object.

While toying with #7, I have been including Store.dispatcher into a few of my actions and adding a call method to dispatch them. They look something like this:

Clicked = GrandCentral::Action.create do
  include MyStore.dispatcher

  def call
    dispatch(self)
  end
end

class MyComponent
  include Clearwater::Component

  def render
    div({ onclick: Clicked.new }, "Click Me")
  end
end

It tidies things up, but doesn't help when you need to operate on the event. This solves that problem; I definitely like the form the component takes in your example.

I wonder if there is a reasonable way to provide the event processing in the Dispatcher's call method. There will be unique scenarios requiring more flexibility there.

One option is to provide a custom Dispatcher class:

class DropDispatcher < Dispatcher
  def call(event)
    event.prevent.stop_propagation
    super(event.files)
  end
end

Dropped = GrandCentral::Action.create do
  store MyStore
  dispatcher DropDispatcher
end

I don't love that. Another option would be to give the behavior in a block:

class Dispatcher
  def call *args
    args = @action_class.dispatch.call(*args) if @action_class.dispatch
    @store.dispatch @action_class.new(*@args, *args)
  end
end

Dropped = GrandCentral::Action.create do
  store MyStore

  dispatch do |event|
    event.prevent.stop_propagation
    event.files
  end
end
ajjahn commented 7 years ago

Actually, strike that last part. After a closer look, I suppose just allowing most raw events as an argument to the action wouldn't be much different than providing it in a custom class or block. Anything beyond the event types (input, submit) you've got in the call method can just be handled in a method in the action.

jgaskins commented 7 years ago

Yeah, I really like a lot of the stuff in #7 and I've been using it a lot in my components. I like how you used it on the actions themselves to come up with something not too far from this PR. We get around the problem of "how do we let components know about dispatching to the store without giving them access to the state?" by not letting them know the store exists in the first place.

This PR is mostly a proof of concept after playing around with Elm (view functions in Elm don't know about update, just the messages themselves), but I really like it so far. Another thing I really like about it is that we can use a dispatch callback like the following:

# Side effects
store.on_dispatch do |old, new, action|
  case action
  when FetchProducts
    action.promise
      .then(&:json) # Get JSONified response body
      .then(&LoadProducts)
      .catch(&FetchProductsError)
  end
end

I've been experimenting with this pattern to consolidate side effects into a single place for actions that have them and it simplifies things a bit. This doesn't work in MRI unless the action class responds to to_proc, but it does work in Opal. Even if the Opal team fixes this hack, we can still implement to_proc with a single line.

jgaskins commented 7 years ago

I've had enough time to try this for real and I think it works really well, but there are still some areas where this is lacking. For example, it only supports submit and input events. This only works well for form submissions and text / range (and similar) inputs. Radio buttons, checkboxes, select boxes, and other similar things (which trigger change events and have different properties to use) must still be used the old-fashioned way.

Granted, none of this is technically GrandCentral's responsibility to know about since it's UI-related and GrandCentral is about state (and none of it matters when it runs server-side anyway), but I think this has the potential to save a lot of complexity in front-end apps and I want to provide enough support so that people aren't falling back to that complexity in so many cases.

jgaskins commented 7 years ago

Merged and version 0.4.0 released

ajjahn commented 7 years ago

Excellent.