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.

```ruby
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.

```ruby
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:

```ruby
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):

```ruby
# 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](https://phlex-reactive.zoolutions.llc/docs/actions-events) (the `on(...)` API and the full param-types table) and the file-params note in [Security](https://phlex-reactive.zoolutions.llc/docs/security).