Need help with view_component_reflex?
Click the “chat” button below for chat support from the developer who created it, or find similar developers for support.

About the developer

joshleblanc
231 Stars 24 Forks MIT License 454 Commits 6 Opened issues

Description

Call component methods right from your markup

Services available

!
?

Need anything else?

Contributors list

ViewComponentReflex

ViewComponentReflex allows you to write reflexes right in your view component code.

It builds upon stimulus_reflex and view_component

Usage

You can add reflexes to your component by inheriting from

ViewComponentReflex::Component
.

This will act as if you created a reflex with the method

my_cool_stuff
. To call this reflex, add
data-reflex="click->MyComponentReflex#my_cool_reflex"
, just like you're using stimulus reflex.

ViewComponentReflex will maintain your component's instance variables between renders. You need to include

data-key=
on your root element, as well as any element that stimulates a reflex. ViewComponent is inherently state-less, so the key is used to reconcile state to its respective component.

Example

# counter_component.rb
class CounterComponent < ViewComponentReflex::Component
  def initialize
    @count = 0
  end

def increment @count += 1 end end

# counter_component.html.erb

<p></p>

Collections

In order to reconcile state to components in collections, you can specify a

collection_key
method that returns some value unique to that component.
class TodoComponent < ViewComponentReflex::Component
  def initialize(todo:)
    @todo = todo
  end

def collection_key @todo.id end end #

In case you're rendering a collection of empty models, use a UUID of some sort to address the correct component instance on your page:

class TodoComponent < ViewComponentReflex::Component
  def initialize(todo:)
    @todo = todo
  end

def collection_key @todo.id || SecureRandom.hex(16) end end #

API

permitparameter?(initialparam, new_params)

If a new parameter is passed to the component during rendering, it is used instead of what's in state. If you're storing instances in state, you can use this to properly compare them.

def permit_parameter?(initial_param, new_param)
  if new_param.instance_of? MyModel 
    new_param.id == @my_model.id
  else
    super
  end
end

omittedfromstate

Return an array of instance variables you want to omit from state. Only really useful if you're using the session state adapter, and you have an instance variable that can't be serialized.

def omitted_from_state
  [:@form]
end

reflextag(reflex, name, contentoroptionswith_block = nil, options = nil, escape = true, &block)

This shares the same definition as

content_tag
, except it accepts a reflex as the first parameter.

Would add a click handler to the

increment
method on your component.

To use a non-click event, specific that with

->
notation
increment", :button, "Click me!" %>

reflexdataattributes(reflex)

This helper will give you the data attributes used in the reflex_tag above if you want to build your own elements.

Build your own tag:


Render a ViewComponent

increment")) %>

Make sure that you assign the reflexdataattributes to the correct element in your component.

collection_key

If you're rendering a component as a collection with

MyComponent.with_collection(SomeCollection)
, you must define this method to return some unique value for the component. This is used to reconcile state in the background.
def initialize
  @my_model = MyModel.new
end

def collection_key @my_model.id end

stimulate(target, data)

Stimulate another reflex from within your component. This typically requires the key of the component you're stimulating which can be passed in via parameters.

def initialize(parent_key)
  @parent_key = parent_key
end

def stimulate_other stimulate("OtherComponent#method", { key: @parent_key }) end

refresh!(selectors)

Refresh a specific element on the page. Using this will implicitly run

prevent_render!
. If you want to render a specific element, as well as the component, a common pattern would be to pass
selector
as one of the parameters
def my_method
  refresh! '#my-special-element', selector
end

selector

Returns the unique selector for this component. Useful to pass to

refresh!
when refreshing custom elements.

prevent_refresh!

By default, VCR will re-render your component after it executes your method.

prevent_refresh!
prevents this from happening.
def my_method
  prevent_refresh!
  @foo = :bar
end # the rendered page will not reflect this change

refresh_all!

Refresh the entire body of the page

def do_some_global_action
  prevent_refresh!
  session[:model] = MyModel.new
  refresh_all!
end

stream_to(channel)

Stream to a custom channel, rather than the default stimulus reflex one

def do_something
  stream_to MyChannel

@foo = :bar end

key

This is a key unique to a particular component. It's used to reconcile state between renders, and should be passed as a data attribute whenever a reflex is called

Click me!

component_controller(options = {}, &blk)

This is a view helper to properly connect VCR to the component. It outputs

You must wrap your component in this for everything to work properly.
  

afterstateinitialized(parameters_changed)

This is called after the state has been inserted in the component. You can use this to run conditional functions after some parameter has superseeded whatever's in state

def after_state_initialized(parameters_changed)
  if parameters_changed.include?(:@filter)
    calculate_visible_rows
  end
end

Custom reflex base class

Reflexes typically inherit from a base ApplicationReflex. You can define the base class for a viewcomponentreflex by using the

reflex_base_class
accessor. The parent class must inherit ViewComponentReflex::Reflex, and will throw an error if it does not.
class ApplicationReflex < ViewComponentReflex::Reflex

end

class MyComponent < ViewComponentReflex::Component MyComponent.reflex_base_class = ApplicationReflex end

