The pad saves experiments to the local filesystem, but so far it uses synchronous file I/O directly in update/2. In this chapter we add asynchronous commands for background work, platform effects for native dialogs, and multi-window support. By the end, the pad will have import/export via file dialogs, clipboard integration, and the ability to detach an experiment into its own window.

Async commands

Plushie.Command.async/2 runs a function in a separate process and delivers the result as an event:

alias Plushie.Command

def update(model, %WidgetEvent{type: :click, id: "fetch"}) do
  cmd = Command.async(fn ->
    # This runs in a Task, not in the runtime process
    {:ok, fetch_data_from_api()}
  end, :data_loaded)

  {%{model | status: :loading}, cmd}
end

The second argument, :data_loaded, is a tag that identifies this task. The result arrives as a Plushie.Event.AsyncEvent struct with the same tag:

alias Plushie.Event.AsyncEvent

def update(model, %AsyncEvent{tag: :data_loaded, result: {:ok, data}}) do
  %{model | status: :done, data: data}
end

def update(model, %AsyncEvent{tag: :data_loaded, result: {:error, reason}}) do
  %{model | status: :error, error: inspect(reason)}
end

A few things to know about async:

  • One task per tag. Starting a new async with the same tag kills the previous one. This prevents stale results from a superseded request.
  • Results are nonce-checked. If a task is killed and its result arrives late, the runtime discards it silently.
  • The function runs in a linked Task. Exceptions in the function become {:error, reason} results, where reason is the exception struct.

Platform effects

Effects are asynchronous requests to the renderer for platform operations: file dialogs, clipboard access, and notifications. Unlike async commands (which run Elixir code), effects are handled by the renderer binary.

File dialogs

Every effect takes an atom tag as its first argument. The tag identifies the effect in result matching, so there is no need to store request IDs in your model.

alias Plushie.Effect

def update(model, %WidgetEvent{type: :click, id: "import"}) do
  {model, Effect.file_open(:import, title: "Import Experiment", filters: [{"Elixir", "*.ex"}])}
end

The result arrives as a Plushie.Event.EffectEvent struct. Match on the tag:

alias Plushie.Event.EffectEvent

def update(model, %EffectEvent{tag: :import, result: {:ok, %{path: path}}}) do
  source = File.read!(path)
  # ... load the experiment
end

def update(model, %EffectEvent{tag: :import, result: :cancelled}) do
  model  # user closed the dialog
end

def update(model, %EffectEvent{tag: :import, result: {:error, reason}}) do
  %{model | error: inspect(reason)}
end

Available file dialogs:

  • Effect.file_open/2 - single file selection
  • Effect.file_open_multiple/2 - multiple file selection
  • Effect.file_save/2 - save dialog
  • Effect.directory_select/2 - directory selection
  • Effect.directory_select_multiple/2 - multiple directories

Clipboard

# Copy text to clipboard
{model, Effect.clipboard_write(:copy, model.source)}

# Read from clipboard
{model, Effect.clipboard_read(:paste)}
# Result: %EffectEvent{tag: :paste, result: {:ok, %{text: content}}}

Also available: clipboard_read_html/1, clipboard_write_html/3, clipboard_clear/1. On Linux, clipboard_read_primary/1 and clipboard_write_primary/2 access the middle-click selection buffer.

Notifications

{model, Effect.notification(:exported, "Exported", "Experiment saved to #{path}")}

Options include :icon, :timeout, and :urgency (:low, :normal, :critical).

Effect timeouts

Effects have default timeouts: 120 seconds for file dialogs (the user may browse for a while), 5 seconds for clipboard and notifications. If the renderer does not respond in time, you get {:error, :timeout}.

Applying it: import and export

Add import and export buttons to the pad toolbar:

row padding: {4, 8}, spacing: 8 do
  button("save", "Save", style: :primary)
  button("import", "Import")
  button("export", "Export")
  checkbox("auto-save", model.auto_save)
  text("auto-label", "Auto-save")
end

Handle the events. Each effect gets a distinct tag, so matching the result is straightforward:

def update(model, %WidgetEvent{type: :click, id: "import"}) do
  {model, Effect.file_open(:import, title: "Import Experiment")}
end

def update(model, %WidgetEvent{type: :click, id: "export"}) do
  {model, Effect.file_save(:export, title: "Export Experiment")}
end

def update(model, %EffectEvent{tag: :import, result: {:ok, %{path: path}}}) do
  source = File.read!(path)
  %{model | source: source}
end

def update(model, %EffectEvent{tag: :export, result: {:ok, %{path: path}}}) do
  File.write!(path, model.source)
  model
end

def update(model, %EffectEvent{tag: tag, result: :cancelled}) when tag in [:import, :export] do
  model
