JavaScript Hooks

View Source

Sutra UI uses colocated hooks, a Phoenix 1.8+ feature that allows JavaScript hooks to be defined alongside their components. No separate hooks.js file needed.

What Are Colocated Hooks?

Colocated hooks are JavaScript hooks defined directly within component files using a special <script> tag:

def my_component(assigns) do
  ~H"""
  <div id={@id} phx-hook=".MyHook">
    Content here
  </div>

  <script :type={Phoenix.LiveView.ColocatedHook} name=".MyHook">
    export default {
      mounted() {
        console.log("Hook mounted!", this.el)
      }
    }
  </script>
  """
end

Phoenix 1.8+ Required

Colocated hooks require Phoenix 1.8 or later. The hooks are extracted at compile time and bundled automatically.

How Sutra UI Uses Colocated Hooks

Several Sutra UI components use colocated hooks for interactivity:

ComponentHookPurpose
dialog.DialogShow/hide modal, backdrop click
tabs.TabsKeyboard navigation
select.SelectDropdown behavior, search
dropdown_menu.DropdownMenuMenu positioning, keyboard nav
command.CommandCommand palette behavior
toast.ToastAuto-dismiss, animations
accordion.AccordionCollapse animations
slider.SliderRange input behavior
range_slider.RangeSliderDual-handle slider
live_select.LiveSelectAsync search, tags
carousel.CarouselScroll snap, navigation
theme_switcher.ThemeSwitcherTheme persistence

The Hook Name Convention

Hook names in Sutra UI start with a dot (e.g., .Dialog, .Tabs). This is required by Phoenix's colocated hook system.

<!-- The phx-hook value matches the script name -->
<dialog id="my-dialog" phx-hook=".Dialog">

The full hook name becomes ModuleName.HookName (e.g., SutraUI.Dialog.Dialog), but you only reference the short name with the dot prefix.

Custom Events

Sutra UI hooks dispatch custom events using the sutra-ui: namespace:

// Dispatching an event
this.el.dispatchEvent(new CustomEvent('sutra-ui:select-change', {
  detail: { value: selectedValue }
}))

Listening to Events

Listen for Sutra UI events in your LiveView:

def handle_event("sutra-ui:select-change", %{"value" => value}, socket) do
  {:noreply, assign(socket, selected: value)}
end

Or in JavaScript:

document.addEventListener('sutra-ui:select-change', (e) => {
  console.log('Selected:', e.detail.value)
})

Event Reference

EventComponentDetail
sutra-ui:select-changeSelect{ value }
sutra-ui:dialog-openDialog{ id }
sutra-ui:dialog-closeDialog{ id }
sutra-ui:tab-changeTabs{ value }
sutra-ui:toast-dismissToast{ id }
sutra-ui:slider-changeSlider{ value }
sutra-ui:range-changeRangeSlider{ min, max }

Using JS Commands with Hooks

Sutra UI provides helper functions that work with colocated hooks:

import SutraUI.Dialog

# Show a dialog
<.button phx-click={show_dialog("my-dialog")}>Open</.button>

# Hide a dialog
<.button phx-click={hide_dialog("my-dialog")}>Close</.button>

These helpers dispatch events that the hooks listen for:

def show_dialog(js \\ %JS{}, id) do
  JS.dispatch(js, "phx:show-dialog", to: "##{id}")
end

Extending Hooks

You can extend Sutra UI hooks in your application by creating your own colocated hooks that build on the component behavior.

Example: Custom Dialog with Analytics

defmodule MyAppWeb.Components.TrackedDialog do
  use Phoenix.Component
  alias Phoenix.LiveView.ColocatedHook

  import SutraUI.Dialog, only: [dialog: 1]

  def tracked_dialog(assigns) do
    ~H"""
    <div phx-hook=".TrackedDialog" data-dialog-id={@id}>
      <.dialog id={@id}>
        <%= render_slot(@inner_block) %>
      </.dialog>
    </div>

    <script :type={ColocatedHook} name=".TrackedDialog">
      export default {
        mounted() {
          const dialogId = this.el.dataset.dialogId
          const dialog = document.getElementById(dialogId)
          
          dialog.addEventListener('phx:show-dialog', () => {
            // Track dialog open
            analytics.track('dialog_opened', { id: dialogId })
          })
        }
      }
    </script>
    """
  end
end

Build Considerations

Compilation Order

Colocated hooks are extracted when the component is compiled. Ensure mix compile runs before asset bundling:

# mix.exs - custom release alias
defp aliases do
  [
    "assets.deploy": [
      "compile",  # Compile first to extract hooks
      "esbuild default --minify",
      "phx.digest"
    ]
  ]
end

Development Mode

In development, hooks are automatically extracted and hot-reloaded when you change component files.

Production Builds

The hooks are bundled into your JavaScript assets automatically. No additional configuration needed.

Troubleshooting

Hook not mounting

Symptoms: Component renders but interactions don't work.

Causes:

  1. Missing phx-hook attribute
  2. Wrong hook name (must start with .)
  3. Phoenix version < 1.8

Fix:

<!-- Ensure hook is specified -->
<div id="my-id" phx-hook=".HookName">

"Hook not found" errors

Symptoms: Console error about missing hook.

Cause: Component not compiled or hook name mismatch.

Fix:

mix compile --force

Events not firing

Symptoms: phx-click with JS commands doesn't trigger hook.

Cause: Event name mismatch between JS command and hook listener.

Fix: Verify the event names match:

# JS command dispatches:
JS.dispatch("phx:show-dialog", to: "#dialog")

# Hook listens for:
this.el.addEventListener("phx:show-dialog", ...)

Runtime Hooks (Advanced)

For special cases like LiveDashboard integration, you can use runtime hooks that aren't extracted at compile time:

<script :type={ColocatedHook} name=".RuntimeHook" runtime>
  {
    mounted() {
      // Note: no "export default" for runtime hooks
    }
  }
</script>

Runtime hooks have limitations:

  • No bundler processing (ES6+ features may not work in older browsers)
  • CSP considerations (may need nonce)

Next Steps