Skip to content

Instantly share code, notes, and snippets.

@pesterhazy
Last active July 16, 2017 01:45
Show Gist options
  • Save pesterhazy/bc309afa0883f29a07131685cc1087da to your computer and use it in GitHub Desktop.
Save pesterhazy/bc309afa0883f29a07131685cc1087da to your computer and use it in GitHub Desktop.
Reagent input field experiments in bidirectional binding

Reagent state bidi input

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 [])
@pesterhazy
Copy link
Author

pesterhazy commented Jun 13, 2017

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