[go: up one dir, main page]

State

State is a mutation effect. It allows reading and writing non-local values.

Basic usage

Handling code:

require 'dry/effects'

class CountCalls
  include Dry::Effects::Handler.State(:counter)

  def call
    counter, result = with_counter(0) do
      yield
    end

    puts "Counter: #{counter}"

    result
  end
end

Using code:

require 'dry/effects'

class HeavyLifting
  include Dry::Effects.State(:counter)

  def call
    self.counter += 1
    # ... do heavy work ...
  end
end

Now it's simple to count calls by gluing two pieces:

count_calls = CountCalls.new
heavy_lifting = HeavyLifting.new

count_calls.() { 1000.times { heavy_lifting.() }; :done }
# Counter: 1000
# => :done

Handler interface

As shown above, the State handler returns two values: the accumulated state and the return value of the block:

include Dry::Effects::Handler.State(:state)

def run(&block)
  accumulated_state, result = with_state(initial_state) do
    block.call
  end

  # result holds the return value of block.call

  # accumulated_state refers to the last written value
  # or initial_value if the state wasn't changed
end

Identifiers and mixing states

All state handlers and effects have an identifier. Effects with different identifiers are compatible without limitations but swapping the handlers may change the result:

require 'dry/effects'

class Program
  include Dry::Effects.State(:sum)
  include Dry::Effects.State(:product)

  def call
    1.upto(10) do |i|
      self.sum += i
      self.product *= i
    end

    :done
  end
end

program = Program.new

extend Dry::Effects::Handler.State(:sum)
extend Dry::Effects::Handler.State(:product)

with_sum(0) { with_product(1) { program.call } }
# => [55, [3628800, :done]]
with_product(1) { with_sum(0) { program.call } }
# => [3628800, [55, :done]]

Relation to Reader

A State handler eliminates Reader effects with the same identifier:

require 'dry/effects'

extend Dry::Effects::Handler.State(:counter)
extend Dry::Effects.Reader(:counter)

with_counter(100) { "Counter value is #{counter}" }
# => [100, "Counter values is 100"]

Not providing an initial value

There are cases when an initial value cannot be provided. You can skip the initial value but in this case, reading it before writing will raise an error:

extend Dry::Effects::Handler.State(:user)
extend Dry::Effects.State(:user)

with_user { user }
# => Dry::Effects::Errors::UndefinedStateError (+user+ is not defined, you need to assign it first by using a writer, passing initial value to the handler, or providing a fallback value)

with_user do
  self.user = 'John'

  "Hello, #{user}"
end
# => ["John", "Hello, John"]

One example is testing middleware without mutating env:

RSpec.describe AddingSomeMiddleware do
  include Dry::Effects::Handler.State(:env)
  include Dry::Effects.State(:env)

  let(:app) do
    lambda do |env|
      self.env = env
      [200, {}, ["ok"]]
    end
  end

  subject(:middleware) { described_class.new(app) }

  it 'adds SOME_KEY to env' do
    captured_env, _ = middleware.({})

    expect(captured_env).to have_key('SOME_KEY')
  end
end

Default value for Reader effects

When no initial value is given, you can use a block for providing a default value:

extend Dry::Effects::Handler.State(:artist)
extend Dry::Effects.State(:artist)

with_artist { artist { 'Unknown Artist' } } # => "Unknown Artist"

When to use?

State is a classic example of an effect. However, using it often can make your code harder to follow.

octocatEdit on GitHub