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