vui.el: Lifecycle Hooks
Master vui.el's four hooks - on-mount, on-unmount, use-effect, and use-async. Learn cleanup patterns, avoid stale closures with functional updates, and see runnable examples throughout.
Hooks let components tap into lifecycle events and manage side effects. vui.el provides four hooks that cover most needs: on-mount, on-unmount, use-effect, and use-async. This article explains each in depth.
#1The Lifecycle of a Component
Before diving into hooks, understand when things happen:
Component Created (vui-component called) │ ▼ First Render │ ▼ on-mount called ◄── Component is now in the tree │ │ (user interactions, state changes) ▼ Re-renders (0 or more times) │ │ (use-effect runs based on deps) │ ▼ on-unmount called ◄── Component about to be removed │ ▼ Component Removed
#1Hook Rules
Before we dive in, three rules that apply to all hooks:
- Hooks must be called on every render - don't skip them conditionally
- Hooks must be called in the same order - vui.el identifies hooks by position
- Don't put hooks inside conditionals - put the condition inside the hook instead
These rules exist because vui.el tracks hooks by their call order during render. If you call a hook conditionally, the order changes between renders, and vui.el can't match up which hook is which.
#1Async Context: A Quick Primer
Before we look at hooks, you need to understand one thing: when you call vui-set-state from asynchronous code (timers, process callbacks, hooks), you need to restore the component context. vui.el provides two macros for this:
vui-with-async-context- wraps code that doesn't receive datavui-async-callback- wraps callbacks that receive arguments
;; Timer - no arguments needed (run-with-timer 1 1 (vui-with-async-context (vui-set-state :count #'1+))) ;; API callback - receives response data (fetch-data-async (vui-async-callback (data) (vui-set-state :items data)))
We'll cover these in detail later, but you'll see them throughout the examples.
#1Functional Updates: Avoiding Stale Closures
There's another pattern you'll see repeatedly: passing a function to vui-set-state instead of a value.
;; Direct value - captures 'count' at definition time (vui-set-state :count (1+ count)) ;; Functional update - receives current value when called (vui-set-state :count #'1+)
Why does this matter? Consider a timer:
(defcomponent broken-timer () :state ((count 0)) :on-mount (run-with-timer 1 1 (vui-with-async-context ;; BUG: 'count' is captured as 0 when the timer is created ;; Every tick sets count to (1+ 0) = 1 (vui-set-state :count (1+ count)))) :render (vui-text (format "Count: %d" count))) ; Always shows 1!
This is the stale closure problem. When the timer callback is created, count is 0. The callback captures that value. Every time the timer fires, it computes (1+ 0) and sets count to 1.
The fix is a functional update:
(defcomponent working-timer () :state ((count 0)) :on-mount (run-with-timer 1 1 (vui-with-async-context ;; CORRECT: #'1+ receives the current value each time (vui-set-state :count #'1+))) :render (vui-text (format "Count: %d" count))) ; 1, 2, 3, 4...
When you pass a function, vui-set-state reads the current state value and passes it to your function. No stale closure.
Rule of thumb: In async callbacks (timers, processes, hooks), always use functional updates when the new value depends on the current value.
#1on-mount: First Render Setup
on-mount runs once, immediately after the component's first render. Use it for one-time setup that requires the component to exist in the tree.
(defcomponent timer-display () :state ((seconds 0)) :on-mount (let ((timer (run-with-timer 1 1 (vui-with-async-context (vui-set-state :seconds #'1+))))) ;; Return cleanup function (lambda () (cancel-timer timer))) :render (vui-text (format "Elapsed: %d seconds" seconds)))
Try it:
(vui-mount (vui-component 'timer-display)) ;; Watch the seconds tick up ;; Kill the buffer to stop (though see note about cleanup below)
#2Return Value: Cleanup
If on-mount returns a function, that function is called during unmount. This is essential for cleanup:
:on-mount (progn ;; Setup (add-hook 'post-command-hook #'my-handler nil t) ;; Return cleanup (lambda () (remove-hook 'post-command-hook #'my-handler t)))
Without cleanup, your hooks and timers persist after the component is gone, causing errors or memory leaks.
#2Common Uses
- Starting timers or intervals
- Adding buffer-local hooks
- Registering global keybindings
- Fetching initial data (though
use-asyncis often better) - Setting up external subscriptions
#1on-unmount: Final Cleanup
on-unmount runs right before the component is removed from the tree. It's your last chance to clean up.
#2When Does Mount/Unmount Happen?
- Mount: When a component is first rendered into the tree
- Unmount: When a parent re-renders and no longer includes the child
Important: killing the buffer does not trigger unmount - the cleanup function won't run. Components only unmount during reconciliation when their parent removes them.
#2Example: Toggle Timer with Cleanup
Let's create a timer that logs to *Messages* and a parent that can show/hide it:
(defcomponent noisy-timer () :state ((seconds 0)) :on-mount (progn (message ">>> Timer MOUNTED") (let ((timer (run-with-timer 1 1 (vui-with-async-context (vui-set-state :seconds (lambda (s) (message "Timer tick: %d" s) (1+ s))))))) (lambda () (message ">>> Timer UNMOUNTED - cancelling timer") (cancel-timer timer)))) :render (vui-text (format "Elapsed: %d seconds" seconds))) (defcomponent timer-toggle () :state ((show-timer t)) :render (vui-vstack (vui-button (if show-timer "Hide Timer" "Show Timer") :on-click (lambda () (vui-set-state :show-timer (not show-timer)))) (when show-timer (vui-component 'noisy-timer))))
Try it:
(vui-mount (vui-component 'timer-toggle)) ;; Watch *Messages*: ;; 1. ">>> Timer MOUNTED" ;; 2. "Timer tick: 0", "Timer tick: 1", ... ;; Click "Hide Timer" button ;; Watch *Messages*: ;; ">>> Timer UNMOUNTED - cancelling timer" ;; No more ticks! ;; Click "Show Timer" again ;; Timer mounts fresh, starts from 0
The cleanup function runs when the parent's show-timer becomes nil and the noisy-timer component is removed from the render output during reconciliation.
#2When to Use on-unmount vs on-mount Cleanup
Prefer returning cleanup from on-mount when possible - it keeps setup and cleanup together, making the code easier to follow.
Use on-unmount when you need to clean up state that accumulated during the component's lifetime - things that didn't exist at mount time.
#3Example: on-mount Cleanup (Paired Resources)
When you create a resource in on-mount, return its cleanup:
(defcomponent live-feed () :state ((messages nil)) :on-mount (let ((subscription (subscribe-to-feed (vui-async-callback (new-message) (vui-set-state :messages (lambda (old) (cons new-message old))))))) ;; Cleanup: unsubscribe the same resource we created (lambda () (unsubscribe subscription))) :render (vui-list messages #'render-message))
The subscription is created at mount and cleaned up at unmount - a perfect pair.
#3Example: on-unmount (Accumulated State)
Use on-unmount when cleanup depends on state that changes over time:
(defcomponent document-editor (doc-id) :state ((content "") (dirty nil)) :on-mount (vui-set-state :content (load-document doc-id)) :on-unmount ;; At unmount time, check if we have unsaved changes ;; We can't do this in on-mount cleanup because we don't know ;; what the final state will be (lambda () (when dirty (save-document doc-id content) (message "Auto-saved changes to %s" doc-id))) :render (vui-vstack (vui-field :value content :on-change (lambda (v) (vui-set-state :content v) (vui-set-state :dirty t))) (when dirty (vui-text "(unsaved)" :face 'warning))))
Here, on-mount can't return the save logic because:
- At mount time,
dirtyisnil- nothing to save - At mount time,
contentis the original - hasn't been edited yet - The cleanup needs the final state, not the initial state
#1use-effect: React to Changes
use-effect runs side effects in response to dependency changes. It's the most flexible hook.
#2Why Hooks Live in :render
You'll notice use-effect is called inside :render:
:render (progn (use-effect (query) (search-for query)) (vui-vstack ...))
This might seem odd - why call a side effect during render? The answer is that vui.el needs to track which effect is which across re-renders. It does this by call order: the first use-effect call is always "effect #1", the second is "effect #2", and so on.
This is why you can't put hooks in conditionals - it would change the call order between renders.
#2What use-effect Can Do
use-effect subsumes the other lifecycle hooks:
- Empty deps
(use-effect () ...)- runs once on mount, likeon-mount - Cleanup function - runs on unmount, like
on-unmount - With deps
(use-effect (x y) ...)- runs whenxorychange - Cleanup before re-run - cleanup runs before each re-execution, not just unmount
- Multiple effects - use several
use-effectcalls for separate concerns
This flexibility comes at a cost: you must think about dependencies. With on-mount, it just runs once. With use-effect, you control when it runs by choosing what to depend on.
#2Example: Search with Cancellation
;; Simulate async search with cancellation support (defvar my-search-timer nil "Current pending search timer.") (defun my-search (query callback) "Search for QUERY. After 2 seconds, call CALLBACK with results." (message ">>> Starting search for: '%s'" query) (setq my-search-timer (run-with-timer 2 nil callback (list (format "Result 1 for '%s'" query) (format "Result 2 for '%s'" query))))) (defun my-cancel-search () "Cancel any pending search." (when my-search-timer (message ">>> Cancelling pending search!") (cancel-timer my-search-timer) (setq my-search-timer nil))) (defcomponent search-results (query) :state ((results nil) (loading t)) :render (progn (use-effect (query) (message ">>> Effect running for query: '%s'" query) (vui-set-state :loading t) (vui-set-state :results nil) (my-search query (vui-async-callback (data) (message ">>> Search completed for: '%s'" query) (vui-set-state :results data) (vui-set-state :loading nil))) ;; Return cleanup - cancels search if query changes #'my-cancel-search) (vui-vstack (vui-text (format "Searching: '%s'" query) :face 'bold) (if loading (vui-text "Loading...") (if results (vui-vstack (vui-text (format " • %s" (nth 0 results))) (vui-text (format " • %s" (nth 1 results)))) (vui-text "No results")))))) (defcomponent search-demo () :state ((query "emacs")) :render (vui-vstack (vui-hstack (vui-button "[emacs]" :on-click (lambda () (vui-set-state :query "emacs"))) (vui-button "[lisp]" :on-click (lambda () (vui-set-state :query "lisp"))) (vui-button "[vui]" :on-click (lambda () (vui-set-state :query "vui")))) (vui-component 'search-results :query query)))
Try it:
(vui-mount (vui-component 'search-demo))
Watch *Messages* while clicking buttons:
- Initial: ">>> Starting search for: 'emacs'"
- Wait 2 seconds: ">>> Search completed for: 'emacs'" - results appear
- Click
[lisp]quickly (before 2 seconds): ">>> Cancelling pending search!" then ">>> Starting search for: 'lisp'"
The cleanup function prevents stale results from overwriting newer queries!
#2Dependency List
The first argument to use-effect is a list of dependencies:
;; Run once on mount (empty deps) (use-effect () (message "Mounted!")) ;; Run when 'count' changes (use-effect (count) (message "Count is now: %d" count)) ;; Run when either 'user' or 'page' changes (use-effect (user page) (fetch-user-page user page))
The effect runs:
- After the first render (always)
- After any re-render where a dependency changed
#2Cleanup Function
Like on-mount, use-effect can return a cleanup function:
(use-effect (user-id) ;; Setup: subscribe to user updates (let ((sub (subscribe-user-updates user-id callback))) ;; Cleanup: unsubscribe (lambda () (unsubscribe sub))))
The cleanup runs:
- Before the effect runs again (when deps change)
- When the component unmounts
This ensures you don't have stale subscriptions when dependencies change.
#2Effect Identity
Each use-effect in a component has a stable identity based on its position. This matters for correct cleanup:
(defcomponent multi-effect () :state ((a 1) (b 2)) :render (progn (use-effect (a) (message "Effect A: %d" a)) (use-effect (b) (message "Effect B: %d" b)) (vui-text "...")))
These are tracked separately. Changing a only runs the first effect.
Try it:
(defcomponent effect-demo () :state ((a 1) (b 2)) :render (progn (use-effect (a) (message ">>> Effect A fired: %d" a)) (use-effect (b) (message ">>> Effect B fired: %d" b)) (vui-vstack (vui-hstack (vui-button "Increment A" :on-click (lambda () (vui-set-state :a (1+ a)))) (vui-button "Increment B" :on-click (lambda () (vui-set-state :b (1+ b))))) (vui-text (format "A=%d, B=%d" a b))))) (vui-mount (vui-component 'effect-demo)) ;; Click "Increment A" - only Effect A fires ;; Click "Increment B" - only Effect B fires
#2Pitfalls
Don't put use-effect in conditionals:
;; WRONG: hooks must be called unconditionally :render (progn (when should-track (use-effect (value) (track-value value))) ; Don't do this! ...) ;; RIGHT: put the condition inside the effect :render (progn (use-effect (value should-track) (when should-track (track-value value))) ...)
Hooks rely on call order for identity. Conditional calls break this.
#1use-async: Data Loading
Some operations are expensive - fetching data from an API, running a shell command, parsing a large file. In single-threaded Emacs, these block the entire editor until they complete. Users can't type, scroll, or do anything else.
If the operation can run asynchronously (the API supports callbacks, or you can spawn a subprocess), you avoid blocking. But now you have a UI problem: you need loading indicators, error handling, and a way to update the display when data arrives.
Emacs offers several async mechanisms:
| Use Case | Recommended Approach |
|---|---|
| External process (shell, CLI) | make-process + sentinel |
| HTTP requests | plz.el or url-retrieve |
| CPU-heavy pure Lisp | async.el (child Emacs) |
| Chained async operations | promise.el |
use-async doesn't replace these - it works with them. You provide a loader that uses async primitives; use-async manages the UI state (loading, success, error) and triggers re-renders when data arrives.
#2The Manual Approach
Without use-async, you'd manage loading state yourself:
(defcomponent user-profile-manual (user-id) :state ((user nil) (loading t) (error nil)) :render (progn (use-effect (user-id) (vui-set-state :loading t) (vui-set-state :error nil) (fetch-user user-id (vui-async-callback (data) (vui-set-state :user data) (vui-set-state :loading nil)) (vui-async-callback (err) (vui-set-state :error err) (vui-set-state :loading nil)))) (cond (loading (vui-text "Loading...")) (error (vui-text (format "Error: %s" error))) (t (render-user user)))))
This works, but every async operation needs the same boilerplate.
#2The use-async Solution
use-async extracts the pattern:
(defcomponent user-profile (user-id) :render (let ((result (use-async (list 'user user-id) ; Key: determines when to re-fetch (lambda (resolve reject) (fetch-user user-id resolve reject))))) (pcase (plist-get result :status) ('pending (vui-text "Loading...")) ('error (vui-text (format "Error: %s" (plist-get result :error)))) ('ready (render-user (plist-get result :data))))))
No explicit state, no manual effect setup. use-async provides:
- Automatic state management - tracks pending/ready/error for you
- Key-based re-fetching - when
user-idchanges, re-fetch automatically - Caching - same key returns cached result without re-fetching
- Consistent API -
resolve/rejectpattern like JavaScript Promises
The hook doesn't make your code async - your loader must use async primitives (make-process, url-retrieve, etc.). What use-async does is manage the UI state around async operations.
#2The Key Mechanism
The first argument is a key that identifies the async operation:
;; Simple key: just a symbol (use-async 'users (lambda (resolve reject) ...)) ;; Compound key: re-fetches when user-id changes (use-async (list 'user user-id) (lambda (resolve reject) ...))
When the key changes (compared with equal), the previous operation is cancelled and a new one starts.
#2Return Value
use-async returns a plist with:
| Key | Value |
|---|---|
:status | 'pending, 'ready, or 'error |
:data | The resolved data (when ready) |
:error | The error message (when error) |
(let ((result (use-async 'my-data loader))) (pcase (plist-get result :status) ('pending (vui-text "Loading...")) ('error (vui-text (format "Error: %s" (plist-get result :error)))) ('ready (render-data (plist-get result :data)))))
#2Important: use-async Doesn't Make Things Async
This is a common misconception. use-async is a state machine for managing async operations - it doesn't perform async work itself. The loader function must use actual async mechanisms:
;; WRONG: This blocks! (use-async 'data (lambda (resolve _reject) ;; shell-command-to-string blocks Emacs (resolve (shell-command-to-string "slow-command")))) ;; RIGHT: Use async primitives (use-async 'data (lambda (resolve reject) (make-process :name "slow-command" :command '("slow-command") :sentinel (lambda (proc _event) (when (eq (process-status proc) 'exit) (if (= 0 (process-exit-status proc)) (resolve (process-buffer-output proc)) (reject "Command failed")))))))
#2Example: Async Shell Command
Here's a complete, runnable example using make-process:
(defcomponent async-command-demo () :state ((command "echo 'Hello from async!'")) :render (let ((result (use-async (list 'cmd command) (lambda (resolve reject) (let ((output-buffer (generate-new-buffer " *async-output*"))) (make-process :name "async-cmd" :buffer output-buffer :command (list "sh" "-c" command) :sentinel (lambda (proc _event) (when (memq (process-status proc) '(exit signal)) (if (= 0 (process-exit-status proc)) (with-current-buffer output-buffer (funcall resolve (string-trim (buffer-string)))) (funcall reject "Command failed")) (kill-buffer output-buffer))))))))) (vui-vstack (vui-hstack (vui-button "echo" :on-click (lambda () (vui-set-state :command "echo 'Hello!'"))) (vui-button "date" :on-click (lambda () (vui-set-state :command "date"))) (vui-button "sleep" :on-click (lambda () (vui-set-state :command "sleep 2 && echo 'Done!'")))) (vui-newline) (pcase (plist-get result :status) ('pending (vui-text "Running..." :face 'shadow)) ('error (vui-text (format "Error: %s" (plist-get result :error)) :face 'error)) ('ready (vui-text (format "Output: %s" (plist-get result :data))))))))
Try it:
(vui-mount (vui-component 'async-command-demo)) ;; Click [echo] - instant result ;; Click [date] - shows current date ;; Click [sleep] - shows "Running..." for 2 seconds, then "Done!" ;; Click [sleep] then quickly click [echo] - sleep is cancelled, echo runs
#2Cancellation
When the key changes or component unmounts, any pending operation should be cancelled. This happens automatically if your loader respects Emacs process semantics - vui.el tracks processes and can kill them when needed.
#1Combining Hooks
Hooks compose naturally. Here's a component using multiple hooks:
(defcomponent data-view (source-id) :state ((is-visible t) (view-count 0)) :on-mount (progn (message "DataView mounted for source %s" source-id) nil) ; No cleanup needed :on-unmount (lambda () (message "DataView unmounted after %d views" view-count)) :render (let ((result (use-async (list 'source source-id) (lambda (resolve reject) (fetch-source source-id resolve reject))))) (use-effect (is-visible) (when is-visible (vui-set-state :view-count #'1+))) (if (not is-visible) (vui-text "[Hidden]") (pcase (plist-get result :status) ('pending (vui-text "Loading...")) ('error (vui-text "Failed to load")) ('ready (vui-vstack (vui-text (format "Source: %s (viewed %d times)" source-id view-count)) (vui-text (plist-get result :data))))))))
#1Async Context: The Full Story
Earlier we introduced vui-with-async-context and vui-async-callback. Here's the complete picture.
#2The Problem
When Emacs runs async code (timer callbacks, process sentinels, hooks), it's outside the component's render cycle. vui.el doesn't know which component the code belongs to, so vui-set-state doesn't work.
#2vui-with-async-context
Use when your callback doesn't receive data:
;; Timer - no arguments (run-with-timer 1 1 (vui-with-async-context (vui-set-state :count #'1+))) ;; Hook - ignore any arguments passed by the hook (let ((handler (vui-with-async-context (vui-set-state :width (frame-width))))) (add-hook 'window-size-change-functions handler))
#2vui-async-callback
Use when your callback receives data from the async operation:
;; API callback receives response data (fetch-data-async (vui-async-callback (data) (vui-set-state :items data))) ;; Process sentinel receives proc and event (make-process :command '("echo" "hello") :sentinel (vui-async-callback (proc _event) (when (memq (process-status proc) '(exit signal)) (vui-set-state :done t))))
#2Why Two Macros?
vui-with-async-context wraps code and returns a zero-argument function. vui-async-callback does the same but the returned function accepts arguments.
| Situation | Use |
|---|---|
| Timer tick | vui-with-async-context |
| Hook (ignore args) | vui-with-async-context |
| API response | vui-async-callback |
| Process sentinel | vui-async-callback |
| Any callback with data | vui-async-callback |
#1Custom Hook Patterns
While vui.el doesn't have a formal custom hooks system, you can create reusable patterns by combining state and effects:
;; Pattern: Window size tracking (defcomponent responsive-component () :state ((window-width (window-width))) :render (progn (use-effect () (let ((handler (vui-with-async-context (vui-set-state :window-width (window-width))))) (add-hook 'window-size-change-functions handler) (lambda () (remove-hook 'window-size-change-functions handler)))) (if (< window-width 80) (vui-text "Narrow layout") (vui-text "Wide layout"))))
Try it:
(vui-mount (vui-component 'responsive-component)) ;; Resize your Emacs frame ;; Watch the text change between "Narrow layout" and "Wide layout"
#1Error Handling
Hook errors are caught and reported without crashing your UI:
:on-mount (error "Something went wrong!") ; Won't crash, will be logged
Customise error handling with vui-lifecycle-error-handler:
(setq vui-lifecycle-error-handler (lambda (component hook-name error) (message "Hook error in %s (%s): %s" component hook-name error)))
#1Hook Reference
| Hook | Runs When | Returns | Use For |
|---|---|---|---|
on-mount | After first render | Optional cleanup fn | One-time setup |
on-unmount | Before removal | Cleanup fn | Final cleanup |
use-effect | Mount + deps change | Optional cleanup fn | Reactive side effects |
use-async | Key changes | Plist (:status :data :error) | Async data management |
#1Summary
on-mount: One-time setup after first render. Return cleanup function.on-unmount: Final cleanup before removal. Use when cleanup depends on final state.use-effect: Run side effects when dependencies change. Most flexible.use-async: Manage async operations with loading/error states.
Key principles:
- Always clean up - timers, hooks, subscriptions
- Keep hooks unconditional - don't wrap in
if, put conditions inside - Use dependencies to control when effects run
- Use functional updates in async callbacks to avoid stale closures
- Remember:
use-asyncdoesn't make code async - your loader must be async
Next: Practical patterns for async data loading in Emacs.