GrandCentral

GrandCentral is a state-management and action-dispatching library for Ruby apps. It was created with Clearwater apps in mind, but 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.

Installation

Add this line to your application's Gemfile:

gem 'grand_central'

And then execute:

$ bundle

Or install it yourself as:

$ gem install grand_central

Usage

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"

Actions

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

Dispatching actions without the store

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

Action Currying

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

Using with Clearwater

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

Performing actions on dispatch

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

Models

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')

Immutable Models

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

Development

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.

Contributing

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.