end

Applying it: copy code

Add a "Copy" button that copies the current experiment source to the clipboard:

def update(model, %WidgetEvent{type: :click, id: "copy"}) do
  {model, Effect.clipboard_write(:copy, model.source)}
end

Streaming and cancellation

For long-running work that produces intermediate results:

cmd = Command.stream(fn emit ->
  for {line, n} <- Enum.with_index(File.stream!("large.csv"), 1) do
    emit.(%{line: n, data: parse(line)})
  end
  :done
end, :csv_import)

Each emit.() call delivers a Plushie.Event.StreamEvent struct. The final return value arrives as a Plushie.Event.AsyncEvent struct, same as regular async.

To cancel a running async or stream:

{model, Command.cancel(:csv_import)}

For one-shot delayed events (not recurring like subscriptions):

{model, Command.send_after(3000, {:clear_status})}

See the Commands reference for details.

Batching

Combine multiple commands from a single update:

{model, Command.batch([
  Effect.clipboard_write(:copy, model.source),
  Effect.notification(:copied, "Copied", "Source copied to clipboard"),
  Command.focus("editor")
])}

Commands in a batch execute in order.

Multi-window

A Plushie app can have multiple windows. Return a list of window nodes from view/1:

def view(model) do
  windows = [
    window "main", title: "Plushie Pad" do
      # ... main pad UI
    end
  ]

  if model.detached do
    windows ++ [
      window "experiment", title: "Experiment: #{model.active_file}" do
        container "detached-preview", padding: 16 do
          model.preview
        end
      end
    ]
  else
    windows
  end
end

A few important things about multi-window:

Window IDs must be stable strings. If a window ID changes between renders, the renderer closes the old window and opens a new one. Use consistent, predictable IDs.

exit_on_close_request is a per-window prop that controls whether closing the window triggers app exit. There is no "primary window" concept -- each window independently decides its close behaviour:

window "main", title: "App", exit_on_close_request: true do
  # Closing this window exits the app
end

window "settings", title: "Settings", exit_on_close_request: false do
  # Closing this window just closes the window
end

close_requested vs closed: When the user clicks the window's close button, the renderer sends a :close_requested event, not a :closed event. Your app decides whether to actually close. If exit_on_close_request is true (the default), the runtime exits. Otherwise, the event arrives in update/2 and you handle it:

alias Plushie.Event.WindowEvent

def update(model, %WindowEvent{type: :close_requested, window_id: "experiment"}) do
  %{model | detached: false}
end

Daemon mode keeps the app running after the last window closes. Set :daemon in Plushie.start_link/2 options. When all windows close, you receive %SystemEvent{type: :all_windows_closed} in update/2.

Applying it: detach experiment

Add a "Detach" button that opens the experiment in its own window:

def update(model, %WidgetEvent{type: :click, id: "detach"}) do
  %{model | detached: true}
end

def update(model, %WindowEvent{type: :close_requested, window_id: "experiment"}) do
  %{model | detached: false}
end

The view conditionally adds a second window when model.detached is true. Closing the experiment window sets the flag back to false, and the experiment returns to the preview pane.

Error handling patterns

Always handle both success and failure for async and effects:

def update(model, %AsyncEvent{tag: :export, result: {:ok, _}}) do
  %{model | status: "Exported"}
end

def update(model, %AsyncEvent{tag: :export, result: {:error, reason}}) do
  %{model | error: "Export failed: #{inspect(reason)}"}
end

def update(model, %EffectEvent{tag: _tag, result: :cancelled}) do
  model  # user cancelled the dialog, not an error
end

The :cancelled result is distinct from {:error, reason}. A user cancelling a file dialog is expected behaviour, not a failure.

Verify it

Effect stubs let you control what the renderer returns for platform operations. Register a stub before triggering the effect, and the renderer responds with your stub instead of executing the real operation:

test "import loads experiment from file" do
  register_effect_stub("file_open", {:ok, %{path: "/tmp/test.ex"}})
  click("#import")
  # The stub responds with the path; verify the source was loaded
  assert model().source != ""
end

test "detach button opens second window" do
  click("#detach")
  assert model().detached == true
end

The full effect stubbing API is covered in chapter 15.

Try it

Write experiments to try these concepts:

  • Build a button that triggers Command.async with a simulated slow operation (Process.sleep(2000)). Show a loading indicator while the task runs, then display the result.
  • Try Command.stream to deliver progress updates. Show a progress bar that fills as chunks arrive.
  • Try Command.batch to combine a clipboard write with a notification.
  • Build a two-window experiment: a main window and a secondary window that opens conditionally. Close the secondary window and see it disappear from the view.

In the next chapter, we will explore the canvas system for custom 2D drawing.


Next: Canvas