Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save bhb/7f4c92e17c65e505df0b762c15a296ab to your computer and use it in GitHub Desktop.
Save bhb/7f4c92e17c65e505df0b762c15a296ab to your computer and use it in GitHub Desktop.
Reagent input field experiments in bidirectional binding
(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]])
(defn dec-to-zero [x]
(if (and x (pos? x))
(dec x)
0)
)
(defn bidi-input-ui2
[{initial-value :value}]
(let [!expected-values (atom {})
!local-value (r/atom initial-value)]
(r/create-class
{:display-name "bidi-input-ui2"
:component-will-receive-props
(fn [this [_ {new-value :value}]]
(let [prev-value (:value (r/props this))]
(when (neg? (dec (get @!expected-values new-value)))
(reset! !local-value new-value))
(swap! !expected-values update new-value dec-to-zero)))
:render
(fn [this]
[input* (-> (r/props this)
(assoc :value @!local-value)
(update :on-change-text
(fn [original-on-change-text]
(fn [v]
(swap! !expected-values update v inc)
(reset! !local-value v)
(r/force-update this)
(original-on-change-text v)))))])})))
(defn demo-5 []
[:div
[:h3 "demo-5: bidirectional input field 2"]
[:p "Can we simplify the state by exploiting the fact that the UI expects the backend to echo all changes to the 'value'? Here is an example with two text fields mirroring the same input and also a working 'set from the outside' button. I have not noticed any lag." ]
[:p
[bidi-input-ui2 {:value (:demo-5 @!state)
:on-change-text (delayed #(swap! !state assoc :demo-5 %))}]]
[:p
[bidi-input-ui2 {:value (:demo-5 @!state)
:on-change-text (delayed #(swap! !state assoc :demo-5 %))}]]
[:p
[:button
{:on-click (fn []
(swap! !state assoc :demo-5 "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}}
[demo-1]
[demo-2]
[demo-3]
[demo-4]
[demo-5]
[: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 [])
@bhb
Copy link
Author

bhb commented Jun 13, 2017

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment