Back to the guide

Real-world patterns

Conventions distilled from a range of production Compony apps. These are idioms, not framework requirements — but they recur consistently and are worth adopting. Every example uses a neutral domain (Account, Order, LineItem, Document). Where a pattern relies on a companion gem (CanCanCan, ActiveType, simple_form, a date/select input) that is called out.

For exact method signatures see dsl_reference.md; for footguns see gotchas.md.

1. The app base-component layer

Almost every non-trivial app inserts one abstract layer between Compony's pre-built components and the concrete ones. Concrete components inherit from the app layer, never from Compony directly. This centralizes layout, button styling, and chrome so the whole app's look changes in one place.

# app/components/base_components/show.rb  (a common location; app/compony/ is also used)
module BaseComponents
  class Show < Compony::Components::Show
    setup do
      standalone { layout :backend }     # app-wide Rails layout for all non-publicly accessible components
      button(:icon) { :eye }
      content :main, hidden: true        # concrete comps fill :main…
      content :wrapper do                # …chrome lives here, inherited
        div class: 'card card-body' do
          content :main
        end
      end
    end
  end
end

# app/components/orders/show.rb
class Components::Orders::Show < BaseComponents::Show
end                                      # fully functional, empty body

Recurring forms of this layer: BaseComponents::{Index,Show,New,Edit,Destroy,List}. The content :main, hidden: true + content :wrapper pair is the standard way to let children override the inner content while inheriting the outer chrome (see basic_component.md).

Teams sometimes add their own helper DSL on top of this layer (CSV/PDF helpers, archive toggles, etc.). Keep such helpers in the app base layer, not in concrete components.

2. Thin leaf components

Concrete CRUD components are usually empty — all behavior is inherited. Add a setup block only to deviate.

class Components::Orders::Destroy < BaseComponents::Destroy; end
class Components::Orders::New     < BaseComponents::New;     end
class Components::Orders::Edit    < BaseComponents::Edit;    end

