Components
Components are functions that return SIP markup. When mounted to a DOM element, they automatically re-render whenever the reactive state they depend on changes.
API Reference
(defcomponent name [params] body...)
Defines a component. This is a macro that expands to a regular define -- components are just functions that return SIP vectors.
(defcomponent greeting ()
[:h1 "Hello, world!"])
(defcomponent greeting-with-name (name)
[:h1 "Hello, " name "!"])defcomponent is syntactic sugar. These are equivalent:
(defcomponent counter-view ()
[:p @count])
;; expands to:
(define counter-view
(fn () [:p @count]))(mount! selector component-fn)
Mounts a component to a DOM element identified by a CSS selector. The component renders immediately and re-renders automatically when its signal dependencies change.
(defcomponent app ()
[:div [:p "Hello"]])
(mount! "#app" "app")The second argument is the name of the component function as a string, not the function itself. This is how the runtime calls back into the Sema interpreter.
If a component is already mounted at the given selector, it is unmounted first.
WARNING
mount! takes a string name: (mount! "#app" "app"), not (mount! "#app" app).
(local name initial) -- Component-Scoped State
Creates reactive state scoped to the current component. Unlike hooks in React, local state is keyed by name, not call order. This means:
- You can call
localinside conditionals - You can call
localin any order - The name must be unique within the component
(defcomponent counter ()
(let ((count (local "count" 0)))
[:div
[:p "Count: " @count]]))On the first render, (local "count" 0) creates a new signal with value 0. On subsequent re-renders, it returns the same signal -- the initial value is ignored.
local returns a signal reference, so you read it with @ and write it with put! or update!, just like top-level state.
(on-mount fn) -- Lifecycle Hook
Registers a function to call once after the component's first render. The callback can return either:
- a cleanup function value
- a cleanup function name string
That cleanup runs when the component is unmounted.
(defcomponent timer ()
(let ((elapsed (local "elapsed" 0))
(interval-id (local "interval-id" nil)))
(define (tick) (update! elapsed (fn (n) (+ n 1))))
(define (cleanup)
(when @interval-id
(js/clear-interval @interval-id)))
(on-mount (fn ()
(put! interval-id (js/set-interval tick 1000))
cleanup)) ;; returning the function value is preferred
[:p "Elapsed: " @elapsed "s"]))Key points:
on-mountruns after the DOM is painted, not during render- The cleanup function runs when
component/unmount!is called on the selector - Call
on-mountonly once per component -- the last call wins
(effect deps fn) -- Run Work After Render
Runs fn after the component's DOM has been patched, and again whenever deps change. fn may return a cleanup -- a function value or a global's name -- which runs before the next re-run and once when the component is destroyed.
(defcomponent clock ()
(let ((now (local "now" 0)))
(effect (list)
(fn ()
(let ((id (js/set-interval (fn () (put! now (+ @now 1))) 1000)))
(fn () (js/clear-interval id)))))
[:time (number->string @now)]))deps controls when the body re-runs:
deps | Behaviour |
|---|---|
(list) | Runs once, cleans up at unmount |
(list a b) | Re-runs whenever a or b changes |
nil | Re-runs after every render |
Dependencies are compared structurally, so a map or list dep counts as changed only when its contents differ -- not merely because the render built a new one.
Anything an effect body creates -- intervals, watches, streams, state -- is owned by the component and disposed with it. That is a teardown guarantee, not a re-run guarantee: an effect whose deps change runs its body again, and the second run's interval or watch is added to the first one's. So a body that creates something and can re-run must return a cleanup that undoes it:
;; Without the cleanup, changing @topic leaves the old subscription live and
;; the callback fires once per past run.
(effect (list @topic)
(fn ()
(let ((id (js/set-interval poll 1000)))
(fn () (js/clear-interval id)))))An effect with (list) deps runs once, so for that shape the cleanup is only about teardown.
Composed children and :key
A child rendered with component/render gets its own scope: its effect, on-unmount, local, and resource registrations belong to the child instance, not to the component that mounted it.
Repeated children need a :key in their props to be told apart:
(defcomponent row (props)
(def draft (local "draft" ""))
(effect (list (:id props)) (fn () (subscribe (:id props))))
[:li {:key (:id props)} @draft])
(defcomponent page ()
[:ul (map (fn (r) (component/render row (assoc r :key (:id r)))) @rows)])Note the key appears twice, and both matter: {:key ...} on the [:li] gives the DOM stable identity, and :key in the props map gives the child's state the same identity. The SIP attribute is invisible to the component system, so a keyed list without the prop falls back to sibling ordinals — and removing a middle row then shifts every row after it onto its neighbour's state, exactly as an unkeyed list does in any framework.
A child that appears once, or a conditional branch between two different children, needs no key.
Register effects unconditionally
Effects are matched across renders by call order, not by name (unlike local). Put effect and on-unmount at the top level of the component body, never inside an if or a map callback -- a render that registers a different number of effects re-keys the ones that follow.
Effects flush at the end of each render, so a first-render effect body runs before the on-mount callback.
(on-unmount fn) -- Teardown Hook
Runs fn once when the component is destroyed, whether by (component/unmount! selector), by mounting something else over the same selector, or by disposing the whole runtime.
(defcomponent session-view ()
(on-unmount (fn () (console/log "session view went away")))
[:p "active"])Hooks run in reverse registration order, so the last one registered tears down first. Like effect, on-unmount is matched by call order -- register it unconditionally.
A hook runs at teardown and nowhere else. If a later render changes the slot sequence -- it registers an extra effect above the hook, or stops registering the hook at all -- the runtime reports it through the onerror handler as lifecycle:<component>#<slot> rather than running the hook while the component is still on screen. A hook a render stopped registering never runs, which is why the rule is "register unconditionally" rather than "prefer to register unconditionally".
Errors thrown by an effect body, an effect cleanup, or an on-unmount hook are reported through the runtime's onerror handler and never abort teardown; the remaining cleanups still run.
(component/unmount! selector) -- Unmount
Removes a mounted component, runs its cleanup function (if any), clears the mount target, and stops reactive tracking.
(component/unmount! "#app")(component/force-render! selector) -- Force Re-render
Triggers a re-render even if no signal dependencies changed. Rarely needed, but useful for debugging.
(component/force-render! "#app")Call it from an event handler or from ordinary code -- never from a render body or an effect body of the component it names. That component is already rendering, so the request is refused and reported as force-render:<component>; a component re-renders on its own when the state it reads changes.
Event Handling
Events are handled through delegated event listeners. In SIP markup, on-* attributes specify the name of a Sema function to call:
(define (handle-click ev)
(console/log "Clicked!"))
(defcomponent app ()
[:button {:on-click "handle-click"} "Click me"])The event handler receives a handle to the DOM event. You can extract data from it:
(define (handle-input ev)
(let ((value (dom/event-value ev)))
(put! search-text value)))
(defcomponent search ()
[:input {:type "text"
:value @search-text
:on-input "handle-input"
:placeholder "Search..."}])Supported Events
Delegation works by listening on the mount root, so the events it can route are the ones that bubble to it. These are the whole set:
| Category | Events |
|---|---|
| Mouse | on-click, on-dblclick, on-auxclick, on-contextmenu, on-mousedown, on-mouseup, on-mousemove, on-mouseover, on-mouseout, on-wheel, on-mouseenter, on-mouseleave |
| Pointer | on-pointerdown, on-pointerup, on-pointermove, on-pointerover, on-pointerout, on-pointercancel |
| Touch | on-touchstart, on-touchend, on-touchmove, on-touchcancel |
| Keyboard | on-keydown, on-keyup, on-keypress |
| Form | on-input, on-change, on-submit, on-reset, on-select |
| Focus | on-focusin, on-focusout |
| Clipboard | on-copy, on-cut, on-paste |
| Drag & drop | on-drag, on-dragstart, on-dragend, on-dragenter, on-dragleave, on-dragover, on-drop |
| Animation | on-animationstart, on-animationend, on-transitionend |
Event handler values are always strings -- the name of a defined Sema function.
Anything else is refused rather than rendered dead. A misspelled name ({:on-sumbit.prevent "save"}) reports through onerror under sip-render:on-handler with the correction named, and installs no handler -- because a .prevent that silently never runs lets the form navigate away, which is the entire symptom a typo would otherwise have.
focus and blur do not bubble; use on-focusin / on-focusout, which do. For a custom element's event or another non-bubbling one (scroll), attach the listener yourself from an on-mount callback:
(defcomponent picker ()
(on-mount (fn ()
(let ((el (dom/query "#picker")))
(dom/on! el "picker-change" "handle-pick")
(fn () (dom/off! el "picker-change" "handle-pick")))))
[:my-picker {:id "picker"}])mouseenter and mouseleave are synthesized from mouseover/mouseout. Every element the pointer actually entered runs its handler, outermost first, and every element it left runs its handler innermost first -- so a hover counter kept by a nested pair of handlers stays balanced.
Re-rendering and Diffing
Components re-render via @preact/signals-core's effect(). When a signal dependency changes:
- The component function is called again, producing new SIP markup
- SIP markup is rendered to DOM nodes
morphdompatches the existing DOM to match, minimizing mutations
Focus Preservation
morphdom is configured to preserve focus state. If the user is typing in an input field and a re-render occurs, the input retains focus and cursor position.
Everything else about the element is still patched: attributes the new render declares are applied, attributes it no longer declares are removed (including a dropped on-*, whose handler stops firing with it), and a focused <select> still gains and loses <option>s -- a dependent dropdown must update for the user who has it focused. What is left alone is the live state the user owns: the value and checked properties, the caret, and which options are selected.
What Triggers a Re-render
Only signals read via @ during the component's render are tracked. Event handlers, watch callbacks, and on-mount code do not create subscriptions.
(def a (state 1))
(def b (state 2))
(defcomponent example ()
;; This component subscribes to `a` only
[:p "Value: " @a])
;; Changing `a` re-renders the component
(put! a 10)
;; Changing `b` does NOT re-render -- it was never read during render
(put! b 20)Full Example: Timer with Cleanup
;; A timer that counts seconds and cleans up on unmount
(def elapsed (state 0))
(def timer-id (state nil))
(define (tick)
(update! elapsed (fn (n) (+ n 1))))
(define (start-timer)
(put! timer-id (js/set-interval "tick" 1000)))
(define (stop-timer)
(when @timer-id
(js/clear-interval @timer-id)
(put! timer-id nil)))
(define (reset-timer ev)
(batch
(stop-timer)
(put! elapsed 0)
(start-timer)))
(define (cleanup-timer)
(stop-timer))
(defcomponent timer-view ()
(on-mount (fn ()
(start-timer)
"cleanup-timer"))
(let ((mins (quotient @elapsed 60))
(secs (remainder @elapsed 60)))
[:div {:class "timer"}
[:p (string-append
(number->string mins) "m "
(number->string secs) "s")]
[:button {:on-click "reset-timer"} "Reset"]]))
(mount! "#app" "timer-view")Full Example: Todo App
;; --- State ---
(def todos (state '()))
(def next-id (state 1))
;; --- Actions ---
(define (add-todo ev)
(let ((input (dom/query "#todo-input")))
(let ((text (dom/get-attribute input "value")))
(when (not (equal? text ""))
(batch
(update! todos (fn (lst)
(append lst (list {:id @next-id :text text :done false}))))
(update! next-id (fn (n) (+ n 1))))
(dom/set-attribute! input "value" "")))))
(define (toggle ev)
;; Get the todo ID from the event target's data attribute
(let ((id (string->number (dom/get-attribute (dom/event-target ev) "data-id"))))
(update! todos (fn (lst)
(map (fn (t)
(if (equal? (get t :id) id)
(assoc t :done (not (get t :done)))
t))
lst)))))
(define (remove ev)
(let ((id (string->number (dom/get-attribute (dom/event-target ev) "data-id"))))
(update! todos (fn (lst)
(filter (fn (t) (not (equal? (get t :id) id))) lst)))))
;; --- Components ---
(defcomponent todo-item (todo)
(let ((done? (get todo :done))
(id (number->string (get todo :id))))
[:li {:class (if done? "done" "")}
[:span {:on-click "toggle" :data-id id}
(get todo :text)]
[:button {:on-click "remove" :data-id id} "x"]]))
(defcomponent app ()
[:div {:class "todo-app"}
[:h1 "Todos"]
[:div {:class "input-row"}
[:input {:id "todo-input" :type "text" :placeholder "What needs doing?"}]
[:button {:on-click "add-todo"} "Add"]]
[:ul
(map (fn (t) (todo-item t)) @todos)]])
(mount! "#app" "app")Gotchas
SIP event handlers still use names. {:on-click "my-fn"} passes the string "my-fn". SIP delegated event attributes are still name-based even though lower-level APIs like dom/on!, watch, and on-mount now accept function values.
local needs a string name. (local "count" 0) not (local count 0). The name is used as a stable key across re-renders.
on-mount timing. The callback runs after the first render is painted to the DOM, not during the render function. Do not read signal values inside on-mount to drive rendering -- use the component body for that.
Avoid dom/on! inside components. Event listeners added with dom/on! are lost on re-render because morphdom replaces elements. Use {:on-click "handler"} in SIP attributes instead -- these use delegated event handling that survives DOM patches.
Nested components. Call component functions directly in the parent's SIP output. They are regular function calls, not mount points. Only the top-level mount! creates a reactive boundary.
Related
- Reactive State --
state,put!,update!,computed,batch,watch - Getting Started -- setting up your first page