Examples

File uploads & custom types

Accept a file in a reactive action — attach a document, receipt, or image to a record without dropping out to a bespoke controller — and register your own coerced param type. A code-first walkthrough; there is no live upload demo (a public upload endpoint is a storage/abuse surface).

A component that accepts a file#

Declare :file (or [:file] for multiple) in the action's param schema. When the reactive root holds a populated <input type="file">, the client sends the action as multipart FormData instead of JSON; the endpoint coerces the declared :file param to the ActionDispatch::Http::UploadedFile and passes it through untouched. The action attaches it — the rest of the reactive flow (token threading, re-render/morph) is identical.

class DocumentUploadComponent < ApplicationComponent
  include Phlex::Reactive::Streamable
  include Phlex::Reactive::Component

  reactive_record :document

  # Single-file path (has_one_attached). A caption rides alongside as a
  # scalar field to prove multipart carries scalar params + the file.
  action :upload, params: { file: :file, caption: :string }
  # Multiple-file path (has_many_attached): [:file] coerces an array.
  action :upload_pages, params: { pages: [:file] }

  def initialize(document:) = @document = document

  def id = dom_id(@document)

  def upload(file: nil, caption: nil)
    @document.file.attach(file) if file
    @document.update!(title: caption) if caption.present?
  end

  def upload_pages(pages: nil)
    @document.pages.attach(pages) if pages.present?
  end

  def view_template
    div(**mix(reactive_attrs, id:)) do
      span { @document.title }
      form(**on(:upload, event: "submit")) do
        input(type: "file", name: "file")
        input(name: "caption")
        button(type: "submit") { "Upload" }
      end
    end
  end
end

How the multipart path works#

Only the request encoding changes when a file is present — the action never sees the difference:

  • When a populated <input type="file"> is in the root, the client switches from a JSON body to multipart FormData.
  • token and act ride as fields; scalar params (caption) ride as fields; the file(s) are appended.
  • The endpoint coerces :file to the uploaded file, passed through untouched. A non-file value sent to a :file param is dropped (the keyword default applies — never a fabricated file), the same drop-don't-fabricate contract as every built-in type.
  • [:file] coerces an array of uploads for a has_many_attached target.
action :upload,       params: { file: :file, caption: :string }
action :upload_pages, params: { pages: [:file] }

A file alongside nested params (#39)#

A :file param can sit next to an explicit nested-hash param. On the multipart path, nested and array params are bracket-expanded into params[key][sub] / params[key][index] fields — the same Rails-form shape a JSON body produces — so a JSON body and a multipart body coerce identically. The nested meta used to be dropped on the multipart path (issue #39); it now survives next to the file:

action :upload_with_meta,
  params: { file: :file, meta: { tag: :string, year: :integer } }

def upload_with_meta(file: nil, meta: nil)
  @document.file.attach(file) if file
  @document.update!(title: "#{meta[:tag]} #{meta[:year]}") if meta
end

The schema mirrors the field names, not the conceptual params — a flat { tag: :string } against meta[tag] inputs would match nothing and drop silently. When in doubt, read a field's real name attribute and shape the schema to it.

Registering a custom param type (:money)#

File params are one built-in type; you can register your own. In an initializer, Phlex::Reactive.param_type takes a block that receives the raw client value and returns the coerced value — or Phlex::Reactive::ParamSchema::DROP to reject it, so the keyword default applies (the same drop-don't-fabricate contract):

# config/initializers/phlex_reactive.rb
Phlex::Reactive.param_type(:money) do |value|
  if /\A\d+(\.\d{1,2})?\z/.match?(value.to_s)
    BigDecimal(value)
  else
    Phlex::Reactive::ParamSchema::DROP
  end
end

# then, in any component:
action :charge, params: { amount: :money }
def charge(amount:) = @invoice.charge!(amount)   # a BigDecimal, or unset
Register during boot only

The type registry is frozen after initialization, so a runtime param_type call raises. A schema that references a registered type is validated at declaration like any built-in — a typo raises Phlex::Reactive::UnknownParamType at class load, not at click time.

What this example proves#

  • :file and [:file] accept single and multiple uploads in a reactive action, coerced to ActionDispatch::Http::UploadedFile.
  • Scalar params (caption) ride alongside the file through multipart.
  • A nested-hash param (meta) survives next to the file (#39).
  • A custom param_type(:money) coerces or drops, and is validated at declaration.

For the reference detail, see Actions & events (the on(...) API and the full param-types table) and the file-params note in Security.