-
Notifications
You must be signed in to change notification settings - Fork 18
Keeping Track of Multiple Components
@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 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.
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.
- The Form base class becomes a FormStore class, which will provide
open_form!
andclosing_form!
methods via a helper module. - 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.
- 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
. - 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.
- 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. - 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
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
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
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.