Skip to content
This repository has been archived by the owner on Oct 19, 2018. It is now read-only.

Keeping Track of Multiple Components

Mitch VanDuyn edited this page Jan 18, 2017 · 8 revisions

@catmando

On the gitter.im chat I was recently asked how the following situation might be handled:

The application has several different "form" areas, that can be "opened", "edited" and then "closed." After closing the data is saved into a Store. (HyperMesh is not an option for this application for various reasons.)

Here is the problem: In addition there is "save all" button that "closes" any open forms, and persists any changes via an API. How can this "save all" button achieve this?

First Cut Solution

First we define a class that all of our Form's will inherit from. This class provide the capability of closing all the forms. It will simply keep track of each mounted component, and then provide a class method which will call a "close" method on each component. The subclass can define the close method in any way it needs:

class Form < React::Component::Base
  class << self
    attr_accessor :components
    def close_all
      components.each(&:close) if components
    end
  end
  before_mount do
    Form.components ||= []
    Form.components << self
  end
  before_unmount do
    Form.components.delete(self)
  end
end

Now that we have this component defined here is how we can use it:

class AForm < Form
  param :name
  def close
    if state.opened!(false) # keep in mind bang methods return the current state
      state.message! "I am closing..."
      after(2) { state.message! nil }
    end
  end
  render(DIV) do
    "I am #{params.name} ".span
    if state.opened
      BUTTON { 'close me' }.on(:click) { close }
    else
      BUTTON { 'open me' }.on(:click) { state.opened! true }
    end
    state.message.span if state.message
  end
end

class App < React::Component::Base
  render(DIV) do
    AForm(name: 'form 1')
    AForm(name: 'form 2')
    BUTTON { 'Close All' }.on(:click) { Form.close_all }
  end
end

This pattern might be common enough to incorporate it directly into HyperReact. See issue https://github.com/ruby-hyperloop/hyper-react/issues/159 for a similar idea.

Hiding the Close All Button

I was thinking about this and wondered how to make it so that the 'Close All' button on the App would only show if there was at least one form open. The problem is now there is two-way communication going on, and one solution to this is to get things into a flux store.

It's actually not too hard.

  1. The Form base class becomes a FormStore class, which will provide open_form! and closing_form! methods via a helper module.
  2. Instead of registering/deregistering the form on mount/unmount we are going to register when the form is "opened" and "closing" using the helper methods provided by the FormStore class.
  3. In order for the outer App component to detect changes in state we are going to have to move the class instance variable, @components to a class state variable which will rename _open_forms.
  4. Then we can add a class method form_open? that depends on this state and so will cause updates to any components using that method.
  5. While we are at it also going to refactor the states in AForm so that we have one state that goes between :open, :closing, and :closed.
  6. Although our App never unmounts components its good practice to make sure we clean up after ourselves, so we will call closing_form! in the unmount callback.
class FormStore
  include React::Component
  module Helpers
    # include in any object
    def open_form!
      FormStore._open_forms! << self
    end

    def closing_form!
      FormStore._open_forms!.delete(self)
    end
  end

  class << self
    def close_all!
      _open_forms.dup.each { |form| form.close! }
    end

    def form_open?
      _open_forms.any?
    end
  end

  export_state _open_forms: [] # private
end

class AForm < React::Component::Base

  param :name

  include FormStore::Helpers

  define_state my_state: :closed

  def close!
    state.my_state! :closing
    closing_form!
    after(2) do
      state.my_state! :closed
    end
  end

  def open!
    state.my_state! :opened
    open_form!
  end

  before_unmount do
    closing_form!
  end

  render(DIV) do
    "I am #{params.name} ".span
    if state.my_state == :opened
      BUTTON { 'close me' }.on(:click) { close! }
    elsif state.my_state == :closed
      BUTTON { 'open me' }.on(:click) { open! }
    else
      SPAN { 'closing...' }
    end
  end
end

class App < React::Component::Base
  render(DIV) do
    AForm(name: 'form 1')
    AForm(name: 'form 2')
    BUTTON { 'Close All' }.on(:click) { FormStore.close_all! } if FormStore.form_open?
  end
end

