Skip to content

Latest commit

 

History

History
335 lines (278 loc) · 11.6 KB

README.md

File metadata and controls

335 lines (278 loc) · 11.6 KB

RailsRedhot gem

REDux pattern for HOTwire == Redhot

Single page applications using redux (react) are very popular. And with good reason, redux makes maintaining the current state of the app easy. For instance when building some kind of editor, every action of the user is added to the redux store. All actions can be reduced to the determine the current state of the editor. Views are rendered using the current state.
Or when building a complex search page for a webshop. Whenever the user selects a category or price range to filter on this can be an action for the redux store. If the user hits the back button the last action can be deleted and the current state regenerated by reducing all remaining actions. The user only sees the last filter being reverted to what it was before.

What is redux?

It is a store containing a list of changes.
All changes combined determine the current view state through one or more reducer functions.
The current view state is also stored.
If a new change arrives it only needs to be applied to the current view state.
To undo a change apply all but the last action again to rebuild te view state.

For example a view contains a number counting the likes for an article.
There are two buttons: increase- and decrease the likes counter.
Clicking on a button adds an action to the store.
The action is passed on to all reducer functions,
each function passes the new computed state on to the next function:
- If the counter value is nil in the current state set it to zero
- If the action was 'increase' then increment the counter value
- If the action was 'decrease' then decrement the counter value
- If the counter value is lower than zero set it to zero
Save the new state in the store.
Render the view showing the updated counter value.

What are the advantages of redux?

From the redux website (minus the part about plugins):

Predictable
Redux helps you write applications that behave consistently,
run in different environments (client, server, and native)
and are easy to test.

Centralized
Centralizing your application's state and logic enables powerful capabilities
like undo/redo, state persistence, and much more.

Debuggable
The Redux DevTools make it easy to trace when, where, why
and how your application's state changed.
Redux's architecture lets you log changes, use 'time-travel debugging'
and even send complete error reports to a server.

Remove complexity

Sometimes the actions of the user in the frontend should be sent to a backend application. For instance when actions of multiple users should be kept in-sync. In react applications command-query-responsibility-separation (CQRS) is often used for this purpose. These solutions can become very complex (example, scroll down a bit for a full architecture picture).

The Hotwire (Html Over The Wire) approach does an excellent job of removing the need to build single page apps. Hotwire is the default tool for frontend development in Rails 7. However when using hotwire the responsibilty of maintaining frontend state entirely falls to the backend application. So when building your editor or search page you need a way to keep track of that state. The redux (also known as flux- or observer-) pattern is very useful for this purpose.

Hotwire

This gem aims to combine html-over-the-wire approach with the redux pattern to radically reduce overall complexity of an application. (At least when compared to for instance react+cqrs application stacks.) Only four components are required:

  1. Views, normal rails views rendering the current state and delivered as turbo frames
  2. Actions, just submit buttons that send a request to the backend handled by a controller
  3. Store, keeping the list of actions and current state, managed by this gem and stored in an activerecord model
  4. Reducers, a set of functions (provided by you) that translate actions to changes in state. The state can be used again in step 1

Benefits

  • Straightforward workflow
  • Common actions (undo, redo, flatten actions to initial state) are provided by this gem. Combined with turbo frames for rendering partial page updates this makes it easy to create a very smooth user experience
  • You can create a store of attributes within a single ActiveRecord model. In a Single Page App (SPA) lots of settings may be needed for a good user experience. It may be a lot of work to store these in multiple models. A redux store can hold an arbitrary amount of attributes

Usage

Model

Create a migration to add a 'text' type attribute to a model that should have a redux store. In the model add an acts_as_redux line, specifying the name of the text attribute. Add a private method holding all your reducer functions. See this example. Note that all reducer functions must return the state object (a Hash).

class Foobar < ApplicationRecord
  include RailsRedhot::ActsAsRedux

  acts_as_redux :my_redux

  private

  def my_redux_reducers
    [
      ->(state, action) {
        case action[:type]
        when :add
          state[:total] += 1
        when :remove
          state[:total] -= 1
        end
        state
      }
    ]
  end
end

Or specify your own reducer method:

acts_as_redux :my_redux, reducers: :my_list_of_reducers

def my_list_of_reducers
  # ...

Undo/redo

Every instance of the model now has access to several methods. For undoing actions there are: undo?, undo_action and undo!, which you might use in a view like this:

<%- if foobar.undo? %>
  <%= form_with(model: foobar, url: update_action_foobar_path(foobar), method: :put) do |form| %>
    <%= form.hidden_field :action, value: :undo %>
    <%= form.submit "Undo: #{foobar.undo_action['type']}" %>
  <% end %>
<% end %>

In the controller action use the undo! method to perform the action. For redoing actions the similar methods redo?, redo_action and redo! are available.

Flatten

You can 'save' the current state. Essentially this copies the current view state to the initial state and truncates the list of actions. Redo and undo are not possible until new actions are added. Methods flatten? and flatten! can be used in a view and controller:

<%- if foobar.flatten? %>
  <%= form_with(model: foobar, url: update_action_foobar_path(foobar), method: :put) do |form| %>
    <%= form.hidden_field :action, value: :flatten %>
    <%= form.submit "Save changes" %>
  <% end %>
<% end %>

Sequence ID

As a convenience a sequence ID id is available which should always return a unique id (within the context of the model instance). To get the next sequence id use next_seq_id, to get the current sequence value use seq_id. You could use a sequence in a reducer function to make sure every added item is assigned a unique id.

Dispatch actions and view state

To add an action to the store you can use the dispatch! method, passing a hash with the details of the action. What the content of that hash should be is up to you. As long as your reducer fuctions can handle the action anything is possible.

Finally to get the current state the view_state method is available. In a view it can be used like so:

<p>
  There are <%= foobar.view_state['total'] %> items
</p>

<%- foobar.view_state['items'].each do |item| %>
  <p>
    <%= CGI.unescape(item['value']) %>
  </p>
<% end %>

The view_state method returns a Hash. What the content of this hash looks like depends on the reducer functions you have implemented.

For a full working example see the demo applications view and controller.

Adding errors

Just like you can add validation errors on a model, you can add errors inside your reducer methods. There is an ActiveModel::Errors object for redux errors. It is separate from the one for the model and can be accessed via reduce_errors. Use it like this:

def my_redux_reducers
  @my_redux_reducers ||= [
    -> (state, action) {
      case action[:type]&.to_sym
      when :add
        if action[:item].length <= 6
          state[:items] << { id: next_seq_id, value: CGI.escape(action[:item]) }
        else
          reduce_errors.add(:item, :too_long, { count: 6 })
        end
      end

      state
    }
  ]
end

Since ActiveModel does not know anything about attributes living inside your redux store using reduce_errors.full_messages won't work. You can create you own error to message translation or supply :base as the attribute name plus a message. See the Rails documentation.

reduce_errors.add(:base, message: 'Item should have between 1 and 6 characters')

If any reduce error is present the dispatch! method will return false. To check if there are any errors present (to prevent saving the model) use:

@foobar.reduce_valid?

In the controller you may want to reload the model if the dispatch action gave an error, so the old state is rendered.

After change callback

Sometimes you want to do something after the redux store has changed. For instance to manipulate the view state based on all entries (the reducer methods only handle one action at the time). This callback method is called after dispatch!, undo! and redo!. In the model:

class AnotherFooBar < ApplicationRecord
  include RailsRedhot::ActsAsRedux

  acts_as_redux :another_redux_store, after_change: :my_after_change_actions

  private

    def another_redux_store_reducers
      [
        -> (state, _action) {
          state[:items] ||= []
          state
        },
        # ...
      ]
    end

    def my_after_change_actions
      # Do something with the view_state
      # view_state[:items].each { do_something }
    end
end

Security

Care must be taken to not introduce any vulnerabilities! When passing values from the request to the reducer functions treat any string or complex values as potential candidates for SQL injection. Either sanitize or CGI.escape strings before adding them to the redux store.

Installation

Add this line to your application's Gemfile:

gem "rails_redhot"

And then execute:

$ bundle

Or install it yourself as:

$ gem install rails_redhot

Demo application

To use the demo application, clone the repo and run rails:

git clone https://github.com/easydatawarehousing/rails_redhot.git
cd rails_redhot
bundle install
cd test/dummy
rails db:setup
bin/dev

Then open the application. Click on 'New foobar', 'Add a new foobar' and 'Edit this foobar'.

Test

Run:

rails test test/dummy/test

License

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

Remarks

  • This gem is not designed to handle very large lists of actions and state. When calling undo the state is rebuilt from scratch, if the list of actions to process is large this would become slow. One would need add 'savepoints' that regularly save the state and rebuild the current state from that point forward
  • Stricly speaking, hotwire is not needed for this gem to work. Just using plain old rails views and controllers is fine. Hotwire certainly makes an application using this gem a lot faster
  • No checking on the size of the text attribute used for the store is done
  • Currently only one redux store can be added to a model
  • Redux store code inspired by: