Hyperloop Stores are implemented in the HyperStore Gem.
Stores are where the state of your Application lives. Anything but a completely static web page will have dynamic states that change because of user inputs, the passage of time, or other external events.
class UserStore < Hyperloop::Store
state :current, scope: :class, reader: true
def self.set_current! user
mutate.current user
end
end
# to access the store
UserStore.set_current! user
UserStore.current_user
Stores are Ruby classes that keep the dynamic parts of the state in special state variables
Hyperloop::Store::Mixin
can be mixed in to any class to turn it into a Flux Store.- You can also create Stores by subclassing
Hyperloop::Store
. - Stores are built out of reactive state variables.
- Components that read a Store's state will automatically update when the state changes.
- All of your shared reactive state should be Stores - The Store is the Truth!
- Stores can receive dispatches from Operations
A Store will have one or more Reactive State Variables or State for short. States are read using the state
method, and are changed using the mutate
method.
state.items
reads the current value of the state named items
. Hyperloop tracks all reads of state, and mutating those states will trigger a re-render of any Components depending on the current value.
mutate.items
returns the current value of the state named items
, but also tells Hyperloop that the value is changing, and that any Components depending on the current value will have to be re-rendered.
The one thing you must remember to do is use mutate
if you intend to update the internal value of a state. For example if the state contains a hash, and you are updating the Hash's internal value you would use mutate
otherwise the change will go unrecorded.
To assign a new value to a state use the mutate
method and pass a parameter to the state:
mutate.items(Hash.new { |h, k| h[k] = 0 })
To read the current value of a state use the state
method:
state.items # returns current value of items
Typically a store will have quite a few reader (aka getter) methods that hide the details of the state, allowing the Store's implementation to change, without effecting the interface.
Often states hold data structures like arrays, hashes, sets, or other Ruby classes, which may be mutated. For example when you push a new value onto an array you will mutate it. The value of the array does not change, but its contents does. If you are accessing a state with the intent to change its content then use the mutate
method:
mutate.items[item] = value
Stores are often singleton classes. In an application there is one 'cart' for example.
However sometimes you will want to create a class where each instance is a Store. This is straight forward because if a state is read or mutated in an instance method, then you will be referring to that instance's copy of the state.
# Each UserStream provides a stream of unique user profiles.
# Each instance has a single HyperStore state variable called user
# user will contain a single hash representing the user profile.
class UserStream < Hyperloop::Store
# get another user
def get_another!
mutate.user UserStream._select_random_user
end
# extract various attributes from the user hash
def user_name
state.user[:login]
end
def user_url
state.user[:html_url]
end
def avatar
state.user[:avatar_url]
end
def initialize
get_another!
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.
# The cache of users to choose from does not have to be an state
# variable, so we use plain instance variables.
return @users.delete_at(rand(@users.length)) unless @users.blank?
# execute the GetMoreUsers Operation to grab another batch of users
# if we are not already waiting on a promise
@promise = GetMoreUsers.then do |response|
@users = response.json
end if @promise.nil? || @promise.resolved?
# wait for the promise to resolve then try again
@promise.then { _select_random_user }
end
end
class GetMoreUsers < HyperOperation
def execute
HTTP.get("https://api.github.com/users?since=#{rand(500)}")
end
end
Stores that have multiple instances will typically have instance methods that directly mutate the store. We recommend you end these methods with an exclamation (!) to make it clear you are exposing a mutator.
The above example is greatly simplified because if a promise is assigned to a state it will not mutate until the promise resolves. Combining this with instance Stores gives a powerful way to encapsulate system behavior.
States like instance variables are created when they are first referenced.
As a convenience you may also explicitly declare states. This reduces code noise, and improves readability.
class Cart < Hyperloop::Store
state items: Hash.new { |h, k| h[k] = 0 }, scope: :class, reader: true
end
This declares the items
state as a class state variable, will initialize it with the hash on Hyperloop::Boot
, and provides a reader method.
That is 6 lines of code for the price of 1, plus now the intention of items
is clearly defined.
The state
declaration has the following flavors, depending on how the state is to be initialized:
state :items, ... other options ... # items will be initialized to nil
state items: [1, 2, 3], ... other options ... # items will be initialized to the array [1, 2, 3]
state :items, ... other options ... do
... compute initial value ...
... context will be either the class an ...
... instance depending on the scope ...
end
Other options to the state
declaration are:
scope:
either:class
,:instance
,:shared
. Details below!reader:
eithertrue
, or a symbol used to declare a reader (getter) method.initializer:
either a Proc or a Symbol (indicating a method), to be used to initialize the state.
The value of the scope
option determines where the state resides.
- A class state has one instance per class and is directly accessible in class methods, and indirectly in instances using
self.class.state
. - An instance state has a different copy in each instance of the class, and is not accessible by class methods.
- A shared state is like a class state, but is also directly accessible in instances.
The default value for scope:
depends on where the state is declared:
state :items # declares an instance state variable, each instance gets its own state
class << self
state :items # declares a class instance state variable
end
In the above example there is one class instance state named items
and an additional state variable also called
items for each instance.
The shared
option just makes it easier to access a class state from instances.
class MyStore < Hyperloop::Store
state :shared_state, scope: :shared
state :class_state, scope: :class
state :instance_state # scope: :instance is default here
def instance_method
# shared state makes class states easy to access
state.shared_state
# without shared state class_state is still accessible
# with more typing
self.class.class_state
# each instance gets its own copy of instance states
state.instance_state
# attempt to access a declared state variable out of context
# results in an error!
state.class_state # exception!
end
def self.class_method
# this is the same state as was referenced in instance_method
state.shared_state
# and so is this
state.class_state
# and this will raise an exception
state.instance_state
end
Class state variables are initialized by an implicit Hyperloop::Application::Boot
receiver. If an initial value is directly provided (not via a proc, method or block) then the value will be dup
ed when the second and following Boot dispatches are received. The proc, method or block initializers will run in the context of the class, and the state variable will be available. For example:
state :boot_counter, scope: :shared do
(state.boot_counter || 0)+1
end
# more practically perhaps:
state :my_state, scope: :shared do
state.my_state || [] # don't re-initialize me on reboots
end
Instance variables are initialized when instances of the Store are created. Each initialization will dup
the initial value unless supplied by a proc, method or block.
This initialization behavior will work in most cases but for more control simply leave off any initializer, and write your own.
Note for class states there is a subtle difference between saying:
state my_state: nil, scope: :shared # or :class
# and
state :my_state, scope: :shared # or :class
In the first case my_state
will be re-initialized to nil on every boot, in the second case it will not.
Stores can receive Operation dispatches using the receive method.
Here is a simple shopping cart Store that receives Add, Remove and Empty Operations:
class Cart < Hyperloop::Store
# First we will define the two Operations.
# Because these are closely associated with the Cart
# we will name space them inside the cart.
class Add < HyperOperation
param :item
param :qty, type: Integer, min: 1
end
class Remove < HyperOperation
param :item
param :qty, type: Integer, nils: true, min: 1
end
class Empty < HyperOperation
end
# The cart's state is represented as a hash, items are the keys, qty is the value
# initialize the hash by receiving the system Hyperloop::Application::Boot or Empty dispatches
receives Hyperloop::Application::Boot, Empty do
mutate.items(Hash.new { |h, k| h[k] = 0 })
end
# The stores getter (or reader) method
def self.items
state.items
end
def self.empty?
state.items.empty?
end
receives Add do
# notice we use mutate.items since we are modifying the hash
mutate.items[params.item] += params.qty
end
receives Remove do
mutate.items[params.item] -= params.qty
# remove any items with zero qty from the cart
mutate.items.delete(params.item) if state.items[params.item] < 1
end
end
This example demonstrates the two ingredients of a Store:
- Receiving Operation Dispatches and
- Reading, and Mutating states.
These are explained in detail below.
The receive
method takes an list of Operations, and either a symbol (indicating a class method to call), a proc, or a block.
When the dispatch is received the method, proc, or block will be run within the context of the Store's class (not an instance.) In addition the params
method from the Operation will be available to access the Operations parameters.
The Flux paradigm promotes only mutating state inside of receivers.
Hyperloop is less opinionated. You may also add mutator methods to your class. Our recommendation is that you append an exclamation (!) to methods that mutate state.
Note that it is reasonable to have several receivers for the same Operation. This allows subclassing, mixins, and separation of concerns.
Note also that the Ruby scoping rules make it very reasonable to define the Operations to be received by a Store inside the Store's scope. This does not change the semantics of either the Store or the Operation, but simply keeps the name space organized.