Skip to content

Ephemeral state: where it belongs

A dropdown can be open, a field can hold a half-typed draft, and a drag can have an in-flight pointer position. Those facts do not all belong in the same place.

Fresco has no component-local reactive cell. There is no Fresco equivalent of Reagent's r/atom, and useState does not belong in a defview body. Application-visible facts live in app-db. High-rate widget mechanics stay inside a React island. Browser-owned state stays in the browser.

1. Application-visible state: app-db

Use an explicit app-db address for state that affects what the application can do or what another part of the application can observe: open, expanded, selected, or the active tab.

h/reg-state registers one subscription and one setter event, stored under [:ui concern instance-key], that every instance shares:

(ns app.todos.views
  (:require [re-frame.core :as rf]
            [re-frame.fresco :as h]))

(h/reg-state :todo.ui/expanded? {:default false})

(h/defview todo-item [{:keys [id]}]
  (let [{:keys [title notes]} (h/sub [:todo/by-id id])
        expanded?             (h/sub [:todo.ui/expanded? id])]
    [:li
     [:button {:type "button"
               :aria-expanded expanded?
               :on-click [:todo.ui/expanded? id (not expanded?)]}
      title]
     (when expanded?
       [:p.notes notes])]))

(h/sub [:todo.ui/expanded? id]) reads, [:todo.ui/expanded? id value] writes, and [::h/clear :todo.ui/expanded? id] removes the entry so that instance reads its default again. A hundred todo rows share the one registration, and because the state is at an app-db address you get replay, frame isolation, Xray visibility, and direct test setup.

The concern must be a namespace-qualified keyword, because it is a sub id, an event id and an app-db key at once. The instance key is a keyword, string, number or vector of those. Registering the concern again replaces the registration, so a namespace reload is harmless.

The cell below runs todo-item for two todos and prints what app-db holds at [:ui :todo.ui/expanded?] underneath. Expand a todo and watch its entry appear there, keyed by the todo's id. Forget todo 1 dispatches [::h/clear :todo.ui/expanded? 1], which removes the entry so the todo reads its default again.

(require '[re-frame.core :as rf]
         '[re-frame.fresco :as h])

(rf/reg-event :todo/initialise
  (fn [_ _]
    {:db {:todos {1 {:id 1 :title "Buy milk"     :notes "Oat, not dairy."}
                  2 {:id 2 :title "Walk the dog" :notes "Round the park."}}}}))

(rf/reg-sub :todo/ids (fn [db _] (sort (keys (:todos db)))))
(rf/reg-sub :todo/by-id (fn [db [_ id]] (get-in db [:todos id])))
(rf/reg-sub :app/expanded-slice              ;; for the printout
  (fn [db _] (get-in db [:ui :todo.ui/expanded?])))

(h/reg-state :todo.ui/expanded? {:default false})

(h/defview todo-item [{:keys [id]}]
  (let [{:keys [title notes]} (h/sub [:todo/by-id id])
        expanded?             (h/sub [:todo.ui/expanded? id])]
    [:li
     [:button {:type "button"
               :aria-expanded expanded?
               :on-click [:todo.ui/expanded? id (not expanded?)]}
      title]
     (when expanded?
       [:p.notes notes])]))

(h/defview todo-items [_]
  [:div
   [:ul
    (for [id (h/sub [:todo/ids])]
      [todo-item {:key id :id id}])]
   [:button {:type "button" :on-click [::h/clear :todo.ui/expanded? 1]}
    "Forget todo 1"]
   [:pre "[:ui :todo.ui/expanded?] " (pr-str (h/sub [:app/expanded-slice]))]])

[h/frame-root {:id :app :initial-events [[:todo/initialise]]}
 [todo-items]]

When a change means more than "this slot now holds that value" (something else must happen, or the change itself should be recorded), write a named event and its subscription by hand instead:

(rf/reg-sub :todo/details-open?
  (fn [db [_ id]]
    (get-in db [:ui :todo/details-open id] false)))

(rf/reg-event :todo/details-toggled
  (fn [{:keys [db]} [_ id]]
    {:db (update-in db [:ui :todo/details-open id] not)}))

[:todo/details-toggled id] records what happened and leaves room for effects later; [:todo.ui/expanded? id true] records only the value. Either way, prefer a named event over a generic [:ui/set path value].

2. Drafts and form state: the forms module

A draft is application-visible when validation, submit gating, dirty-leave logic, or another view needs it. Store it at an app-db address, usually through re-frame.fresco.forms.

The forms module is one view, forms/buffered-field: a draft in front of a committed value, with a baseline, a commit protocol and the ::h/revision reset, at an address you supply (Forms). Validation gating and submit status are recipes on ordinary events and subscriptions, taught in the same chapter; there is no form object, validation DSL or submit orchestrator to require. For a smaller concern, such as the new-todo input, ordinary events and an app-db slice are enough:

[:todo.ui/set-draft text]
[:todo.ui/clear-draft]

Either way, the draft has one app-db address and no local copy.

3. Host-private mechanics: native state

Some state exists only to operate a widget:

  • measured geometry
  • an in-flight drag position
  • composition buffers
  • internal focus mechanics
  • a chart or map SDK handle

This state may update every pointer move or animation frame, and nothing outside the widget needs it. Keep it inside a React island or a declared host (Islands, Interop).

Keep the motion inside the island and dispatch one event when it produces a result. Here a todo is dragged to a new position in the list:

(ns app.todos.drag
  (:require ["react" :as react]
            [re-frame.fresco :as h]))

(defn drag-surface [^js props]
  (let [[start set-start] (react/useState nil)
        [dy set-dy]       (react/useState 0)]
    (react/createElement "div"
      #js {:className "card"
           :style     #js {:translate (str "0px " dy "px")}
           :onPointerDown
           (fn [e]
             (.setPointerCapture (.-currentTarget e) (.-pointerId e))
             (set-start (.-clientY e)))

           :onPointerMove
           (fn [e]
             (when start
               (set-dy (- (.-clientY e) start))))

           :onPointerUp
           (fn [_]
             (when start
               ((.-onDrop props) (js/Math.round (/ dy 40))))
             (set-start nil)
             (set-dy 0))}
      (.-label props))))

