GrandCentral is a state-management and action-dispatching library for Ruby apps. It was created with Clearwater apps in mind, b 8000 ut there's no reason you couldn't use it with other types of Ruby apps.
GrandCentral is based on ideas similar to Redux. You have a central store that holds all your state. This state is updated via a handler block when you dispatch actions to the store.
Add this line to your application's Gemfile:
gem 'grand_central'
And then execute:
$ bundle
Or install it yourself as:
$ gem install grand_central
First, you'll need a store. You'll need to seed it with initial state and give it a handler block:
require 'grand_central'
store = GrandCentral::Store.new(a: 1, b: 2) do |state, action|
case action
when :a
# Notice we aren't updating the state in-place. We are returning a new
# value for it by passing a new value for the :a key
state.merge a: state[:a] + 1
when :b
state.merge b: state[:b] + 1
else # Always return the given state if you aren't updating it.
state
end
end
store.dispatch :a
store.dispatch :b
store.dispatch "You can dispatch anything you want, really"
The actions you dispatch to the store can be anything. We used symbols in the above example, but GrandCentral also provides a class called GrandCentral::Action
to help you set up your actions:
module Actions
include GrandCentral
# The :todo becomes a method on the action, similar to a Struct
AddTodo = Action.with_attributes(:todo)
DeleteTodo = Action.with_attributes(:todo)
ToggleTodo = Action.with_attributes(:todo) do
# We don't want to toggle the Todo in place. We want a new instance of it.
def toggled_todo
Todo.new(
id: todo.id,
name: todo.name,
complete: !todo.complete?
)
end
end
end
Then your handler can use these actions to update the state more easily:
store = GrandCentral::Store.new(todos: []) do |state, action|
case action
when Actions::AddTodo
state.merge todos: state[:todos] + [action.todo]
when Actions::DeleteTodo
state.merge todos: state[:todos] - [action.todo]
when Actions::ToggleTodo
state.merge todos: state[:todos].map { |todo|
# We want to replace the todo in the array
if todo.id == action.todo.id
action.todo
else
todo
end
}
else
state
end
end
There may be parts of your app that need to dispatch actions but you don't want them to know about the store itself. In a Clearwater app, for example, your components may need to fire off actions, but you don't want them to know how to get things from the store.
Subclasses of GrandCentral::Action
have a store
attribute where you can set the default store to dispatch to:
store = GrandCentral::Store.new(todos: []) do |state, action|
# ...
end
MyAction = GrandCentral::Action.with_attributes(:a, :b, :c)
MyAction.store = store
To use this default store, you can send the call
message to the action class itself:
MyAction.call(1, 2, 3) # these arguments correspond to the a, b, and c attributes above
You can curry an action with the []
operator instead of call
. There is a minor difference between action currying and traditional function currying, though: function currying automatically invokes the function once all of the arguments are received, whereas currying a GrandCentral::Action
will curry until you explicitly invoke it with call
:
SetAttribute = Action.with_attributes(:attr, :value)
# Returns an action type based on `SetAttribute` with `attr` hard-
# coded to `:name`. When you invoke this new action type, you only
# need to provide the value.
SetName = SetAttribute[:name]
# This action is a further specialization where we know both the
# attribute *and* the value. You don't need to provide any values.
ClearName = SetName[nil]
These are all equivalent action invocations:
SetAttribute.call :name, nil
SetAttribute[:name].call nil
SetAttribute[:name, nil].call
SetName.call nil
ClearName.call
Because of action currying, we can set action types as event handlers in Clearwater components. GrandCentral even knows how to handle Bowser::Event
objects whose targets are instances of Bowser::Element
(Clearwater uses Bowser as its DOM abstraction). So if you set an action to handle toggling a checkbox, the last argument will contain the input's checked
property; if you use it for a text box, the last argument will be the value
property. It'll even prevent form
submission for you:
AddTodo = Action.with_attributes(:description)
SetNewTodoDescription = Action.with_attributes(:description)
ToggleTodo = Action.with_attributes(:todo, :complete)
DeleteTodo = Action.with_attributes(:todo)
class TodoList
include Clearwater::Component
def initialize(todos, new_description)
@todos = todos
@new_description = new_description
end
def render
div([
# All arguments are provided to the AddTodo action, but we still delay its invocation
form({ onsubmit: AddTodo[@new_description] }, [
# We omit the description from SetNewTodoDescription - it's inferred from the input event
input(oninput: SetNewTodoDescription, value: @new_description),
button('Add'),
]),
ul(todos.map { |todo|
li([
input(type: :checkbox, onchange: ToggleTodo[todo]),
todo.description,
button({ onclick: DeleteTodo[todo] }, '✖️'),
])
}),
])
end
You may want your application to do something in response to a dispatch. For example, in a Clearwater app, you might want to re-render the application when the store's state has changed:
store = GrandCentral::Store.new(todos: []) do |state, action|
# ...
end
app = Clearwater::Application.new(component: Layout.new)
# on_dispatch yields the state before and after the dispatch as well as the action
store.on_dispatch do |before, after, action|
app.render unless before.equal?(after)
end
Notice the unless before.equal?(after)
clause. This is one of the reasons we recommend you update state by returning a new value instead of mutating it in-place. It allows you to do cache invalidation in O(1) time.
Using on_dispatch
also useful if you want to consolidate all of your side effects:
FetchUsers = Action.create do
def request
Bowser::HTTP.fetch('/api/users')
end
end
LoadUsers = Action.with_attributes(:json) do
def users
json[:users].map { |attrs| User.new(attrs) }
end
end
store.on_dispatch do |before, after, action|
case action
when FetchUsers
action.request
.then(&:json)
.then(&LoadUsers)
end
end
We can use the GrandCentral::Model
base class to store our objects:
class Person < GrandCentral::Model
attributes(
:id,
:name,
:location,
)
end
This will set up a Person
class we can instantiate with a hash of attributes:
jamie = Person.new(name: 'Jamie')
The attributes of a model cannot be modified once set. That is, there's no way to say person.name = 'Foo'
. If you need to change the attributes of a model, there's a method called update
that returns a new instance of the model with the specified attributes:
jamie = Person.new(name: 'Jamie')
updated_jamie = jamie.update(location: 'Baltimore')
jamie.location # => nil
updated_jamie.location # => "Baltimore"
This allows you to use the update
method in your store's handler without mutating the original reference:
store = GrandCentral::Store.new(person) do |person, action|
case action
when ChangeLocation
person.update(location: action.location)
else person
end
end
This keeps each version of your app state intact if you need to roll back to a previous version. In fact, the app state itself can be a GrandCentral::Model
:
class AppState < GrandCentral::Model
attributes(
:todos,
:people,
)
end
initial_state = AppState.new(
todos: [],
people: [],
)
store = GrandCentral::Store.new(initial_state) do |state, action|
case action
when AddPerson
state.update(people: state.people + [action.person])
when DeleteTodo
state.update(todos: state.todos - [action.todo])
else
state
end
end
After checking out the repo, run bin/setup
to install dependencies. Then, run rake spec
to run the tests. You can also run bin/console
for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install
. To release a new version, update the version number in version.rb
, and then run bundle exec rake release
, which will create a git tag for the version, push git commits and tags, and push the .gem
file to rubygems.org.
Bug reports and pull requests are welcome on GitHub at https://github.com/clearwater-rb/grand_central. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the Contributor Covenant code of conduct.