Skip to content

Repository files navigation

Declarable

Gem Version Build Status

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.

Philosophy

  • 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.

Installation

Add this line to your application's Gemfile:

gem 'declarable'

And then execute: $ bundle install

Or install it yourself as: $ gem install declarable

Core Concepts

Declarable is built around three main concepts:

  1. state: The raw, mutable data that your class holds. This is the "source of truth".
  2. 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.
  3. 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.

Quick Start

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!"

DSL Reference

state

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 }
end

derive

Defines a computed, reactive value.

With a Block

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
end

With a Class

For 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
end

action

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

Advanced Example

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."

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/alexander-s-fokin/declarable.

License

The gem is available as open source under the terms of the MIT License.

About

Declarable provides a DSL to define states, derived values, and actions.

Topics

Resources

Code of conduct

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages