/signalize

A Ruby port of Signals, providing reactive variables, derived computed state, side effect callbacks, and batched updates.

Primary LanguageRubyMIT LicenseMIT

Signalize

Signalize is a Ruby port of the JavaScript-based core Signals package by the Preact project. Signals provides reactive variables, derived computed state, side effect callbacks, and batched updates.

Additional context as provided by the original documentation:

Signals is a performant state management library with two primary goals:

Make it as easy as possible to write business logic for small up to complex apps. No matter how complex your logic is, your app updates should stay fast without you needing to think about it. Signals automatically optimize state updates behind the scenes to trigger the fewest updates necessary. They are lazy by default and automatically skip signals that no one listens to. Integrate into frameworks as if they were native built-in primitives. You don't need any selectors, wrapper functions, or anything else. Signals can be accessed directly and your component will automatically re-render when the signal's value changes.

While a lot of what we tend to write in Ruby is in the form of repeated, linear processing cycles (aka HTTP requests/responses on the web), there is increasingly a sense that we can look at concepts which make a lot of sense on the web frontend in the context of UI interactions and data flows and apply similar principles to the backend as well. Signalize helps you do just that.

NOTE: read the Contributing section below before submitting a bug report or PR.

Installation

Install the gem and add to the application's Gemfile by executing:

$ bundle add signalize

If bundler is not being used to manage dependencies, install the gem by executing:

$ gem install signalize

Usage

Signalize's public API consists of five methods (you can think of them almost like functions): signal, untracked, computed, effect, and batch.

signal(initial_value)

The first building block is the Signalize::Signal class. You can think of this as a reactive value object which wraps an underlying primitive like String, Integer, Array, etc.

require "signalize"

counter = Signalize.signal(0)

# Read value from signal, logs: 0
puts counter.value

# Write to a signal
counter.value = 1

You can include the Signalize::API mixin to access these methods directly in any context:

require "signalize"
include Signalize::API

counter = signal(0)

counter.value += 1

untracked { }

In case when you're receiving a callback that can read some signals, but you don't want to subscribe to them, you can use untracked to prevent any subscriptions from happening.

require "signalize"
include Signalize::API

counter = signal(0)
effect_count = signal(0)
fn = proc { effect_count.value + 1 }

effect do
  # Logs the value
	puts counter.value

	# Whenever this effect is triggered, run `fn` that gives new value
	effect_count.value = untracked(&fn)
end

computed { }

You derive computed state by accessing a signal's value within a computed block and returning a new value. Every time that signal value is updated, a computed value will likewise be updated. Actually, that's not quite accurate — the computed value only computes when it's read. In this sense, we can call computed values "lazily-evaluated".

require "signalize"
include Signalize::API

name = signal("Jane")
surname = signal("Doe")

full_name = computed do
  name.value + " " + surname.value
end

# Logs: "Jane Doe"
puts full_name.value

name.value = "John"
name.value = "Johannes"
# name.value = "..."
# Setting value multiple times won't trigger a computed value refresh

# NOW we get a refreshed computed value:
puts full_name.value

effect { }

Effects are callbacks which are executed whenever values which the effect has "subscribed" to by referencing them have changed. An effect callback is run immediately when defined, and then again for any future mutations.

require "signalize"
include Signalize::API

name = signal("Jane")
surname = signal("Doe")
full_name = computed { name.value + " " + surname.value }

# Logs: "Jane Doe"
effect { puts full_name.value }

# Updating one of its dependencies will automatically trigger
# the effect above, and will print "John Doe" to the console.
name.value = "John"

You can dispose of an effect whenever you want, thereby unsubscribing it from signal notifications.

require "signalize"
include Signalize::API

name = signal("Jane")
surname = signal("Doe")
full_name = computed { name.value + " " + surname.value }

# Logs: "Jane Doe"
dispose = effect { puts full_name.value }

# Destroy effect and subscriptions
dispose.()

# Update does nothing, because no one is subscribed anymore.
# Even the computed `full_name` signal won't change, because it knows
# that no one listens to it.
surname.value = "Doe 2"

IMPORTANT: you cannot use return or break within an effect block. Doing so will raise an exception (due to it breaking the underlying execution model).

def my_method(signal_obj)
  effect do
    return if signal_obj.value > 5 # DON'T DO THIS!

    puts signal_obj.value
  end

  # more code here
end

Instead, try to resolve it using more explicit logic:

def my_method(signal_obj)
  should_exit = false

  effect do
    should_exit = true && next if signal_obj.value > 5

    puts signal_obj.value
  end

  return if should_exit

  # more code here
end

However, there's no issue if you pass in a method proc directly:

def my_method(signal_obj)
  @signal_obj = signal_obj

  effect &method(:an_effect_method)

  # more code here
end

def an_effect_method
  return if @signal_obj.value > 5

  puts @signal_obj.value
end

batch { }

You can write to multiple signals within a batch, and flush the updates at all once (thereby notifying computed refreshes and effects).

require "signalize"
include Signalize::API

name = signal("Jane")
surname = signal("Doe")
full_name = computed { name.value + " " + surname.value }

# Logs: "Jane Doe"
dispose = effect { puts full_name.value }

batch do
  name.value = "Foo"
  surname.value = "Bar"
end

signal.subscribe { }

You can explicitly subscribe to a signal signal value and be notified on every change. (Essentially the Observable pattern.) In your block, the new signal value will be supplied as an argument.

require "signalize"
include Signalize::API

counter = signal(0)

counter.subscribe do |new_value|
  puts "The new value is #{new_value}"
end

counter.value = 1 # logs the new value

signal.peek

If you need to access a signal's value inside an effect without subscribing to that signal's updates, use the peek method instead of value.

require "signalize"
include Signalize::API

counter = signal(0)
effect_count = signal(0)

effect do
  puts counter.value

  # Whenever this effect is triggered, increase `effect_count`.
  # But we don't want this signal to react to `effect_count`
  effect_count.value = effect_count.peek + 1
end

Signalize Struct

An optional add-on to Signalize, the Singalize::Struct class lets you define multiple signal or computed variables to hold in struct-like objects. You can even add custom methods to your classes with a simple DSL. (The API is intentionally similar to Data in Ruby 3.2+, although these objects are of course mutable.)

Here's what it looks like:

require "signalize/struct"

include Signalize::API

TestSignalsStruct = Signalize::Struct.define(
  :str,
  :int,
  :multiplied_by_10
) do # optional block for adding methods
  def increment!
    self.int += 1
  end
end

struct = TestSignalsStruct.new(
  int: 0,
  str: "Hello World",
  multiplied_by_10: computed { struct.int * 10 }
)

effect do
  puts struct.multiplied_by_10 # 0
end

effect do
  puts struct.str # "Hello World"
end

struct.increment! # above effect will now output 10
struct.str = "Goodbye!" # above effect will now output "Goodbye!"

If you ever need to get at the actual Signal object underlying a value, just call *_signal. For example, you could call int_signal for the above example to get a signal object for int.

Signalize structs require all of their members to be present when initializing…you can't pass only some keyword arguments.

Signalize structs support to_h as well as deconstruct_keys which is used for pattern matching and syntax like struct => { str: } to set local variables.

You can call members (as both object/class methods) to get a list of the value names in the struct.

Finally, both inspect and to_s let you debug the contents of a struct.

Development

After checking out the repo, run bin/setup to install dependencies. Then, run bin/rake test to run the tests, or bin/guard or run them continuously in watch mode. 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 the created tag, and push the .gem file to rubygems.org.

Contributing

Signalize is considered a direct port of the original Signals JavaScript library. This means we are unlikely to accept any additional features other than what's provided by Signals (unless it's completely separate, like our Signalize::Struct add-on). If Signals adds new functionality in the future, we will endeavor to replicate it in Signalize. Furthermore, if there's some unwanted behavior in Signalize that's also present in Signals, we are unlikely to modify that behavior.

However, if you're able to supply a bugfix or performance optimization which will help bring Signalize more into alignment with its Signals counterpart, we will gladly accept your PR!

This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the code of conduct.

License

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