A Plushie app implements the Elm architecture:
init produces a model, update handles events, view returns a UI
tree. The runtime manages the loop, the Bridge manages the renderer
process, and the supervision tree ties them together.
This reference covers the full lifecycle: callbacks, supervision, startup, the update cycle, error recovery, and window management.
Callbacks
Every Plushie app implements the Plushie.App behaviour. Three
callbacks are required; four are optional.
| Callback | Signature | Return | Required |
|---|---|---|---|
init/1 | init(opts) | model | {model, command} | yes |
update/2 | update(model, event) | model | {model, command} | yes |
view/1 | view(model) | window node(s) | yes |
subscribe/1 | subscribe(model) | [Subscription.t()] | no |
settings/0 | settings() | keyword() | no |
window_config/1 | window_config(model) | map() | no |
handle_renderer_exit/2 | handle_renderer_exit(model, reason) | model | no |
Where command is Plushie.Command.t() or [Plushie.Command.t()].
init/1
Called once at startup with the app_opts keyword list from
Plushie.start_link/2 (default []). Returns the initial model,
optionally paired with commands to execute after the first render.
The return value is validated: it must be a bare model, a
{model, %Command{}} tuple, or a {model, [%Command{}]} tuple.
Anything else raises ArgumentError immediately. This catches bugs
like returning {model, :ok} at startup rather than silently
corrupting state.
update/2
Called for every event. Receives the current model and the event,
returns the next model (optionally with commands). The same return
validation applies as init/1.
Always include a catch-all clause:
def update(model, _event), do: modelWithout one, unhandled events crash the update cycle. The runtime catches the exception and reverts the model (see error recovery), but the error noise is avoidable.
view/1
Called after every successful update/2. Returns one or more window
nodes describing the current UI. The runtime diffs the new tree against
the previous one and sends only the changes to the renderer.
view/1 runs after every successful update, even if the model
hasn't changed. There is no model-equality check that skips it. The
optimisation happens at the diff stage: if the new tree is identical
to the old one, no patch is sent and no wire traffic occurs. When
update/2 raises an exception, view/1 is skipped entirely and the
previous tree is preserved.
subscribe/1
Called after each update cycle. Returns a list of active subscription specs (timers, keyboard, mouse, window events). The runtime diffs this list against the currently active subscriptions: new specs start, removed specs stop. Subscriptions are a function of the model. Return different lists based on model state to conditionally activate them.
Default: [] (no subscriptions).
See the Subscriptions reference for subscription types and rate limiting.
settings/0
Called once during startup to configure renderer-level defaults (font, text size, theme, antialiasing, event rate). Sent to the renderer before the first snapshot.
Default: [] (renderer uses its own defaults).
See the Configuration reference for the full key table.
window_config/1
Called when new windows are opened or reopened after a renderer restart. Returns a base settings map that is merged with per-window props from the view tree. The merged result is sent to the renderer as a window open operation.
Supported keys: title, size, width, height, position,
min_size, max_size, maximized, fullscreen, visible,
resizable, closeable, minimizable, decorations, transparent,
blur, level, exit_on_close_request.
Default: %{} (only per-window props from the tree apply).
handle_renderer_exit/2
Called when the renderer process crashes or is restarted (e.g. during
Rust hot reload). Receives the current model and the exit reason (an
atom like :normal, :shutdown, :dev_restart, or a raw crash term).
Returns a potentially adjusted model.
This is your opportunity to reset state that depends on renderer-side resources (e.g. clear animation progress, reset scroll positions). The runtime re-renders and sends a fresh snapshot to the new renderer after this callback returns.
Default: model returned unchanged. If the handler raises, the exception is logged and the model reverts to its pre-exit state.
Supervision tree
Plushie is a Supervisor using the :rest_for_one strategy with
auto_shutdown: :any_significant.
Children start in order:
Plushie.Bridge- opens the renderer port (:spawn) or attaches to the transport (:stdio,{:iostream, pid}). Owns the wire connection. Marked:transient+:significant.Plushie.Runtime- owns the app model and runs the update loop. Marked:transient+:significant.Plushie.Dev.DevServer- (optional) file watcher and recompiler. Started only when:code_reloaderis enabled. Marked:transient.
:rest_for_one means if Bridge crashes, Runtime restarts too (it
depends on Bridge). If Runtime crashes alone, Bridge stays running and
Runtime re-syncs by sending settings and a full snapshot to the
existing Bridge.
auto_shutdown: :any_significant means when any significant child
(Bridge or Runtime) exits normally, the entire supervision tree shuts
down. This is how closing the last window exits the app: the runtime
stops normally, which triggers auto_shutdown.
Instance naming
Given :name option MyApp (default Plushie):
| Process | Registered name |
|---|---|
| Supervisor | MyApp.Supervisor |
| Bridge | MyApp.Bridge |
| Runtime | MyApp.Runtime |
| DevServer | MyApp.DevServer |
Use Plushie.runtime_for/1 and Plushie.bridge_for/1 to resolve
names programmatically.
Startup sequence
The full sequence from Plushie.start_link/2 to a rendered window:
- Supervisor starts Bridge, then Runtime
- Runtime calls
init/1, producing initial model and optional commands handle_continue(:initial_render)fires: a. Settings fromsettings/0sent to renderer b.view/1called with initial model; full snapshot sent to renderer c. Widget handler registry derived from the tree d. Init commands execute (after the first snapshot, not before) e. Subscriptions synced viasubscribe/1f. Window state synced (opens windows detected in the tree)
Init commands execute after the first snapshot is sent. This means
async commands from init/1 queue their results for the next update
cycle. The user sees the initial UI immediately; command results arrive
as events in subsequent updates.
Update cycle
On each event:
update/2- event + current model -> new model + commands- Execute commands - synchronous commands run immediately; async commands spawn tasks; effect commands go to the renderer
view/1- new model -> new tree (runs unconditionally)- Diff and patch - new tree compared against previous tree. If
different, a patch is sent to the renderer. If identical, no wire
traffic. A full snapshot (instead of a patch) is sent when the
previous tree is
nil(startup, renderer restart). subscribe/1- diff active subscriptions, start new ones, stop removed ones- Sync windows - detect new/removed/changed windows in the tree, send open/close/update operations to the renderer
Steps 3-6 happen inside render_and_sync/1. The entire cycle is
synchronous within the Runtime GenServer. There are no concurrent updates.
Window sync
The runtime extracts window nodes from the view tree and compares them against the previous set:
- New windows (ID in new tree but not old): calls
window_config/1for base settings, merges with per-window props from the tree, sends an open operation to the renderer. - Removed windows (ID in old tree but not new): sends a close operation.
- Changed windows (ID in both, props differ): sends an update operation with only the changed props.
Window IDs must be stable strings. If a window ID changes between renders, the runtime sees it as a close + open, not an update. The renderer closes the old window and opens a new one.
Error recovery
update/2 exceptions
If update/2 raises, the model reverts to its pre-exception state.
The UI stays on the previous successful render. Log levels escalate
by consecutive error count to prevent log flooding:
| Consecutive errors | Log behavior |
|---|---|
| 1-10 | :error level with full stacktrace |
| 11-100 | :debug level with stacktrace |
| 101 | :warning suppression notice |
| 102+ | silent, with :warning reminders every 1000 errors |
The counter resets to zero on the next successful update/2 call.
This escalation prevents a hot loop of errors from filling disk with
logs while still making the first errors highly visible.
view/1 exceptions
If view/1 raises, the previous tree is preserved (no patch sent).
The error is logged at :error level. A consecutive view error counter
tracks failures: at 5 consecutive errors, a :warning is logged
indicating the UI is stale. The counter resets on the next successful
render.
Return value validation
Both init/1 and update/2 returns are validated immediately. Valid
shapes:
model- bare model, no commands{model, %Command{}}- model + single command{model, [%Command{}, ...]}- model + command list
Invalid shapes raise ArgumentError with a descriptive message:
{model, :not_a_command}- second element must be a Command{model, commands, extra}- tuples with more than 2 elements{model, [valid_cmd, :invalid]}- all list elements must be Commands
This catches bugs at the source rather than producing confusing errors downstream.
Renderer crash
Plushie.Bridge manages renderer restart with exponential backoff:
- Formula:
min(restart_delay * 2^attempt, max_backoff) - Default restart delay: 100ms
- Max backoff: 5000ms
- Default max restarts: 5
On successful reconnection (renderer sends {:hello, _}), the restart
counter resets to zero. If the limit is exhausted, Bridge stops with
{:max_restarts_reached, reason}, which triggers supervision tree
shutdown.
State re-sync after renderer restart
When the renderer restarts successfully:
handle_renderer_exit/2called (opportunity to adjust model)- Settings re-sent to the new renderer process
view/1re-evaluated with a fresh tree diff baseline (nil), causing a full snapshot instead of a patch- Widget handler registry re-derived
- Subscriptions re-synced
- Window state re-synced (all windows re-opened)
Elixir-side widget state (managed by the Runtime) is preserved across restarts. Renderer-side widget state (scroll offsets, cursor positions, text editor state) resets because the new renderer process has no memory of the old one.
Daemon mode
Pass daemon: true to Plushie.start_link/2.
In both modes, closing all windows delivers
%SystemEvent{type: :all_windows_closed} to update/2. The
difference is what happens after:
| Mode | After last window closes |
|---|---|
| Normal (default) | update/2 runs (for cleanup), then runtime shuts down |
| Daemon | update/2 runs, runtime continues. Open new windows by returning them from view/1. |
Daemon mode is useful for tray-style apps, menu bar utilities, or apps that re-open windows based on external events.
See also
Plushie.App- callback typespecs and examplesPlushie.Runtime- update loop internals,get_model/1,get_tree/1,sync/1Plushie.Bridge- wire protocol, transport modes, restart logicPlushie.Command- command types returned frominit/1andupdate/2Plushie.Subscription- subscription specs returned fromsubscribe/1- Configuration reference - environment variables,
app config, and
settings/0key table - Events reference - all event types delivered to
update/2 - Guide: Getting Started - the Elm loop in practice