Forked from pesterhazy/00-reagent-state-bidi-input.md
Last active
June 13, 2017 22:51
-
-
Save bhb/7f4c92e17c65e505df0b762c15a296ab to your computer and use it in GitHub Desktop.
Reagent input field experiments in bidirectional binding
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(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 []) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Live preview http://app.klipse.tech/?container=1&cljs_in.gist=bhb/7f4c92e17c65e505df0b762c15a296ab