unabridged / motion

Reactive frontend UI components for Rails in pure Ruby
https://github.com/unabridged/motion
MIT License
697 stars 19 forks source link

More ergonomic callbacks (`bind`) #32

Closed alecdotninja closed 4 years ago

alecdotninja commented 4 years ago

This PR comes out of a discussion in #22. ~I'm leaving it draft for now because I am not sure this is actually something that we want to do.~

Currently, the only way for a child component to communicate with a parent is with broadcasts. This works well when the purpose of communication is to signal that an external resource (like a model) has been updated, but it gets a bit awkward when the goal is to communicate something specific about the UI state of the page (like a form being "finished"). In the latter case, you need to generate and pass around a unique topic name for the broadcasts to ensure any changes are scoped to that user's view.

This introduces a new API ("callbacks" for now) that tries to make this a little less cumbersome.

Parent components can expose a method as a callback to their children using bind (which for ergonomics can be called both in the component class and the template):

class CounterComponent < ViewComponent::Base
  include Motion::Component

  attr_reader :count

  def initialize(count: 0)
    @count = count
  end

  def increment
    @count += 1
  end
end
<div>
  <h1>The count is <%= count %>!</h1>

  <%= render ButtonComponent.new(text: "+", on_click: bind(:increment)) %>
</div>

Child components can receive the callback created by bind just like any other state, and trigger it using the call method:

class ButtonComponent < ViewComponent::Base
  include Motion::Component

  attr_reader :text, :on_click

  def initialize(text:, on_click:)
    @text = text
    @on_click = on_click
  end

  map_motion :click

  def click
    on_click.call
  end
end
<button data-motion="click"><%= text %></button>

This short example is a bit contrived (Why not eliminate ButtonComponent completely or make it presentational and use a motion directly on CounterComponent?), but I think this is a reasonable feature to want in general.

That said, I have a few concerns:

My intention is that this PR can serve as a place for a public discussion about whether adding this feature to Motion is a good idea.

latortuga commented 4 years ago

This does enable some good use cases, allowing children to call up to parental behavior without having to know about the internals of the parent. I like what it enables. If we are going to allow having nested components a la React, then we need to support handling events up the tree from where they occur.

This just made me think of something. Could a motion that is mapped on a parent (e.g. map_motion :increment) but triggered on a child component (e.g. <button data-motion="increment">) be handled by the parent component? Presumably event bubbling would allow it to occur but is there anything preventing it from working?

alecdotninja commented 4 years ago

Could a motion that is mapped on a parent [...] be handled by the parent component?

No, if the child component is also a Motion component, it will receive the motion from the button and the parent will not.

Presumably event bubbling would allow it to occur but is there anything preventing it from working?

All the usual DOM bubbling of the event applies, but there isn't anything like that for motions. For example, a parent component could put data-motion="click->increment" on a div wrapping the child component and find out about the click event on the nested button that way, but there is no way for it to observe that the increment motion is being triggered on the child.

The event bubbling makes sense to me because clicking a button in a div is necessarily also clicking the div. I'm not sure a similar thing makes sense for motions though ("incrementing" a nested component isn't necessarily the same thing as "incrementing" the parent, for example).