HyperStore

We will soon introduce HyperStore to the Hyperloop library which will clean up the syntax of Stores, but FormStore's protocol will not have to change, and the internal structure will be essentially the same.

class FormStore < HyperStore
 private_state open_forms: [], scope: :class
  module Helpers
    def open_form!
      state.open_forms! << self
    end
    def closing_form!
      state.open_forms!.delete self
    end
  end
  class << self
    def close_all!
      state.open_forms.dup.each { |form| form.close! }
    end
  end
end

HyperEvent

Its pretty tidy, but there is still a lot of boiler plate in FormStore. I am thinking what we need is something like "MicroEvents.js". Let's assume we have a HyperEvent class that gives us lightweight event style communication.

class CloseAll < HyperEvent
end

class AForm < React::Component::Base

  include HyperEvent::Helpers

  param :name

  def open
    listen_for CloseAll # by default send the event to a method named close_all
    state.my_state! :open
  end

  def close_all
    state.my_state! :closing
    stop_listening
    after(2).then do  # NOTE: we are going to return a promise!
      state.my_state = :closed
    end
  end

  define_state my_state: :closed

  render(DIV) do
    "I am #{params.name} ".span
    if state.my_state == :open
      BUTTON { 'close me' }.on(:click) { close_all }
    elsif state.my_state == :closed
      BUTTON { 'open me' }.on(:click) { open }
    else
      SPAN { 'closing ... '}
    end
  end
end

class App < React::Component::Base

  render(DIV) do
    AForm(name: 'form 1')
    AForm(name: 'form 2')
    BUTTON { 'Close All' }.on(:click) { CloseAll() } if CloseAll.listening?
  end
end

Wow, that is better! Our two components remain almost exactly the same but we completely do away with the Form class, and replace with an Event class, that takes care of all the boilerplate!

But wait there is more! Notice that we returned a promise for the timer. This will let the CloseAll event know when the close operation has completed, allowing us to improve our UI with just one line of code:

class App < React::Component::Base

  render(DIV) do
    AForm(name: 'form 1')
    AForm(name: 'form 2')
    if CloseAll.transmitting?
      DIV { "closing..." }
    elsif CloseAll.listening?
      BUTTON { 'Close All' }.on(:click) { CloseAll() }
    end
  end
end

HyperStore revisited

While the Event based solutions looks really nice, I wonder if it's generally applicable. If we go back to the HyperStore example we can see it's not a pure flux loop. Part of the state is in the Form components, and part is back in the HyperStore class. This confuses things and makes it difficult if we want to add the ability to "know" if the form is closing.

Lets move all the state into the HyperStore and see what we get:

class FormStore < HyperStore

  state_accessor form_state: :closed
  private_state forms: [], scope: :class

  def open_form!
    state.form_state! :open
    state.forms! << self
  end

  def close_form!
    state.form_state! :closing
    after(2) { state.forms!.delete self }
  end

  class << self
    def close_all!
      state.forms.each { |form| form.close_form! if form.state.form_state == :open }
    end
    def open?
      state.forms.detect { |form| form.state.form_state == :open }
    end
    def closing?
      state.forms.detect { |form| form.state.form_state == :closing }
    end
  end

end

class AForm < React::Component::Base

  param :name

  before_mount { @store = FormStore.new }

  before_unmount { @store.close_form! }

  render(DIV) do
    "I am #{params.name} ".span
    case @store.form_state
    when :opened
      BUTTON { 'close me' }.on(:click) { @store.close_form! }
    when :closed
      BUTTON { 'open me' }.on(:click) { @store.open_form! }
    else
      SPAN { 'closing...' }
    end
  end
end

class App < React::Component::Base
  render(DIV) do
    AForm(name: 'form 1')
    AForm(name: 'form 2')
    if FormStore.closing?
      DIV { 'closing...' }
    elsif FormState.open?
      BUTTON { 'Close All' }.on(:click) { FormStore.close_all! }
    end
  end
end

While this is a bit more code than the Event version, the implementation is explicit, and in a real example there would probably other activities attached to the Store.

Finally have a look at what the App looks like if rewritten using the Action class here.

Clone this wiki locally