Skip to content

Instantly share code, notes, and snippets.

@shaunlebron
Last active March 26, 2026 19:18
Show Gist options
  • Select an option

  • Save shaunlebron/d6a681ce74f46cf1b23b2b7f07f14759 to your computer and use it in GitHub Desktop.

Select an option

Save shaunlebron/d6a681ce74f46cf1b23b2b7f07f14759 to your computer and use it in GitHub Desktop.
Flashing intermediate changes inside a render batch in Reagent
(ns example.reagent-flash
(:require
[reagent.core :as r]
[reagent.ratom :as ra]
))
(defn- resolve-ratom [r* path]
(cond
(instance? Atom r*) [r* path]
(instance? ra/RAtom r*) [r* path]
(instance? ra/RCursor r*) (resolve-ratom (.-ratom r*)
(into (.-path r*) path))
:else (throw (ex-info (str "unsupported reference type in flash, expected Atom or RAtom: "
(type r*))
{:r* r*}))))
(defn flash
"A reaction that returns the result of pred whenever r* changes:
* r* must be an Atom, RAtom, or RCursor (so we can use add-watch to call ALL intermediate changes in a frame)
* A successful flash condition remains on for a duration to avoid being turned off in the same frame.
"
[r* pred flash-method ms]
(let [[src* path] (resolve-ratom r* [])
result* (r/atom nil) ;; ratom indicating the non-nil value being flashed
watch-k (gensym "flash-watch")
remove-watch! #(remove-watch src* watch-k)
add-watch! (delay
;; NOTE: `add-watch` must be delayed to run inside our returned reaction to avoid a memory leak,
;; because we can only run `remove-watch` in our returned reaction’s `:on-dispose` handler,
;; and Reagent only disposes reactions that have first been deref'd.
(let [fadeout* (volatile! nil)] ;; the currently scheduled "timeout" for the next fadeout
(add-watch src* watch-k
(fn [_ _ oldv newv]
(let [result (pred (get-in oldv path)
(get-in newv path))]
(case flash-method
;; Any non-nil result resets to nil after `ms` milliseconds, unless interrupted by another non-nil.
:flash-on-some (when (some? result)
(reset! result* result)
(js/clearTimeout @fadeout*)
(vreset! fadeout*
(js/setTimeout #(reset! result* nil)
ms)))
;; Any non-nil value persists until `ms` milliseconds after a nil occurs, unless interrupted by a non-nil.
:fadeout-on-nil (if (some? result)
(do (reset! result* result)
(js/clearTimeout @fadeout*)
(vreset! fadeout* nil))
;; NOTE: schedule a fadeout if not already in progress
(when (and (some? @result*) (nil? @fadeout*))
(vreset! fadeout*
(js/setTimeout #(do (reset! result* nil)
(vreset! fadeout* nil))
ms))))
(throw (ex-info (str "unrecognized flash-method: " (pr-str flash-method)) {}))))))))]
(ra/make-reaction
(fn []
(force add-watch!) ;; NOTE: runs add-watch only once when this reaction is first deref'd
@result*)
:on-dispose remove-watch! ;; NOTE: disposal happens if and only if this reaction was deref'd
)))
(ns example.test-flash
(:require-macros [cljs.core.async :refer [go]])
(:require [clojure.core.async :refer [<! timeout]]
[example.reagent-flash :refer [flash]]
[reagent.core :as r]))
(defonce state* (r/atom {:outer 0
:inner {:foo 1
:bar 2
:baz 3}
:n 0}))
(defn flash-style [on? bg]
(if on?
{:background bg}
{:transition "background 1s"}))
;; NOTE: making sure this works with a top-level ratom
(defn test-outer-flash []
(r/with-let [k :outer
flash?* (flash state*
(fn [oldv newv] (when-not (= (k oldv) (k newv)) newv))
:flash-on-some 100)]
[:div.container
[:div.cell {:style (flash-style @flash?* "lime")}
(name k) ": " (get @state* k)]]))
;; NOTE: making sure this works on multiple tracked values on a single nested cursor
(defn test-inner-flashes [inner*]
(r/with-let [flash-path? (memoize (fn [k]
(flash (r/cursor inner* [k]) ;; <-- testing cursor of a cursor
(fn [oldv newv] (when-not (= oldv newv) newv))
#_#_:flash-on-some 300
:fadeout-on-nil 100)))]
[:div.container
(doall
(for [[k v] @inner*]
[:div.cell {:key k :style (flash-style @(flash-path? k) "lime")}
(name k) ": " (pr-str v)]))]))
;; NOTE: making sure this works for always showing the most recent delta
(defn test-delta-flash [n*]
(r/with-let [delta* (flash n* (fn [a b] (when (and a b (not= a b)) (- b a)))
:fadeout-on-nil 100)]
(let [delta @delta*]
[:div.container
[:div.cell {:style (if delta
{:background (cond (pos? delta) "lime"
(neg? delta) "red"
:else "lightgray")}
{:transition "background 1s"})}
"n: " (pr-str @n*)]])))
(defn main-view [n*]
(r/with-let [inner* (r/cursor state* [:inner])
n* (r/cursor state* [:n])]
(with-css [:main {:display "grid"
:place-items "center"
:width "content-width"
:gap "1em"
:font-size "24pt"}
[:div.container {:display "flex"
:padding "1em"
:gap "1em"
:border "1px solid black"}
[:div.cell {:padding "1em"
:border "1px solid black"}]]]
[:main
[test-outer-flash]
[test-inner-flashes inner*]
[test-delta-flash n*]])))
(comment
(swap! state* update :n inc)
(swap! state* update :n dec)
(swap! state* update :n identity)
(do
(swap! state* update :n dec)
(swap! state* update :n identity)
)
(do
(swap! state* update :outer inc)
(swap! state* assoc :outer 0)
)
(do
(swap! state* update-in [:inner :foo] inc)
(swap! state* update-in [:inner :bar] inc)
(swap! state* update-in [:inner :baz] inc))
(go
(dotimes [_ 3]
(doseq [k [:foo :bar :baz]]
(swap! state* update-in [:inner k] inc)
(<! (timeout 50)))
(<! (timeout 400))
)
)
(swap! state* assoc-in [:inner :foo] nil)
(swap! state* assoc-in [:inner :foo] 3)
(do
(swap! state* update-in [:inner :foo] inc)
(swap! state* assoc-in [:inner :foo] 3)
)
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment