Events

Phoenix Liveview supports DOM element bindings for client-server interaction. This allows developers to create server-side components that can react to client-side events triggered by the user.

For a complete guide on Bindings, see the Phoenix's Bindings Guide.

Handling events in LiveView (without Surface)

In Phoenix LiveView, when dispatching events in live components, the default target is the parent live view, not the component itself. If you need to handle events locally, you usually need to:

  • Set the phx-[event] attributes on the elements which events need to be listened to.

  • Set the phx-target attribute on those same elements to indicate that you want to handle them locally.

This can be non-intuitive, especially if you're coming from any existing component-based library like React or Vue.js.

Note: The main reason behind this design choice, as explained by José Valim in this discussion, is that, when using Phoenix templates, it's impossible to know what is the parent and what is the child. There's no way to retrieve that information since templates are not treated as structured code, they are just text.

Handling events in Surface

Instead of treating templates as plain text, Surface parses the code identifying its structure (the hierarchy of components) and uses that information to restore the initially desired behaviour of handling events in LiveView. Bear in mind that in order to keep the behaviour consistent and predictable across multiple components, you should:

  • always use the :on-[event] directive in HTML tags.
  • always declare event properties in components using the type :event

Note: You can still use Phoenix's built-in phx-[event] directly in HTML tags if you want, however, if you need to pass that event as a property before, you should declare that property as :string instead of :event.

Using the :on-[event] directive

The :on-[event] directive can configure a server event binding by automatically generating the phx-[event] and phx-target attributes in the HTML tag, defining the component itself as the default handler (target). This is the preferred way to use phx events in Surface as it can properly handle properties of type :event.

Available directives are: :on-click, :on-capture-click, :on-blur, :on-focus, :on-change, :on-submit, :on-keydown, :on-keyup, :on-window-focus, :on-window-blur, :on-window-keydown and :on-window-keyup.

0

defmodule Counter do
  use Surface.LiveComponent

  data count, :integer, default: 0

  def render(assigns) do
    ~F"""
    <div>
      <h1 class="title">
        {@count}
      </h1>
      <div>
        <button class="button is-info" :on-click="dec">-</button>
        <button class="button is-info" :on-click="inc">+</button>
        <button class="button is-danger" :on-click="reset">Reset</button>
      </div>
    </div>
    """
  end

  # Event handlers

  def handle_event("inc", _, socket) do
    {:noreply, update(socket, :count, &(&1 + 1))}
  end

  def handle_event("dec", _, socket) do
    {:noreply, update(socket, :count, &(&1 - 1))}
  end

  def handle_event("reset", _, socket) do
    {:noreply, assign(socket, :count, 0)}
  end
end

As you can see, we didn't have to define phx-target for any of the buttons. Sweet!

IMPORTANT: Pay attention that :on-[event] directives can only be used in HTML tags, not components. The reason is because, unlike a tag, a component may render more than one DOM element so it's up to the component's author to define the component's public API, including its exposed events, and properly forward those events to the related HTML elements they belong.

The complete list of available events, as well as other types of bindings, can be found in the Bindings section of the docs for Phoenix LiveView.

Passing events through :event properties

Another great thing about Surface's approach is that it makes passing events as properties also more intuitive. Using phoenix templates, unless you always pass both, the event and the target, you cannot be sure where the event will be handled. You need to know upfront if there's a phx-target defined for that DOM element inside that component. Using Surface, the event is always passed along with the related target, assuming, by default, that the target is the caller component/view.

In the above examples the events have been handled by the component itself. Sometimes the parent component needs to handle the event. For that kind of use case, you must declare the event in the child component by using the prop macro defining the type as :event and pass its value to the underlying HTML tag using the :on-[event] directive.

See the properties Event section for more details about event properties.

Stateless component

The simplest case you need to pass an event is when you create a stateless component that includes an element that defines a server binding (event). Since the component is stateless, it cannot handle the event by itself so it needs to receive the event handler as a property.

For example, imagine a Button stateless component that triggers an event when the user clicks on it. In the following example, we create that stateless component.

defmodule Button do
  use Surface.Component

  prop label, :string
  prop click, :event, required: true
  prop kind, :string, default: "is-info"

  slot default

  def render(assigns) do
    ~F"""
    <button type="button" class={"button", @kind} :on-click={@click}>
      <#slot>{@label}</#slot>
    </button>
    """
  end
end

We declared a required click event property that we use on the <button> tag with the :on-click directive.

Now let's see how to define and pass events to that stateless component. We will use the Button component twice, each with a different handling function that has been defined in a parent live component.

Clicked 0 time(s)

defmodule Example do
  use Surface.LiveComponent

  data count, :integer, default: 0

  def render(assigns) do
    ~F"""
    <div>
      <p>Clicked <strong>{@count}</strong> time(s)</p>
      <Button label="Click!" click="clicked" />
      <Button label="Reset" kind="is-danger" click="reset" />
    </div>
    """
  end

  def handle_event("clicked", _, socket) do
    {:noreply, update(socket, :count, &(&1 + 1))}
  end

  def handle_event("reset", _, socket) do
    {:noreply, assign(socket, :count, 0)}
  end
end

Remember that stateless components cannot handle events and do not have state. Events can only be handled in a LiveView or LiveComponent so we will store the state in that kind of component.

Stateful component

In some cases, you may want to have a default behaviour that is handled by the component itself and let the developer override the default implementation with a custom one. To implement a default behaviour, the component must implement an handle_event/3 function, and so it must to be stateful.