This is the single most common pattern. Prefer it over hand-written endpoints (gotchas.md #15).

3. Index = load_data scope + nested :list

Index components rarely render rows themselves; they load a scope and embed the family's List via render_sub_comp.

class Components::Orders::Index < BaseComponents::Index
  setup do
    load_data { @data = Order.accessible_by(current_ability).order(created_at: :desc) }
    content do
      h1 Order.model_name.human(count: 2)
      concat render_sub_comp(:list, @data)
    end
  end
end
  • accessible_by(current_ability) is the CanCanCan scoping idiom — pair it with the authorize block so list and access rules agree.
  • concat is mandatory around render_sub_comp/render_intent (gotchas.md #2).

4. List customization

This pattern is typically combined with a customized BaseComponents::List that adds styling and features to the pre-built list component.

class Components::Orders::List < BaseComponents::List
  setup do
    columns :number, :customer, as_title: true   # as_title -> card title on mobile
    columns :total, :created_at
    column :status do |order|                     # computed/custom cell
      span order.status.label, class: "badge bg-#{order.status.key}"
    end
    filters :number, :status
    sorts :number, :created_at
    default_sorting 'created_at desc'
  end
end

Embedding a child list inside a Show, dropping the redundant FK column and preserving the active tab across filter submits:

concat render_sub_comp(:list, @data.line_items, skip_columns: [:order],
                                                 params_in_filter: [param_name('tab')])

skip_* options (skip_pagination:, skip_filtering:, skip_columns:, …) are constructor kwargs passed through render_sub_comp, useful for read-only embeds.

5. Custom form + Schemacop, kept in sync

form_fields (rendering) and schema_* (param whitelist) must mirror each other.

class Components::Orders::Form < Compony::Components::Form
  setup do
    form_fields do
      concat field(:number)
      concat field(:customer, as: :tom_select)        # association name, not _id
      concat field(:placed_at, as: :flatpickr_datetime)
      concat pw_field(:access_code)
      concat field(:internal_ref, hidden: true)        # submitted, not shown
      div class: 'row' do                               # arbitrary Dyny layout
        div field(:first_name), class: 'col'
        div field(:last_name),  class: 'col'
      end
    end

    schema_fields :number, :customer, :placed_at, :internal_ref
    schema_pw_field :access_code
  end
end
  • as: :tom_select / as: :flatpickr_date(time) are app-registered simple_form inputs (TomSelect, Flatpickr) — a good choice for selects and date pickers.
  • Use the association name in field/schema_field; _id is added automatically (gotchas.md #4).
  • Nested attributes: f.simple_fields_for(:line_items) in form_fields plus a raw schema_line { ary? :line_items_attributes do ... end }.
  • Multilang fields: field(:title, multilang: true).each { |i| concat i } paired with schema_field :title, multilang: true.

Wire a non-default form into New/Edit with form_comp_class:

class Components::Orders::QuickAdd < Compony::Components::New
  setup { form_comp_class Components::Orders::QuickAddForm }
end

6. Autocomplete form (app-level subclass)

Compony does not ship autocomplete, but a very common app pattern is an AutocompleteForm base (subclass of Compony::Components::Form) exposing an extra standalone JSON endpoint for an ajax select. Shape:

class BaseComponents::AutocompleteForm < Compony::Components::Form
  # class-level `autocomplete(field) { |query, ability| ...collection... }` that
  # registers an extra `standalone :autocomplete_<field>` returning
  # [{ text:, value:, icon: }] JSON, consumed by a TomSelect Stimulus controller.
end

class Components::Orders::Form < BaseComponents::AutocompleteForm
  setup do
    form_fields { concat field(:customer, as: :tom_select) }
    schema_field :customer
    autocomplete(:customer) { |q, ability| Customer.accessible_by(ability).search(q) }
  end
end

If you need autocomplete, build this base once and reuse it.

7. Tabbed Show via a mixin

Detail pages are split into tabs with a small app mixin that adds a tab DSL and renders a tab bar into :main. Each tab body typically renders content :data or a nested list.

class Components::Orders::Show < BaseComponents::Show
  include ComponentMixins::Tabs

  setup do
    tab(:overview, _('Overview')) { content :data }
    tab(:items,    _('Items'))    { concat render_sub_comp(:list, @data.line_items,
                                                           skip_columns: [:order]) }
  end
end

The mixin keys the active tab off a prefixed param (param_name('tab')) so multiple tabbed components can coexist. Compony has no built-in tabs — copy the mixin per app.

8. Lifecycle hooks for derived data

  • after_assign_attributes — fill defaults / context after params are assigned, before validation: @data.account_id ||= current_user.account_id.
  • before_render — verb-independent guards and precomputation. Redirect and the content chain is skipped: ruby before_render do redirect_to Compony.path(:show, @data) if @data.locked? end
  • load_data — narrow the scope (accessible_by, includes, ordering).
  • store_data — override persistence (virtual models, file handling, bulk import).
  • on_{created,updated,destroyed}_redirect_path — control where success lands, e.g. Compony.path(:show, @data.parent) for owned records.

9. Exposed intents as the action toolbar

Concrete components tailor the header toolbar by add/remove on inherited intents.

exposed_intents do
  remove :destroy
  add :show, @data, label: 'PDF', name: :pdf, path: { format: :pdf },
      feasibility_action: :pdf
  add :archive, @data, method: :patch, before: :destroy
end
  • path: { format: :pdf } points a button at a format endpoint (see pattern 10).
  • feasibility_action: ties the button's enabled state to a model prevent (feasibility.md).
  • State-dependent toolbars (archived vs active) are done by branching inside the exposed_intents block on @data.
  • Generating one intent per enum value is common: Period.all.each { |p| add :new, :prices, name: :"new_#{p.key}", path: { price: { period: p.key } } }.

10. CSV / PDF via respond :format

A format export is the same component with an extra respond branch and an exposed intent pointing at it. Because overriding respond skips the default authorize, re-check there (gotchas.md #3).

standalone path: 'orders' do
  verb :get do
    authorize { can?(:read, Order) }
    respond :csv do
      can?(:read, Order) or raise CanCan::AccessDenied
      send_data(OrderCsv.new(@data).to_csv, filename: 'orders.csv', type: 'text/csv')
    end
    respond :pdf do
      can?(:read, @data) or raise CanCan::AccessDenied
      send_data(OrderPdf.new(@data).render, filename: @data.pdf_name,
                type: 'application/pdf')
    end
  end
end
# exposed_intents { add :index, :orders, label: 'CSV', path: { format: :csv } }

11. Non-CRUD: job dispatch, toggles, clone

Job dispatch — POST-only custom component, enqueue, flash, redirect:

class Components::Orders::ScheduleSync < Compony::Component
  setup do
    standalone path: 'orders/schedule_sync' do
      verb :post do
        authorize { can?(:create, Order) }
        respond do
          SyncOrdersJob.perform_later
          flash.notice = _('Queued — give it a few minutes.')
          redirect_to Compony.path(:index, :orders)
        end
      end
    end
    label(:all) { _('Sync now') }
    button(:icon) { :rotate }
  end
end

Expose it from Index: exposed_intents { add :schedule_sync, :orders, method: :post }.

State toggle — inherit Edit, flip in after_assign_attributes, dynamic label:

class Components::Accounts::ToggleActive < Compony::Components::Edit
  setup do
    standalone path: 'accounts/:id/toggle_active' do
      verb :patch do authorize { can?(:toggle_active, @data) } end
    end
    label(:long) { |a| a.active? ? _('Deactivate') : _('Activate') }
    after_assign_attributes { @data.active = !@data.active }
  end
end

Clone — inherit New, load + dup the source in load_data, redirect to the copy:

class Components::Orders::Clone < Compony::Components::New
  setup do
    standalone path: 'orders/:id/clone'
    load_data do
      source = Order.find(params[:id])
      authorize!(:read, source)          # CanCanCan bang form
      @data = source.dup
    end
    on_created_redirect_path { Compony.path(:show, @data) }
  end
end

12. Virtual model for non-persistent / upload forms

Inherit New, back it with a Compony::VirtualModel, take over the response. @data.save is a no-op so business logic goes in on_created_respond (or store_data).

class Components::Documents::Import < Compony::Components::New
  class VirtualModel < Compony::VirtualModel
    attribute :id, :bigint
    belongs_to :account
    has_one_attached :file
    field :account, :association
    field :file,    :attachment
    validates :file, presence: true
  end

  setup do
    standalone path: 'documents/import'
    data_class VirtualModel
    form_comp_class Components::Documents::ImportForm

    # ActiveStorage on a virtual model: validate only, read the tempfile yourself.
    store_data do
      @create_succeeded = @data.validate
      next unless @create_succeeded
      tempfile = params.dig(:documents_virtual_model, :file)&.tempfile
      DocumentImporter.call(account: @data., io: tempfile)
    end

    on_created_respond do
      flash.notice = _('Imported.')
      redirect_to Compony.path(:index, :documents)
    end
  end
end

See virtual_models.md and gotchas.md #12.

13. Public endpoints & webhooks

class Components::Public::Webhook < Compony::Component
  setup do
    standalone path: '/webhooks/orders' do
      skip_authentication!
      skip_forgery_protection!
      verb :post do
        authorize { true }                       # still mandatory
        respond do
          expected = "Bearer #{ENV.fetch('WEBHOOK_TOKEN')}"
          got      = request.headers['Authorization'].to_s
          unless ActiveSupport::SecurityUtils.secure_compare(got, expected)
            sleep 1                               # crude timing equalization
            next controller.head(:unauthorized)
          end
          OrderWebhook.process!(request.params)
          controller.head :accepted
        end
      end
    end
  end
end

A login-aware redirect splitter is the same shape with verb :get + before_render choosing a Compony.path by current_user.

14. Custom button style

Register one app button style and refer to it everywhere via style:.

class Components::Commons::BootstrapButton < Compony::Components::Buttons::Link
  protected
  def prepare_opts!
    super
    classes = (@comp_opts[:class] || '').split
    classes << 'btn' << "btn-#{@comp_opts[:color] || :primary}"
    @comp_opts[:class] = classes.join(' ')
  end
end
# config/initializers/compony.rb
# Compony.register_button_style :bootstrap, '::Components::Commons::BootstrapButton'
# Compony.default_button_style = :bootstrap

Make a separate style per visual kind (dropdown item, pill, compact) and select with render_intent(:show, @data, style: :compact).

15. Inline-edit card with a Turbo Frame

A Show panel where the Edit form swaps in place (no full-page nav) and swaps back on save. Wrap both the Show content and the Edit form in a same-named turbo_frame_tag; Turbo Drive then scopes navigation to that frame. Distinct from the render_sub_comp(:list, …, turbo_frame:) use in nesting.md (there the frame isolates a nested list's own search/filter params; here it is the inline-edit boundary for one record's Show/Edit pair).

# One frame name shared by the Show panel and the Edit form.
def card_frame(record) = :"#{record.model_name.singular}_#{record.id}_card"

class Components::Accounts::Show < Compony::Components::Show
  setup do
    content :data do
      turbo_frame_tag card_frame(@data) do      # Dyny: Rails view helper
        # …render fields…
        concat render_intent(:edit, @data, label: { format: :short })
      end
    end
  end
end

class Components::Accounts::Edit < Compony::Components::Edit
  setup do
    content do
      turbo_frame_tag card_frame(@data) do      # same frame name
        concat form_comp.render(controller, data: @data)
      end
    end
    # Default on_updated_redirect_path → Show; Turbo replaces just the frame.
  end
end
  • Frame name must match exactly; deriving it from the record id keeps it unique when several cards render on one page.
  • A failed save re-renders Edit with HTTP 422 — keep the turbo_frame_tag wrapper in the Edit content so errors render in-frame too.

16. Multi-step wizard across components

A create/edit flow split over several steps, each its own component, advancing on save. Chain steps with on_updated_redirect_path (or on_created_redirect_path) and render a step indicator via a shared mixin (same mechanism as the tabs mixin in §7).

module OrderWizard
  extend ActiveSupport::Concern
  STEPS = %i[details_edit shipping_edit confirm_edit].freeze

  included do
    setup do
      content :wizard_nav, before: :main do
        ol class: 'wizard' do
          OrderWizard::STEPS.each do |step|
            li step.to_s.delete_suffix('_edit'),
               class: (component.comp_name.to_sym == step ? 'active' : nil)
          end
        end
      end
    end
  end
end

class Components::Orders::DetailsEdit < Compony::Components::Edit
  include OrderWizard
  setup do
    standalone path: 'orders/:id/details'
    on_updated_redirect_path { Compony.path(:shipping_edit, @data) }   # → next step
  end
end

class Components::Orders::ShippingEdit < Compony::Components::Edit
  include OrderWizard
  setup do
    standalone path: 'orders/:id/shipping'
    on_updated_redirect_path { Compony.path(:confirm_edit, @data) }
  end
end
# …ConfirmEdit redirects to Show when done.
  • Each step is a normal resourceful component on the same model — partial validation per step is just per-step schema_fields in each step's Form.
  • For a non-persistent wizard (nothing saved until the end), back the components with a VirtualModel and carry state in its attributes (§12).
  • comp_name drives the active-step highlight, so the mixin needs no per-step config.

17. Inline PATCH without a form (reorder / quick toggle)

A JS front-end (drag-to-sort, an inline checkbox) issues a small PATCH that mutates state and returns no body. Add a named extra standalone with verb :patch, validate with Schemacop directly, and head :ok. No Form component involved.

class Components::Orders::Show < Compony::Components::Show
  setup do
    # Main route inherited from Show. Companion endpoint for reordering line items:
    standalone :reorder, path: 'orders/:id/reorder' do
      verb :patch do
        authorize { can?(:update, @data) }
        respond do                       # overriding respond skips default authorize…
          can?(:update, @data) or raise CanCan::AccessDenied   # …so re-check here
          params = Schemacop::Schema3.new(:hash) do
            ary! :ordered_ids do
              list :integer
            end
          end.validate!(controller.request.params)
          @data.line_items.reorder_by!(params[:ordered_ids])
          controller.head :ok
        end
      end
    end
  end
end

The route is reorder_show_orders_comp (see standalone naming); point your Stimulus controller's PATCH at Compony.path(:show, @data, standalone_name: :reorder).

  • This is the gotchas.md #3 case: the custom respond replaces the default that runs authorize, so authorize again inside it.
  • Keep companion endpoints in the same component as the screen they serve — what extra named standalones are for (standalone.md), not a reason for a new component.
  • Return head :ok (or small JSON) — no Compony content to render for an ajax-only verb.

Goal: an emailed link that lets an unauthenticated visitor perform one bounded action — invite acceptance, magic login, password reset, email confirmation — without a session. The trick: override Compony's path do … end to mint a signed JWT and carry it as a token query param, then gate a skip_authentication! standalone with authorize { token_valid?(params) }. A small mixin centralizes encode/decode.

Security — read before copying. Such a link is the capability; anyone holding the URL can perform the action. It is only safe if every one of these holds:

  • Expiry is mandatory. Put exp in the payload and verify it. A capability link without a TTL is a permanent account-takeover primitive (it leaks via referrer headers, proxy logs, mail forwarding, browser history). Pair short TTLs with a resend flow.
  • Pin the algorithm and verify the signatureJWT.decode(token, secret, true, { algorithm: 'HS512' }). Never accept alg: none; never leave verification off.
  • Fail closed. Rescue JWT::DecodeError (its subclasses cover bad signature, malformed token and expiry) and return nil/false so authorize denies with 403 — not a 500.
  • Use a dedicated signing secret, not secret_key_base, so rotating it doesn't also invalidate every session (and vice-versa).
  • Still provide an authorize block: skip_authentication! removes authentication, not authorization (gotchas.md #14).
# app/component_mixins/with_token.rb
module WithToken
  extend ActiveSupport::Concern
  TOKEN_TTL = 14.days

  def encode_token(payload)
    JWT.encode(payload.merge(exp: TOKEN_TTL.from_now.to_i), token_secret, 'HS512')
  end

  # Memoized; returns the payload (indifferent access) or nil. Fails closed.
  def token_data(params = nil)
    return @token_data if @token_data
    return nil if params.blank?
    @token = params[:token]
    return nil if @token.blank?
    @token_data = JWT.decode(@token, token_secret, true, { algorithm: 'HS512' })
                     .first.with_indifferent_access
  rescue JWT::DecodeError   # bad sig / malformed / expired — all subclasses
    nil
  end

  def token_secret
    Rails.application.credentials.capability_token_secret.presence ||
      Rails.application.credentials.secret_key_base   # fallback until set
  end
end

Override path so links self-mint a token (callers pass the subject, not the token):

class Components::Invites::Accept < Compony::Components::New
  include WithToken

  class VirtualModel < Compony::VirtualModel
    attribute :password, :string
    attribute :account_id          # carried for validation only
    field :password, :string
    def label = 'Invite'
  end

  setup do
    # Building a path to this component mints the token from the given account.
    path do |*args, account: nil, token: nil, **kwargs|
      if token.blank?
        fail('Missing kwarg :account in path') if .nil?
        token = encode_token(account_id: .id)
      end
      next Rails.application.routes.url_helpers
              .send("#{path_helper_name}_path", *args, token:, **kwargs)
    end

    standalone path: '/invites/accept' do
      skip_authentication!
      verb :get  do authorize { token_valid?(params) } end
      verb :post do authorize { token_valid?(params) } end
    end

    data_class VirtualModel
    form_cancancan_action nil
    submit_path { Compony.path(self.class, @data, token: @token) }
    after_assign_attributes { @data. = token_data(params)[:account_id] }

    store_data do
      @create_succeeded = @data.validate
      next unless @create_succeeded
      Account.find(token_data[:account_id]).update!(password: @data.password)
    end
    on_created_respond { redirect_to Compony.path(:show, :sessions) }
  end

  # Shape-check the decoded payload; anything off → false → 403 (never 500).
  def token_valid?(params)
    data = token_data(params)
    return false if data.blank?
    Schemacop::Schema3.new(:hash) do
      int! :account_id, cast_str: true
      int? :exp
    end.validate!(data)
    true
  rescue Schemacop::Exceptions::ValidationError
    false                                # token signed for a different flow
  end
end

Notes:

  • Compony.path(:accept, :invites, account: some_account) returns the full tokenized URL — email that. The token, not a session, authorizes the request.
  • path do runs outside the request context; build URLs via Rails.application.routes.url_helpers, not controller/helpers (see standalone.md).
  • Reuse the mixin for every link flow (magic login, password reset, email confirm); encode a flow discriminator or rely on the per-component payload shape-check to stop a token minted for one flow being replayed against another.
  • One signed boolean in the payload (e.g. confirmed: true) is tamper-proof since the client cannot re-sign — handy for multi-hop confirm flows.

Good habits

  • CanCanCan everywhere: authorize { can?(...) }, scope with Model.accessible_by(current_ability), bang form authorize!(:read, record) for ad-hoc checks in load_data.
  • Always Compony.path / render_intent, never hardcoded routes or button_to (gotchas.md #11, #15).
  • Place a resourceful component in the family of the model it acts on, not the family it is reached from; pass parent context via path params.
  • Keep virtual/form-only fields off models — use ActiveType/VirtualModel (gotchas.md #16).
  • concat around every render_intent/render_sub_comp/field in a block.

Guide index