-
Notifications
You must be signed in to change notification settings - Fork 18
HyperStore
I am thinking a lot about how stores (see flux for stupid people and flux-concepts) should work in Hyperloop.
HyperReact already has a simple powerful extension to React in the export_state
method. However this method is confusing for a number of reasons:
- it only works inside of components. To use
export_state
to build a Flux style store you have to create a component that has a dummy render method which is pretty ugly. - it confuses two different capabilities: Creating a state that is accessible outside of its class and creating a state that is associated with the class rather than an instance of a class.
Instead, I propose that export_state
be deprecated and a new class - HyperStore - be created.
This will give you the whole flux architecture via subclassing/including the HyperStore module/class.
HyperStore will provide just a few simple concepts:
- states which are independent of any component.
- automatic registration of components that depend on the current value of a state.
- a couple of convenience macros.
1 + 2 are already built into HyperReact, but we are going to change the syntax for HyperStore. A point to consider is whether we just build HyperStore into HyperReact, or extract the additional functionality in HyperReact out to a HyperStore gem. The votes seem to go to extracting the function out.
Let's have a look at a simple example using this proposed class. This example was converted from the JS example in this article: React+Flux can do in just 137 lines what jQuery can do in 10
The example creates a component that allows a user to enter a subject and body, and will create a mailto
email link. The use of a "Store" in this case is a bit artificial as other mechanisms are probably more appropriate, but it does make a nice example to compare with the typical "flux" setup.
class Store < HyperStore::Base
# we can read and write Store.subject and Store.body
state_accessor :subject, scope: :class, initial: ''
state_accessor :body, scope: :class, initial: ''
end
class Subject < React::Component::Base
# following the original example, we pass the current
# value for subject and then rely on the component to update
# the store.
param :value
render(:div, class: 'form-group') do
label(html_for: :subject) { 'Subject:' }
input.form_control(
type: :text,
id: :subject,
placeholder: 'Paste your subject line',
value: params.value)
.on(:change) { |evt| Store.subject = evt.target.value }
end
end
class Body < React::Component::Base
param :value
render(:div, class: 'form-group') do
label(html_for: :body) { 'Email Body:' }
textarea.form_control(
id: :body,
style: { height: 300 },
placeholder: "Paste your email body here. HTML is fine, we'll convert it to a text-only format.",
value: params.value)
.on(:change) { |evt| Store.body = evt.target.value }
p.help_block {'This will become the prefilled body.'}
end
end
class Link < React::Component::Base
# The Link component builds an email anchor tag out of the subject and body.
# It is does not interact with the store, and so is a stateless component.
param :subject
param :body
def build_link
subject = `encodeURIComponent(#{params.subject})`
body_with_brs = params.body.gsub /(?:\r\n|\r|\n)/, '<br />'
body = `encodeURIComponent(toMarkdown(#{body_with_brs})`
"mailto:?subject=#{params.subject}+&&body=#{params.body}"
end
render(:p) do
"You're done! Copy this into your email: ".span
a(href: build_link, target: :_blank) {'Email this to a friend'}
end
end
class ShareThisEmail < React::Component::Base
# and finally here is our main component.
render(:div) do
form.col_md_8 do
Subject(value: Store.subject)
Body(value: Store.value)
Link(subject: Store.subject, body: Store.subject)
end
end
end
The HyperLoop version is less than half the lines of the original. This is partly because the HyperReact syntax is just a bit cleaner, but mainly it's because HyperStore takes care of the nitty-gritty of building a dispatcher, adding actions, and registering callbacks. These things all happen, but they happen under the hood.
For example when an instance of ShareThisEmail
reads Store.subject
HyperStore (actually the React::State
module) will register a change listener, so that if Store.subject
changes, the ShareThisEmail
instance will be rerendered. (FYI this was a key concept taken from the great Volt framework.)
Here is another example based on this web article. This time it's a post showing how Streams can be used, so it also makes an interesting comparison between functional style reactive programming and the more traditional object-oriented approach.
class GitHubUserStream < HyperStore::Base
# This store can have many instances.
# Each instance provides a stream of unique github user profiles
# Each instance has a single HyperStore state variable called user
# user will contain a single hash representing the user profile
def reload!
# Reload the state variable.
# Note that the syntax is just like in a React::Component
state.user! GitHubUserStream.select_random_user
end
# extract various attributes from the user hash
def user_name
user[:login]
end
def user_url
user[:html_url]
end
def avatar
user[:avatar_url]
end
def initialize
reload!
end
def self.select_random_user
# select_random_user provides a stream of unique user profiles.
# It will either return a user profile hash, or a promise of one
# to come.
return @users.delete_at(rand(@users.length)) unless @users.blank?
@promise = HTTP.get("https://api.github.com/users?since=#{rand(500)}").then do |response|
@users = response.json
end if @promise.nil? || @promise.resolved?
@promise.then { select_random_user }
end
private_state :user
end
class App < React::Component::Base
before_mount do
@streams = Array.new(5) { GitHubUserStream.new }
end
render(DIV) do
DIV(class: :header) do
H2 { 'Who to follow' }
A(class: :refresh) { 'Refresh' }.on(:click) { @streams.each &:reload! }
end
UL(class: :suggestions) do
# HyperStore includes a loaded? method that is true unless any states
# are currently assigned promises. We use this to hide streams while they
# are loading.
@streams.each { |stream| LI { User(stream: stream) } if stream.loaded? }
end
end
end
class User < React::Component::Base
param :stream, type: GitHubUserStream
render(DIV) do
IMG(src: params.stream.avatar)
A(class: :username, target: :_blank, href: params.stream.user_url) { params.stream.user_name }
A(class: :close, href: '#') { 'x' }.on(:click) { params.stream.reload! }
end
end
Most of this is fairly straight forward. Each instance GitHubUserStream
has a single state variable, and the class provides accessor methods to get data from the class, and one action called reload!
which changes state. By convention we add the bang (!) to action methods.
One thing that keeps our code very simple is how HyperStore will deal with state variables being assigned a promise. When a state variable receives a promise HyperStore will not update the state but instead do three things:
- It marks the Store as "loading" so that the
loaded?
method will return false - When the promise resolves, it will then update the state
- Once all states again have non-promise values,
loaded?
will return true.
Note that each instance and the class has its own loading?
& loaded?
methods.
Internally the loaded?
value of each state can be accessed by adding the _loading?
suffix to the state name. For example state.some_state_loaded?
.
The promise handling mechanism has been tested, and can be added directly to HyperReact as well especially considering that our intention is that Operations always return promises. Because this is a potentially breaking change that depends on fixes in Opal 0.10, we will have HyperStore monkey patch in the change into HyperReact for now.
You can create stores by subclassing HyperStore::Base
, or including HyperStore
. Both are equivalent. The second is useful if the store needs to inherit from some other class.
loading?
: returns true if any states in the store have been assigned a promise that is not yet resolved.
loaded?
: == !loading?
state
: returns the state wrapper class, from which you can access any states. Like instance variables states are created as they are accessed.
Any state can be suffixed with _loading?
or _loaded?
to determine that specific states loading status.
state_accessor, state_reader, state_writer
: These are analogous to Ruby's attr_accessor, attr_reader
, and attr_writer
methods. They just provide a short hand way of creating methods to read and write states.
state_accessor :foo
# equivalent to
def foo
state.foo
end
def foo=(x)
state.foo! x
end
These methods have an optional scope:
key to indicate that the accessor should be a class method rather than an instance method.
state_accessor :foo, scope: :class # default value is :instance
# scope: :class is equivelent to rails cattr_accessor method.
# foo will provide accessors to a class state variable, i.e.
def self.foo
state.foo
end
You can also use the :initial
key to provide an initial value to the state.
state_accessor :foo, initial: 12
# or shorter still
state_accessor foo: 12
If you want to use the initializer, or simply for documentation purposes predeclare a state that does not have a public accessor you can use the private_state
method:
private_state :foo, scope: :class, initial: 12
Finally
loaded?
and loading?
are also available as class methods that depend on any class level state variable.
Here are the 4 key parts of flux (from the facebook flux documentation)
Data from stores is displayed in views. When a view uses data from a store it must also subscribe to change events from that store. Then when the store emits a change the view can get the new data and re-render.
In HyperStore you "hide" state data inside the store, and provide accessor methods to read and massage the data as needed. Subscription is automatic and occurs when a component causes a store to access a state.
Actions define the internal API of your application. They capture the ways in which anything might interact with your application. They are simple objects that have a "type" field and some data.
In HyperStore there is no special action concept. Rather actions are simply methods on Stores that will update the state of that store. When such methods are called, and the state is updated this will trigger any components observing (subscribed to in flux terms) that state to be rerendered. To help keep things understandable the convention is to add the bang (!) to name of action methods.
A store is what holds the data of an application. Stores will register with the application's dispatcher so that they can receive actions. The data in a store must only be mutated by responding to an action. There should not be any public setters on a store, only getters. Stores decide what actions they want to respond to. Every time a store's data changes it must emit a "change" event. There should be many stores in each application.
A HyperStore is a class that either inherits from HyperStore::Base
or includes HyperStore
. A HyperStore class is going to have at least one state variable, which is just like an instance variable except when it changes any observers (flux subscribe), will be notified. It is up to the developer to build the API to the store class such that actions result in updating internal state and any accessor methods return values based on that state.
The dispatcher receives actions and dispatches them to stores that have registered with the dispatcher. Every store will receive every action. There should be only one singleton dispatcher in each application.
In HyperStore actions are directly called as normal methods, and subscription is automatically managed. This means the role of the dispatcher is moved downstream between the store and the components (the views.)
In Flux the picture looks like this:
action -> dispatcher -> store -> view
^ |
|---<<< action <<<---|
In HyperStore the picture is like this:
action -> store -> dispatcher -> view
^ |
|---<<< action <<<---|
Where we understand that 'actions' are simply methods on stores and not separate from them.
Simply put: in Flux the dispatcher dispatches actions to stores, but in HyperStore the dispatcher dispatches state and state changes to components. HyperStore keeps track of what states have been accessed during rendering of the current component and will then notify (via the normal React.js state mechanism) when any of those states change.
Here is key quote from the facebook flux overview:
This structure allows us to reason easily about our application in a way that is reminiscent of functional reactive programming, or more specifically data-flow programming or flow-based programming, where data flows through the application in a single direction — there are no two-way bindings. Application state is maintained only in the stores, allowing the different parts of the application to remain highly decoupled. Where dependencies do occur between stores, they are kept in a strict hierarchy, with synchronous updates managed by the dispatcher.
We found that two-way data bindings led to cascading updates, where changing one object led to another object changing, which could also trigger more updates. As applications grew, these cascading updates made it very difficult to predict what would change as the result of one user interaction. When updates can only change data within a single round, the system as a whole becomes more predictable.
Right! Agreed and moving the dispatcher one step to left does not alter the above quote a bit!
Let's look again at our sample components that are using the streams:
class App < React::Component::Base
before_mount do
@streams = Array.new(5) { GitHubUserStream.new }
end
render(DIV) do
DIV(class: :header) do
H2 { 'Who to follow' }
A(class: :refresh) { 'Refresh' }.on(:click) { @streams.each &:reload! }
end
UL(class: :suggestions) do
@streams.each { |stream| LI { User(stream: stream) } if stream.loaded? }
end
end
end
class User < React::Component::Base
param :stream, type: GitHubUserStream
render(DIV) do
IMG(src: params.stream.avatar)
A(class: :username, target: :_blank, href: params.stream.user_url) { params.stream.user_name }
A(class: :close, href: '#') { 'x' }.on(:click) { params.stream.reload! }
end
end
Here is the basic reason we need a Store: Both the User
and App
components act on (reload!
) the streams. Consider if we did not have the requirement for App
to reload all the streams. In this case, we could let each User
component have its own local user object state, and manage its own stream creation and reloading. Each stream would simply deliver a stream of "dumb" objects containing the url, name, and avatar. The on_click action would simply get a new user object, and update its state.
But as soon as we need something outside of User
to act on the streams, we have to somehow give access the User
's internal state. This is technically possible, but now we are getting view logic mixed up with operational logic.
So the construction of the stream gets moved upwards, and the resulting user is passed down to the child as "dumb" object. Now the parent owns the state variables, and can easily reload them. But how does child now perform its own local reload? This is where the "two-way" binding mentioned above comes into play. For example, we could pass to User a call back method, which the User will invoke when it wants to reload. In fact, the HyperReact DSL makes this very easy to do and in limited cases, this might make sense, but as the Facebook quote says these two-way bindings can quickly make the system hard to understand.
So what the Store does for us is move the ownership of reactive states out of any particular component, and centralize it. Components focus on displaying data, and the logic for responding to actions on data gets moved into Store classes. Everybody is happy!
ReactStore is almost exactly like Mobx, and like Mobx you can use ReactStores to implement a Redux like structure but you are not forced into that pattern.
Check out this page to see how to combine Operations with Stores