One example is a generic stateful Dialog component. By default, if the user clicks "Close", the dialog is hidden. However, if you're using the dialog to show a form where the user must fill in lots of information, you may want to ask for confirmation before closing it. Something like: "Are you sure you want to close this form? All information provided will be lost.".

To implement such feature, you need to provide a default local implementation that closes the dialog, along with a way to override this implementation if the parent component passes its own custom logic. In our case, we want to ask for confirmation before closing it.

First let's take a look at our generic <Dialog> component and its events.

defmodule Dialog do
  use Surface.LiveComponent

  alias SurfaceSiteWeb.Events.LiveButton.Button

  prop title, :string, required: true
  prop ok_label, :string, default: "Ok"
  prop close_label, :string, default: "Close"
  prop ok_click, :event, default: "close"
  prop close_click, :event, default: "close"

  data show, :boolean, default: false

  slot default

  def render(assigns) do
    ~F"""
    <div class={"modal", "is-active": @show} :on-window-keydown={@close_click} phx-key="Escape">
      <div class="modal-background" />
      <div class="modal-card">
        <header class="modal-card-head">
          <p class="modal-card-title">{@title}</p>
        </header>
        <section class="modal-card-body">
          <#slot />
        </section>
        <footer class="modal-card-foot" style="justify-content: flex-end">
          <Button click={@ok_click}>{@ok_label}</Button>
          <Button click={@close_click} kind="is-danger">{@close_label}</Button>
        </footer>
      </div>
    </div>
    """
  end

  # Public API

  def open(dialog_id) do
    send_update(__MODULE__, id: dialog_id, show: true)
  end

  def close(dialog_id) do
    send_update(__MODULE__, id: dialog_id, show: false)
  end

  # Default event handlers

  def handle_event("close", _, socket) do
    {:noreply, assign(socket, show: false)}
  end
end

The component implements a default handle_event that handles the close event and provides two public functions that can be used by other components to open and close the modal.

Note: We're using send_update/2 to set the value of the :show data assign. We'll get into more details about send_update/2 in the State management page.

Also notice that the stateful Dialog component reuses the stateless Button component defined at the beginning of this section and the events defined are passed along to these buttons components.

Now, we can use the dialog component with his default behaviour.

defmodule ExampleWithDefaultBehaviour do
  use Surface.LiveComponent

  alias SurfaceSiteWeb.Events.LiveButton.Button
  alias Surface.Components.Form
  alias Surface.Components.Form.{TextInput, Label, Field}

  def render(assigns) do
    ~F"""
    <div>
      <Dialog title="User form" id="form_dialog_1">
        <Form for={%{}} as={:user}>
          <Field name="name"><Label /><TextInput /></Field>
          <Field name="email"><Label /><TextInput /></Field>
        </Form>
        Clicking <strong>"Ok"</strong> or <strong>"Close"</strong>
        closes the form (default behaviour).
      </Dialog>

      <Button click="open_form">Click to open the dialog!</Button>
    </div>
    """
  end

  def handle_event("open_form", _, socket) do
    Dialog.open("form_dialog_1")
    {:noreply, socket}
  end
end

If you want to change the default behaviour of closing the dialog automatically, all you have to do is pass that custom event using the close prop. Remember, we want to ask for confirmation to close the modal.

defmodule ExampleWithOverwrittenBehaviour do
  use Surface.LiveComponent

  alias SurfaceSiteWeb.Events.LiveButton.Button
  alias Surface.Components.Form
  alias Surface.Components.Form.{TextInput, Label, Field}

  def render(assigns) do
    ~F"""
    <div>
      <Dialog title="User form" close_click="open_confirmation_dialog" id="form_dialog_2">
        <Form for={%{}} as={:user2}>
          <Field name="name"><Label /><TextInput /></Field>
          <Field name="email"><Label /><TextInput /></Field>
        </Form>
        Now, clicking <strong>"Close"</strong> shows a confirmation dialog
        instead of closing the form.
      </Dialog>

      <Dialog
        id="confirmation_dialog"
        title="Alert"
        ok_label="Yes"
        close_label="No"
        ok_click="confirm_close_form"
        close_click="close_confirmation_dialog"
      >
        Are you sure you want to close this form?<br><br>
        <strong>Note:</strong> All information provided will be lost.
      </Dialog>

      <Button click="open_form">Click to open the dialog!</Button>
    </div>
    """
  end

  def handle_event("open_form", _, socket) do
    Dialog.open("form_dialog_2")
    {:noreply, socket}
  end

  def handle_event("open_confirmation_dialog", _, socket) do
    Dialog.open("confirmation_dialog")
    {:noreply, socket}
  end

  def handle_event("close_confirmation_dialog", _, socket) do
    Dialog.close("confirmation_dialog")
    {:noreply, socket}
  end

  def handle_event("confirm_close_form", _, socket) do
    Dialog.close("confirmation_dialog")
    Dialog.close("form_dialog_2")
    {:noreply, socket}
  end
end

Choosing another target

As explained, by default, Surface always passes the event name along with the default target (the caller component/view). This should cover most of the cases you have to face when working with events.

In case you still need the event to be handled by any other component/view, you can explicitly pass the target using the target option.

Examples

Using :on-click in HTML tags:

<button :on-click={"click", target: "#target_id"}>
  OK
</button>

Passing a prop of type :event to a component:

<Button click={"click", target: "#target_id"}>
  OK
</Button>

If you want the target to be the parent LiveView, you can set the target option as :live_view.

<button :on-click={"click", target: :live_view}>
  OK
</button>