Сложное состояние

Счётчик с одним значением — на самое впечатляющее приложение. В реальности состояние приложения включат в себя много "движущихся частей" и воздействуют на это состояние разные элементы интерфейса по разному. Усложним задачу: пусть теперь приложение показывает несколько счётчиков одновременно и позволяет добавлять новые счётчики равно как и удалять существующие.

Поменяются функция draw и содержимое атома counter. Кажется, стоит уже и назвать их иначе:

;; бывший counter
(defonce counters (atom {0 0}))

;; бывшая draw
(rum/defc view-counters < rum/reactive []
  ;; состояние читается один раз в самом начале
  (let [cs (rum/react counters)]
    [:div
     [:ul
      ;; в качестве "ребёнка" тега может выступать список,
      ;; который может быть построен динамически
      (for [i (sort (keys cs))]
        [:li {:key (str "counter-" i)}
         (str (get cs i))
         [:button {:on-click (fn [] (swap! counters update i inc))} "+"]
         [:button {:on-click (fn [] (swap! counters update i dec))} "-"]
         [:button {:on-click (fn [] (swap! counters dissoc i))} "X"]])]
     [:button
      {:on-click
       (fn [] (swap! counters assoc
                    (inc (apply max (keys cs))) 0))}
      "+"]]))

Поменялось многое. В первую очередь атом теперь ссылается на отображение идентификатора счётчика в его значение. {0 0} означает "счётчик с идентификатором 0 имеет значение 0".

Маркированный список :ul теперь наполняется динамически: функция for пробегается по отсортированному списку ключей упомянутого выше отображения и создаёт по элементу списка (:li) для каждого ключа. Обратите внимание: каждый элемент списка имеет атрибут key с уникальным значением. С помощью значений этого атрибута React понимает то, какие элементы в списке нужно обновить, какие нужно добавить, а какие убрать из документа — это достаточно важная оптимизация и если вы её не сделаете, React сообщит в консоли браузера об этом.

Стоит рассмотреть каждый обработчик нажатия, коих теперь уже четыре:

  • (swap! counters update i inc) можно трактовать как (update @counters i inc), то есть увеличивает (inc) значение по ключу i
  • (swap! counters update i inc) — это (update @counters i dec), то есть уменьшение (dec) значения по ключу i
  • (swap! counters dissoc i) — это (dissoc @counters i), то есть удаление из отображения значения по ключу i
  • (swap! counters assoc (inc (apply max (keys cs))) 0) — это добавление значения 0 по ключу (inc (apply max (keys cs))). Вычисляемый этим с виду сложным выражением ключ будет равен числу, следующему за максимальным значением среди ключей отображения cs (полученное выше значение по ссылке из атома counters).

Функция max находит максимальное значение среди своих аргументов. Но мы хотим применить её к списку значений. Здесь нам помогает apply, которая и распаковывает список аргументов: (apply max '(4 2 3 5)) превращается в (max 4 2 3 5).

Сейчас каждая кнопка конструируется вручную и это выглядит как несколько довольно сильно похожих друг на друга кусочков кода. Но каждая кнопка, это всего лишь вектор, а значит мы можем обобщить создание кнопок. Причём мы можем сделать как отдельный компонент, так и просто формировать с помощью функции вектор. Сделаем сначала функцию:

(defn button [label function & args]
  [:button
   {:on-click (fn [] (apply swap! counters function args))}
   label])

(rum/defc view-counters < rum/reactive []
  (let [cs (rum/react counters)]
    [:div
     [:ul
      (for [i (sort (keys cs))]
        [:li {:key (str "counter-" i)}
         (str (get cs i))
         (button "+" update i inc)
         (button "-" update i dec)
         (button "X" dissoc i)])]
     (button "+" assoc (inc (apply max (keys cs))) 0)]))

"& args" в списке параметров функции button означает, что при вызове этой функции все дополнительные аргументы после label и function будут доступны в теле функции в виде списка, связанного с одним параметром args. А далее в теле функции button всё та же функция apply применяет swap! к counters, function и любым другим дополнительным аргументам, которые были указаны при вызове button.

Даже такая простая функция как button в значительной степени разгружает код функции view-counters. А возможна такая лёгкая декомпозиция потому, что мы всего лишь манипулируем знакомыми структурами данных — вот она, сила data­driven design!

Что же случится, если заменить defn на rum/defc в объявлении button? Обычная функция станет компонентом! Полезно такое преобразование тем, что компонент может понять, что "перерисовывать" ничего не нужно, если с прошлого раза значения аргументов не изменились. Если компонент состоит из достаточно большого количества тегов, то выигрыш может быть существенным. Однако и просто так превращать каждую отдельно взятую функцию в компонент не стоит: компоненты "тяжелее" функций с точки зрения используемой памяти. Так что проще начинать с функций, а потом, если понадобится, превратить некоторые из них в компоненты — посмотрев с помощью профилировщика React что и когда перерисовывается и насколько затратна каждая конкретная перерисовка. Как говорится: "семь раз отмерь, один раз отрежь!".