Declarable is a small, powerful Ruby gem for adding declarative, reactive state management to any Ruby class. It helps you separate complex state logic from your core business logic, leading to cleaner, more maintainable, and easily testable code.
Inspired by modern frontend frameworks and patterns like CVA, Declarable brings reactive, computed properties to the Ruby backend in an elegant and intuitive way.
- Declarative: Describe what state your class has, not how to manage it imperatively.
- Reactive: Derived values automatically update when their dependencies change.
- Encapsulated: Keep state-related logic, validations, and actions neatly organized and separated from presentation or business logic.
- Framework-Agnostic: Written in pure Ruby with zero external dependencies.
Add this line to your application's Gemfile:
gem 'declarable'And then execute:
$ bundle install
Or install it yourself as:
$ gem install declarable
Declarable is built around three main concepts:
state: The raw, mutable data that your class holds. This is the "source of truth".derive: Computed values that are derived from one or more states. They are reactive and automatically memoized. When a state they depend on changes, their cache is invalidated.action: Encapsulated procedures that contain logic and side-effects. Actions can read states and derivations, and they can change base states, triggering the reactive system.
Here's a simple example to demonstrate the basic functionality:
require 'declarable'
class Greeter
include Declarable
# 1. Define the interface
declarable do
state :first_name, default: "John"
state :last_name, default: "Doe"
derive :full_name, from: [:first_name, :last_name] do |fname, lname|
puts "--> Deriving full_name..."
"#{fname} #{lname}"
end
end
def initialize(options = {})
# 2. Initialize the context
@context = initialize_declarable(options)
end
def greet
# 3. Use the context to access data
"Hello, #{@context.full_name}!"
end
def change_name(first_name)
@context.first_name = first_name
end
end
# --- Usage ---
greeter = Greeter.new(first_name: "Jane")
puts greeter.greet
# --> Deriving full_name...
# => "Hello, Jane Doe!"
puts greeter.greet
# => "Hello, Jane Doe!" (No console output, result is memoized)
greeter.change_name("John")
# The cache for :full_name is now invalidated.
puts greeter.greet
# --> Deriving full_name...
# => "Hello, John Doe!"Defines a base, mutable state.
declarable do
# With a default value
state :status, default: "pending"
# With validations
state :role, default: "user", validates: {
inclusion: { in: %w[user admin editor] }
}
state :email, validates: { presence: true }
endDefines a computed, reactive value.
For most use cases, a block is the simplest way to define a derivation. The block arguments correspond to the values of the dependencies listed in from.
declarable do
state :price, default: 100
state :tax_rate, default: 0.2
derive :total_price, from: [:price, :tax_rate] do |price, tax|
price * (1 + tax)
end
endFor complex, reusable logic, you can specify a transformer class. The class must respond to .call(dependencies_hash).
class MyComplexTransformer
def self.call(price:, tax_rate:)
# ... complex logic
end
end
declarable do
# ... states
derive :total_price, from: [:price, :tax_rate], class: MyComplexTransformer
endDefines an encapsulated procedure. The block receives the context object as its first argument, followed by any arguments passed to .call.
declarable do
state :page, default: 1
action :next_page do |context|
context.page += 1
end
action :set_page do |context, new_page_number|
context.page = new_page_number
end
end
# Usage:
# @context.next_page.call
# @context.set_page.call(5)This example demonstrates how all parts work together to create a simple search component's logic.
require 'declarable'
# A dummy class to represent our search logic
class MockSearchService
def self.call(query)
query.to_s.empty? ? [] : ["Result for '#{query}' 1", "Result for '#{query}' 2"]
end
end
class SearchComponent
include Declarable
declarable do
# 1. STATES
state :query, default: ""
state :results, default: []
# 2. DERIVATIONS
derive :found_count, from: :results, do: ->(res) { res.size }
derive :status_message, from: [:query, :found_count] do |q, count|
q.empty? ? "Enter a query to search." : "Found #{count} results."
end
# 3. ACTION
action :perform_search do |context|
# Actions can read state and call external services
found_data = MockSearchService.call(context.query)
# Actions can change state, triggering the reactive system
context.results = found_data
end
end
def initialize
@context = initialize_declarable
end
# Expose the context for interaction
attr_reader :context
end
# --- Usage ---
search = SearchComponent.new
puts search.context.status_message
# => "Enter a query to search."
search.context.query = "Ruby Gems"
puts search.context.status_message # `found_count` is 0 because `results` is still []
# => "Found 0 results."
# Call the action to perform the search
search.context.perform_search.call
# Now `results` is updated, and `found_count` and `status_message` are re-derived!
puts search.context.status_message
# => "Found 2 results."Bug reports and pull requests are welcome on GitHub at https://github.com/alexander-s-fokin/declarable.
The gem is available as open source under the terms of the MIT License.