mvu

MVU

A concurrency-safe Model-View-Update (The Elm Architecture) implementation for Crystal.

Installation

Add the dependency to your shard.yml:

dependencies:
  mvu:
    github: shpeckman/mvu

Then run shards install.

Usage

A model is a struct that includes MVU::Model and implements update and view. Messages are structs that include MVU::Msg.

module App
  struct Increment; include MVU::Msg; end
  struct Decrement; include MVU::Msg; end

  struct Model
    include MVU::Model

    getter count : Int32

    def initialize(@count = 0)
    end

    def update(msg : MVU::Msg) : {self, MVU::Cmd}
      case msg
      when Increment then {Model.new(@count + 1), MVU::Cmd.none}
      when Decrement then {Model.new(@count - 1), MVU::Cmd.none}
      else {self, MVU::Cmd.none}
      end
    end

    def view : String
      "Count: #{@count}"
    end
  end
end

program = MVU::Program.new(App::Model.new)
program.run do |model|
  puts model.view
end

DSL

MVU.app generates the model struct from a declarative block, removing the getters, initialize, the case msg dispatch, the {model, cmd} return contract, and the paired subscription methods. The counter above becomes:

MVU.app Counter do
  state do
    count : Int32 = 0
  end

  message Increment
  message Decrement

  update do
    on Increment, {count: count + 1}
    on Decrement, {count: count - 1}
  end

  view do
    "Count: #{count}"
  end
end

program = MVU::Program.new(Counter.new)
program.run do |model|
  puts model.view
end

state

Declares the model's fields. Each is a name : Type declaration with an optional default. The block generates a getter per field, an initialize with defaults, and an immutable copy(...) method that update handlers use to produce a new model.

state do
  count     : Int32  = 0
  label     : String = "idle"
  listening : Bool   = false
end

Fields without a default become required initialize arguments.

message

Each message generates a struct that includes MVU::Msg. Trailing name : Type arguments become getters and initialize parameters.

message Increment           # empty marker message
message SetTo, value : Int32 # carries a payload

update

on Msg clauses build the dispatch. A missing else arm is generated automatically, so unhandled messages leave the model unchanged. There are two handler forms.

The state-only form takes a named tuple whose keys are state fields. It is merged into copy(...) and pairs with MVU::Cmd.none:

update do
  on Increment, {count: count + 1}
  on Decrement, {count: count - 1}
end

The block form uses state ... to set fields and cmd ... to emit a command. A block argument binds the matched message for field access:

update do
  on SetTo do |m|
    state count: m.value
  end

  on Fetch do
    cmd MVU::Cmd.of { fetch_next }
    state label: "loading"
  end
end

Multiple on clauses for the same message are merged into a single arm. Multiple cmd statements in one handler are combined with MVU::Cmd.batch.

subscribe

Each on :id, if: guard do |dispatch, cancel| ... end clause generates an entry in both subscription_ids (gated by the if: guard) and subscription(id). Declaring the two together makes it impossible to define one without the other.

subscribe do
  on :timer, if: listening do |dispatch, cancel|
    until cancel.closed?
      sleep 1.second
      dispatch.call(Tick.new) unless cancel.closed?
    end
  end
end

view

Returns the rendered String for the current model.

view do
  "#{label}: #{count}"
end

Full example

MVU.app App do
  state do
    count     : Int32  = 0
    label     : String = "idle"
    listening : Bool   = false
  end

  message Increment
  message SetTo, value : Int32
  message Tick
  message Toggle

  update do
    on Increment, {count: count + 1}
    on SetTo do |m|
      state count: m.value
    end
    on Toggle, {listening: !listening}
    on Tick, {count: count + 1}
  end

  subscribe do
    on :timer, if: listening do |dispatch, cancel|
      until cancel.closed?
        sleep 1.second
        dispatch.call(Tick.new) unless cancel.closed?
      end
    end
  end

  view do
    "#{label}: #{count}"
  end
end

Middleware is not part of the model block; it is wired at the program layer (see Middleware).

Commands (Cmd)

Commands represent side-effects that may yield a new Msg.

MVU::Cmd.none             # No side-effects
MVU::Cmd.sync { ... }     # Run a blocking block that returns a Msg?
MVU::Cmd.of { ... }       # Run an async block (spawned) that returns a Msg?
MVU::Cmd.batch([...])     # Combine multiple commands

Subscriptions (Sub)

Subscriptions allow listening to ongoing external events, like I/O or timers. To use them, implement subscription_ids and subscription(id) in your model, or declare them with the subscribe DSL block. The Program automatically manages the lifecycle of subscriptions.

def subscription_ids : Array(MVU::SubId)
  return MVU::Sub::NO_IDS unless @listening
  [:timer]
end

def subscription(id : MVU::SubId) : MVU::Sub
  case id
  when :timer
    MVU::Sub.new(id) do |dispatch, cancel|
      until cancel.closed?
        sleep 1
        dispatch.call(Tick.new) unless cancel.closed?
      end
    end
  else
    raise "Unknown sub: #{id}"
  end
end

Middleware

Middleware allows hooking into the update loop (e.g., for logging or persistence).

class Logger(M) < MVU::Middleware(M)
  def call(model : M, msg : MVU::Msg, next_fn : Proc(M, MVU::Msg, {M, MVU::Cmd})) : {M, MVU::Cmd}
    puts "Received: #{msg}"
    next_fn.call(model, msg)
  end
end

MVU::Program.new(App::Model.new, middlewares: [Logger(App::Model).new])
Repository

mvu

Owner
Statistic
  • 0
  • 0
  • 0
  • 1
  • 0
  • about 1 month ago
  • August 12, 2026
License

MIT License

Links
Synced at

Thu, 13 Aug 2026 00:08:28 GMT

Languages