(h/defhost drag-row drag-surface)

(h/defview todo-drag-row [{:keys [id]}]
  (let [{:keys [title]} (h/sub [:todo/by-id id])]
    [drag-row {:label   title
               :on-drop (h/event [rows] [:todo/moved id rows])}]))

Pointer movement stays in local React state. The completed drop is an application event, so it reaches app-db once. :on-drop is an on* prop, so h/event there is an event callback, and the island calls it with the number of rows the todo moved.

Hooks belong in the island. A defview body may branch and loop, so hooks there would make hook order depend on data.

4. Browser-owned state

Sometimes the platform already owns the fact. Do not mirror it in app-db unless the application needs a semantic copy.

CSS should own hover, focus-visible, active state, :has(), and ordinary <details> disclosure.

Uncontrolled inputs may own scratch text through :default-value, with a commit on blur. The tradeoff is explicit: app-db, tests, and tools cannot see mid-edit text (Controlled inputs).

Platform controls may own a presentational toggle, such as a native popover triggered by :popover-target (Overlays and focus).

When validation, another view, routing, or a test needs the fact, move it to app-db.

5. Exit retention: pixels that outlive data

App-db records what is true. A deleted todo should leave app-db immediately, but its row may need a short exit animation. Keeping that node painted is a rendering concern, and app-db does not record it.

Use motion/presence from the optional re-frame.fresco.motion module, which keeps an exiting node painted for the declared :timeout-ms. Match that bound to the CSS exit duration; the module does not observe animation completion.

Common state and its owner

State Owner Reason
Dropdown open App-db; the overlay module reconciles the platform to it It changes what the user can do, and tests and Xray need it
Field draft App-db through the forms module Validation, submit gating, dirty-leave, and replay read it
Drag position during a drag Island state High-rate mechanics; dispatch the completed drop once
Scroll offset DOM; routing restores it per route Do not re-render for every pixel. Commit meaningful thresholds as events when needed
Animation / exit retention CSS for animation; motion/presence for exit retention; host state for rAF mechanics App-db records truth, not what is still painted
Focus Browser focus, changed through one-shot focus actions A mirrored “focused element” value drifts and would update on every Tab
Selected tab App-db, or routing when it should survive reload Other views, tests, or deep links care
WebGL context or SDK handle Declared host or island It is an object identity with an attach/teardown lifecycle, not application data

Why one owner matters

Tests stay data-driven. If “this dropdown is open” is stored at an app-db address, a headless test can seed that address directly. It does not need to mount a component, simulate a click, or wait for a timer (Testing).

Diagnostics keep a complete cause chain. Xray can connect an event to a state commit, subscription invalidation, view render, React commit, and paint. A private reactive store updates on a clock outside that chain (Diagnostics).

Frames remain isolated. App-db is per-frame. A module-level atom is shared by every frame that mounts the code, which defeats frame isolation.

A host may still keep React state, DOM state, canvas state, or an SDK handle. It must not keep an invisible duplicate of an application fact.

Choose a stable instance address

Application-visible and form state need an instance key. Fresco does not invent one. React's useId is unsuitable because it is tied to render order and does not provide a durable app-db address.

Use authored data: a keyword, string, number, or a vector of those values.

  1. Start with a domain id. Qualify ids when different entity types can collide: [:todo/id 42] and [:list/id 42].
  2. Key placement state by placement and value state by entity. Two panes may share one todo draft while keeping separate expanded/collapsed state.
  3. Extend a parent key for nested instances. [list-id :filter] is often enough.
  4. Apply the same stability test as a React :key. It must be derived from data, stable across renders, unique in its scope, and deterministic under SSR.

There is no :on-mount

Fresco has no :on-mount or :on-unmount. The job you are trying to perform already has a more specific owner:

Job Use
Load data for a screen Route :resources, or an [:rf.resource/ensure …] from the event that decides the data is wanted (Routing, Async resources)
Run startup work once :initial-events, before first paint (Installation)
Animate an entrance or exit CSS or motion/presence
Attach to a DOM node or SDK A callback ref or declared host (Interop)

When app-db is the wrong place

Do not put a fact in app-db when:

  • the browser already owns it, such as hover, focus, or raw scroll position;
  • it changes too quickly to be a useful application event, such as pointer movement or per-frame geometry;
  • nobody outside one widget can observe or act on it, such as an SDK handle.

Everything else is application state and should have one app-db address.

;; Don't: this atom is recreated whenever the body runs, and Fresco does not
;; track it as reactive state.
(h/defview broken-todo-item [{:keys [title notes]}]
  (let [expanded? (atom false)]
    [:li
     [:span {:on-click (fn [_] (swap! expanded? not))} title]
     (when @expanded?
       [:p.notes notes])]))

;; Don't: one event, subscription pass, and paint for every pointer move.
:on-pointer-move
(h/event [e] [:todo/drag-moved id (.-clientX e) (.-clientY e)])

The open flag belongs at an app-db address through h/reg-state (section 1), and pointer movement belongs in an island that dispatches the completed drop (section 3).

Troubleshooting

Symptom Cause Fix
You are reaching for useState or r/atom to hold “is this open?” Application-visible state is moving into a private store Give it an app-db address, or use the overlay module's reconciled open flag
A view-local atom resets or never repaints the view The body can re-run or be abandoned, and Fresco does not subscribe to the atom Move the fact to app-db; move genuine widget mechanics into an island
You are looking for :on-mount, componentDidMount, or a mount effect Fresco has no generic lifecycle hook Identify the job and use the owner in the table above
Every panel opens at once All instances share one address Include a stable instance key in the address
:rf.error/fresco-state-bad-argument, thrown by the registration or as the cause of a :rf.error/sub-exception or :rf.error/handler-exception A reg-state read or write got a nil or non-data instance key, or the registration has an unqualified concern or an unknown option; a failed read returns nil, not the default Pass a stable id such as the entity id; the error's reason names which argument is wrong
Typing or dragging lags and Xray shows an event per pointer move High-rate mechanics were routed through app-db Keep pointer mechanics inside the host and dispatch only the semantic result
A dismissed item vanishes before its CSS exit finishes Exit retention was treated as app-db state, or Presence was not used See Motion and presence
app-db accumulates many :ui entries Application-visible UI state is correctly stored there Namespace the slice and exclude it from persistence when appropriate
A test simulates clicks only to open a dropdown The open flag is data Seed the address directly in the test (Testing)
Coming from Reagent

r/atom solved a view-local reactivity problem that Fresco does not create. Put semantic state at addresses, drafts in the forms model, mechanics in hosts, browser-owned facts in the DOM, and exit retention in Motion and presence.