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

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)

store.on_dispatch do |old_state, new_state|
  app.render unless old_state.equal?(new_state)
end

Notice the unless old_state.equal?(new_state) 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.

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.