Common patterns

A lot of the time, you only need to update specific components when changing instance variables. For example, changing

@loading
might only need to display a spinner somewhere on the page. You can define setters to implicitly render the appropriate pieces of dom whenever that variable is set
def initialize
  @loading = false
end

def loading=(new_value) @loading = new_value refresh! '#loader' end

def do_expensive_action prevent_refresh!

self.loading = true execute_it self.loading = false end

  

  <p>Loading...</p>

Click me!

State

By default (since version

2.3.2
), viewcomponentreflex stores component state in session. You can optionally set the state adapter to use the memory by changing
config.state_adapter
to
ViewComponentReflex::StateAdapter::Memory
.

Custom State Adapters

ViewComponentReflex uses session for its state by default. To change this, add an initializer to

config/initializers/view_component_reflex.rb
.
ViewComponentReflex::Engine.configure do |config|
  config.state_adapter = YourAdapter
end

Existing Fast Redis based State Adapter

This adapter uses hmset and hgetall to reduce the number of operations. This is the recommended adapter if you are using AnyCable.

ViewComponentReflex::Engine.configure do |config|
  config.state_adapter = ViewComponentReflex::StateAdapter::Redis.new(
      redis_opts: {
          url: "redis://localhost:6379/1", driver: :hiredis
      },
      ttl: 3600)
end

YourAdapter
should implement
class YourAdapter
  ##
  # request - a rails request object
  # key - a unique string that identifies the component instance
  def self.state(request, key)
    # Return state for a given key
  end

set_state is used to modify the state.

request - a rails request object

controller - the current controller

key - a unique string that identifies the component

new_state - the new state to set

def self.set_state(request, controller, key, new_state) # update the state end

store_state is used to replace the state entirely. It only accepts

a request object, rather than a reflex because it's called from the component's

side with the component's instance variables.

request - a rails request object

key - a unique string that identifies the component instance

new_state - a hash containing the component state

def self.store_state(request, key, new_state = {}) # replace the state end end

Installation

Add this line to your application's Gemfile:

gem 'view_component_reflex'

And then execute:

bash
$ bundle

Or install it yourself as:

bash
$ gem install view_component_reflex

Common problems

Uninitialized constants <component>Reflex

A component needs to be wrapped in

 in order to properly initialize, otherwise the Reflex class won't get created.

Session is an empty hash

StimulusReflex 3.3 introduced selector morphs, allowing you to render arbitrary strings via

ApplicationController.render
, for example:
def test_selector
  morph '#some-container', ApplicationController.render(MyComponent.new(some: :param))
end

StimulusReflex 3.4 introduced a fix that merges the current

request.env
and provides the CSRF token to fetch the session.

Help, my instance variables do not persist into the session

These instance variable names are not working and unsafe:

def unsafe_instance_variables
  [
    :@view_context, :@lookup_context, :@view_renderer, :@view_flow,
    :@virtual_path, :@variant, :@current_template, :@output_buffer, :@key,
    :@helpers, :@controller, :@request, :@tag_builder, :@initialized_state
  ]
end

Please use a different name to be able to save them to the session.

Foo Can't Be Dumped

If you are getting errors that e.g. MatchData, Singleton etc. can't be dumped, ensure that you do not set any instance variables in your components (or any class you inject into them, for that matter) that cannot be marshaled.

This can be easily remedied though, by providing a list of unmarshalable instance variables and overwriting

marshal_dump
and
marshal_load
(from https://stackoverflow.com/a/32877159/4341756):
class MarshalTest
  UNMARSHALED_VARIABLES = [:@foo, :@bar]

def marshal_dump instance_variables.reject{|m| UNMARSHALED_VARIABLES.include? m}.inject({}) do |vars, attr| vars[attr] = instance_variable_get(attr) vars end end

def marshal_load(vars) vars.each do |attr, value| instance_variable_set(attr, value) unless UNMARSHALED_VARIABLES.include?(attr) end end end

Anycable

@sebyx07 provided a solution to use anycable (https://github.com/joshleblanc/viewcomponentreflex/issues/23#issue-721786338)

Leaving this, might help others:

I tried this with any cable and I had to add this to development.rb Otherwise @instance_variables were nil after a reflex

  config.cache_store = :redis_cache_store, { url: "redis://localhost:6379/1", driver: :hiredis }
  config.session_store :cache_store

Sidecar assets

When using ViewComponent generated sidecar assets, the stimulus controller won't resolve properly. To resolve this, override the

self.stimulus_controller
method in your component to the correct method.

For example, if you have a structure of

components/
  example_component/
    example_component_controller.js
    example_component.html.erb
  example_component.rb

You would update your

self.stimulus_controller
method to
def self.stimulus_controller
  "example-component--example-component"
end

Thanks @omairrazam for this solution

License

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

Caveats

State uses session to maintain state as of right now. It also assumes your component view is written with a file extension of either

.html.erb
,
.html.haml
or
.html.slim
.

Support me

Buy Me A Coffee

We use cookies. If you continue to browse the site, you agree to the use of cookies. For more information on our use of cookies please see our Privacy Policy.