This Gist demonstrates problems with synchronizing input fields in reagent.
Shows how to bind an atom to a remote state asychronously and bidirectionally
(ns klipse-like.core | |
(:require [reagent.core :as r]) | |
(:import [goog.async Delay])) | |
(enable-console-print!) | |
(defonce !state (r/atom nil)) | |
(defn input* | |
"Like :input, but support on-change-text prop. Avoids mutability pitfalls of | |
on-change events" | |
[{:keys [on-change on-change-text] :as props}] | |
(assert (fn? on-change-text) "Must have on-change-text prop") | |
(assert (nil? on-change) "Must not have on-change prop") | |
[:input (-> props | |
(assoc :on-change (fn [e] (on-change-text (-> e .-target .-value)))) | |
(dissoc :on-change-text))]) | |
(defn buffered-input-ui | |
[{initial-value :value}] | |
(let [!local-value (atom initial-value)] | |
(r/create-class | |
{:display-name "buffered-input-ui" | |
:component-will-receive-props | |
(fn [this [_ {new-value :value}]] | |
(reset! !local-value new-value)) | |
:render | |
(fn [this] | |
[input* (-> (r/props this) | |
(assoc :value @!local-value) | |
(update :on-change-text | |
(fn [original-on-change-text] | |
(fn [v] | |
(reset! !local-value v) | |
(r/force-update this) | |
(original-on-change-text v)))))])}))) | |
(defn bidi-input-ui | |
[{initial-value :value}] | |
(let [!local-value (r/atom initial-value) | |
!next-value (atom nil) | |
!last-inner-update (atom 0) | |
delay-update (Delay. #(when (not= @!next-value @!local-value) | |
(reset! !local-value @!next-value)) | |
500)] | |
(r/create-class | |
{:display-name "bidi-input-ui" | |
:component-will-receive-props | |
(fn [this [_ {new-value :value}]] | |
(let [prev-value (:value (r/props this))] | |
(when (not= prev-value new-value) | |
(let [now (.getTime (js/Date.)) | |
;; if no update happend recently, fast-track update | |
fast-track? (> (- now @!last-inner-update) 2000)] | |
(reset! !next-value new-value) | |
(if fast-track? | |
(.fire delay-update) | |
(.start delay-update)) | |
(reset! !last-inner-update (.getTime (js/Date.))))))) | |
:component-will-unmount | |
(fn [] | |
(.dispose delay-update)) | |
:render | |
(fn [this] | |
[input* (-> (r/props this) | |
(assoc :value @!local-value) | |
(update :on-change-text | |
(fn [original-on-change-text] | |
(fn [v] | |
(reset! !next-value v) | |
(.fire delay-update) | |
(reset! !last-inner-update (.getTime (js/Date.))) | |
(r/force-update this) | |
(original-on-change-text v)))))])}))) | |
(defn state-ui [] | |
[:pre {} (pr-str @!state)]) | |
(defn demo-1 [] | |
[:div | |
[:h3 "demo-1: instant updates"] | |
[:p "With instant synchronous updates, no problems are visible"] | |
[:p | |
[buffered-input-ui {:value (:demo-1 @!state) | |
:on-change-text #(swap! !state assoc :demo-1 %)}]] | |
[:p | |
[:button | |
{:on-click (fn [] | |
(swap! !state assoc :demo-1 "from-the-outside"))} | |
"Set from the outside"]] | |
[state-ui]]) | |
(defn delayed [f] | |
(fn [& args] | |
(js/setTimeout #(apply f args) 500))) | |
(defn demo-2 [] | |
[:div | |
[:h3 "demo-2: asynchronous delayed-swap"] | |
[:p "When adding a 500ms artifical delay, we see laggy typing behavior"] | |
[:p | |
[buffered-input-ui {:value (:demo-2 @!state) | |
:on-change-text (delayed #(swap! !state assoc :demo-2 %))}]] | |
[:p | |
[:button | |
{:on-click (fn [] | |
(swap! !state assoc :demo-2 "from-the-outside"))} | |
"Set from the outside"]] | |
[state-ui]]) | |
(defn demo-3 [] | |
[:div | |
[:h3 "demo-3: uncontrolled input"] | |
[:p "One attempt to solve this problem is to use an uncontrolled input. But | |
this means that you can't change the value from the outside, by resetting the | |
state. You can refresh the input field by clicking on Remount below, but that | |
doesn't solve the problem."] | |
[:p | |
[input* {:default-value (:demo-3 @!state) | |
:on-change-text (fn [v] | |
(swap! !state assoc :demo-3 v))}]] | |
[:p | |
[:button | |
{:on-click (fn [] | |
(swap! !state assoc :demo-3 "from-the-outside"))} | |
"Set from the outside"]] | |
[state-ui]]) | |
(defn demo-4 [] | |
[:div | |
[:h3 "demo-4: bidirectional input field"] | |
[:p "A better solution is to establish bidirectional synchronisation between | |
local state and the (delayed) global state. With bidi binding, explicit user | |
input - typing into the input element - always takes precendence over new | |
incoming global state. Furthermore, incoming global updates are debounce, | |
i.e. delayed until no new updates are received for 500ms. Finally, incoming | |
state updates are fast-tracked if no activity was received for the last | |
2000ms."] | |
[:p | |
[bidi-input-ui {:value (:demo-4 @!state) | |
:on-change-text (delayed #(swap! !state assoc :demo-4 %))}]] | |
[:p | |
[:button | |
{:on-click (fn [] | |
(swap! !state assoc :demo-4 "from-the-outside"))} | |
"Set from the outside"]] | |
[state-ui]]) | |
(defonce !refresh-count (r/atom 0)) | |
(defn refresh [] | |
(swap! !refresh-count inc)) | |
(defn root* [] | |
(r/create-class | |
{:render (fn [] | |
[:div {:style {:max-width 600}} | |
[:h1 "Reagent input field syncing"] | |
[demo-1] | |
[demo-2] | |
[demo-3] | |
[demo-4] | |
[:div | |
[:button {:on-click refresh} "Remount"]]])})) | |
(defn root [] | |
(prn [:render :root]) | |
[root* {:key @!refresh-count}]) | |
(defn remount [] | |
(r/render-component [root] js/window.klipse-container)) | |
(remount) | |
(defn on-js-reload []) |
For live demo see http://app.klipse.tech/?container=1&cljs_in.gist=pesterhazy/0f350623f871140e2fffbb8415536f21