# Tiny State [![CI](https://github.com/djfpaagman/tiny_state/actions/workflows/ci.yml/badge.svg)](https://github.com/djfpaagman/tiny_state/actions/workflows/ci.yml) > [!WARNING] > Tiny State is currently in early development. Use at your own risk! Tiny State is a library to add a state machine to any Ruby class. It has a few design goals: - It is very small on purpose, with only a small DSL to define the states and events. - Each event is handled by a simple Ruby class, which only defines the transitions it allows and modifies the state's attribute. These can also just be executed as Plain Old Ruby Objects outside of the state machine's context. - No other logic (callbacks, guards, persistence, error handling) is supplied. If you need them you can implement it inside the event classes using regular Ruby code in any way you prefer. - This prevents your main class from being poluted with a lot of state machine related logic, methods, callbacks, conditions, etc. It only defines the state machine. ## Getting Started Tiny State is [published on Rubygems](https://rubygems.org/gems/tiny_state), so just add it to your Gemfile: ```ruby gem "tiny_state" ``` ## Usage This example implements a state machine with three states: `new`, `published` and `rejected`. It has two events: `publish` and `reject`. The `publish` event can only be triggered from the `new` and `rejected` states, and the `reject` event can only be triggered from the `new` state, so you can't reject a post that is already published. ```ruby class Post # include the TinyState module in your class. include TinyState # set up a state attribute, usually this will come from something like your Rails model. attr_accessor :state def initialize(state: :initial) @state = state end # define the state machine with the `tiny_state` method. tiny_state do state :new # define one state at a time state [:approved, :published] # or multiple at once # define each event with the class that handles it. event :publish, PublishPost event :reject, RejectPost end end # define the event classes that handle the transitions, they need to inherit from `TinyState::Event`. class PublishPost < TinyState::Event # define the transitions this event allows. It can transition from multiple states to a single state. transitions from: %i[new rejected], to: :published end class RejectPost < TinyState::Event transitions from: :new, to: :rejected end ``` `tiny_state` takes a block and only defines the possible states and events. The `state` methods defines possible states, and the `event` method defines possible events. The event class is passed as a second argument. Note that the `state` attribute itself is **not** defined by Tiny State. You need to define it yourself in your class or model. There is also no default value set by Tiny State. This will then add the following event methods to a `Post` instance. - `#publish?` and `#publish!` - `#reject?` and `#reject!` The `#question_mark?` methods will return `true` or `false` depending on whether the transition is allowed. These methods are also used internally to check if we can transition the state. The `#bang!` methods will raise an exception if the transition is not allowed, if it is they will change the `state` attribute on the instance to the new value. This will not trigger any database update or other side effects, that is left up to you to implement. ### Defining your states You can define states with the `state` method, eiter individually or multiple states at once. States are deduplicated automatically. ```ruby class Post include TinyState tiny_state do state :new state [:approved, :rejected] end end ``` You can define multiple state machines on a single class, each with their own attribute. If you redefine a state machine, the previous one is overwritten. ### Defining your events You define events with the `event` method. Each event is defined with a class that handles that event. ```ruby class Post include TinyState tiny_state do # ... event :publish, PublishPost end end ``` This class should inherit from `TinyState::Event`. The Event class takes one configuration that defines which transitions it allows. This is checked before transitioning the state. If the transition is not allowed, an `TinyState::InvalidTransitionError` exception is raised. You can define multiple transitions with the `transitions` method. This takes a `from` and `to` keyword argument. The `from` argument can be a single state or an array of states, and the `to` argument is the (single) state the event transitions to. ```ruby class PublishPost < TinyState::Event transitions from: [:new, :rejected], to: :published end ``` ### Exposed state machines To be able to peek inside the state machines, the `#tiny_state_machines` and `#tiny_state_machine` methods are added to the instance of your class. `#tiny_state_machines` returns a hash with the field as the key and a `TinyState::Machine` instance as the value, which contains the defined states and events. As a shortcut the singular `#tiny_state_machine` is also defined, which returns the machine for the first state machine you define or the machine for a specific field if you give it an argument. ```ruby post = Post.new # from the example above post.tiny_state_machine.states # => # post.tiny_state_machine(:state) # => # ``` The `TinyState::StateMachine` instance has the following methods: - `#attribute` returns the attribute the state machine is defined on. - `#states` returns an array of the defined states. - `#events` returns a hash with the event names as keys and the event classes as values. ```ruby post = Post.new # from the example above post.tiny_state_machine.states # => [:new, :published, :rejected] post.tiny_state_machine.events # => [:publish, :reject] post.tiny_state_machine.attribute # => :state ``` ### Using a different field for state By default Tiny State uses the `state` field on your resource. If you want to use a different field, you can supply that as an option when defining the state machine: ```ruby class Post # ... tiny_state :status do # ... end end ``` ### Adding extra transition logic If you want to extend the logic that allows a transition, you can do so by overriding the `#transition?` method in the event class. Be sure to always call `super` to ensure the transition state checks defined in the event itself are also performed. ```ruby class PublishPost < TinyState::Event # ... def transition? # Add your own logic here, for example: super && some_other_condition? end private def some_other_condition? # ... end end ``` ### Implementing side effects If you want to implement side effects before or after a transition, you can do so by overriding the `#transition!` method in the event class. Be sure to always call `super` to ensure the transition is allowed and the `state` is changed. ```ruby class PublishPost < TinyState::Event # ... def transition! super # Add your own logic here, for example: log_publication! end private def log_publication! # ... end end ``` If you don't call `super` in your `transition!` method, it won't check if the transition is allowed and won't change the `state` attribute. If you are fine with that, you can still call `change_state!` to change the state at the moment you want. **Note**: if you plan to have any asynchronous side effects like sending emails or queueing jobs, you should make sure they are fired after the resource is saved and the transaction is committed. This is because the state change is not persisted until the transaction is committed. I recommend to use the [after_commit_everywhere](https://github.com/Envek/after_commit_everywhere) gem for this. ### Using the object or resource itself The object itself is always referenceable through `resource`. This can be used if you want to access the object in the event class. ```ruby class PublishPost < TinyState::Event # ... def transition! # ... resource.published_at = Time.now # ... end end ``` If you want to use a different name for the resource, for example to match the type of resource you pass in, you can alias the method or define a simple one line method that refers to `resource`. ```ruby class PublishPost < TinyState::Event # ... alias_method :post, :resource # or def post = resource end ``` ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/djfpaagman/tiny_state.