Help us understand the problem. What is going on with this article?

ClojureScript & ReagentでReact入門してみた

そろそろReactに入門したいなぁ、でもClojurianとしてはJavaScriptやTypeScriptではなくClojureScriptで書きたいなぁ、ということでClojureScriptとReagentReact公式チュートリアルに取り組んでみた。

最終的なReactチュートリアルアプリ(tic-tac-toe; 三目並べ)の実装例はこちら:
lagenorhynque/react-tutorial

React->Reagentのポイント

ReactのコードをReagentで書く際のポイントを簡単にまとめてみると、

React Reagent
JSX (React element) Hiccup風のDSL
React component ClojureScriptの関数
props コンポーネント関数の引数
state reagent.core/atom

ClojureScript/Reagentの始め方

プロジェクト生成

簡単に始めるために、ここではshadow-cljsのテンプレートcreate-cljs-appでClojureScript/Reagentプロジェクトを生成する(※ Node.jsとJava (JDK)は事前にインストールしておく)。

$ npx create-cljs-app react-tutorial

生成されるプロジェクトの構成は以下の通り。
ここでは、直接 src/app/core.cljs にチュートリアルアプリを実装してみる。

$ tree -a -I '.git|node_modules' react-tutorial/
react-tutorial/
├── .clj-kondo
│   └── config.edn
├── .gitignore
├── .zprintrc
├── README.md
├── package-lock.json
├── package.json
├── public
│   ├── css
│   │   └── style.css
│   ├── favicon.ico
│   └── index.html
├── shadow-cljs.edn
└── src
    ├── app
    │   ├── cards.cljs
    │   ├── core.cljs
    │   ├── hello.cljs
    │   └── hello_cards.cljs
    └── e2e
        └── core.cljs

6 directories, 15 files

テスト起動

ちなみに、この時点でアプリケーションを起動してみると、 http://localhost:3000/ から自動生成された初期画面が確認できる。

$ cd react-tutorial/
$ npm start

実行イメージ(with React Developer Tools)

実行イメージ(初期)

チュートリアルの事前準備

最後に、チュートリアルのStarter Codeを参考に、土台となるHTMLの差分をpublic/index.htmlに、CSSをpublic/css/style.cssに反映すれば準備完了。

Reactコンポーネントを作る/使う

Reagent公式ページdocを参考に、まずはReagentでReactコンポーネントを作ってみる。

コンポーネントの定義

例えば、Starter CodeにあるReactコンポーネント Game は、JavaScriptで以下のように書かれている。

class Game extends React.Component {
  render() {
    return (
      <div className="game">
        <div className="game-board">
          <Board />
        </div>
        <div className="game-info">
          <div>{/* status */}</div>
          <ol>{/* TODO */}</ol>
        </div>
      </div>
    );
  }
}

Reagentでは、JSXの代わりにHiccupスタイルのDSL(ClojureScriptのVector, Map, Keyword, etc.)でHTMLを表現できる。

そして、ReactコンポーネントはHiccup風の式を返すClojureScriptの関数として表現できる。

したがって、上の Game コンポーネントはReagentで次のような game 関数として定義できる。

※ Clojure/Lispでは関数や変数の命名にUpperCamelCaseやlowerCamelCaseではなくlisp-case(別名kebab-case)を使うので、Lisp風の命名に変えてある。

(defn game []
  [:div.game
   [:div.game-board
    [board]]
   [:div.game-info
    [:div
     ;; status
     ]
    [:ol
     ;; TODO
     ]]])

コンポーネントの利用

上記の例にすでに表れているように、既存のコンポーネントを利用するにはReactのJSXで

<Board />

のように書くところを、Reagentでは

[board]

のように関数名を [] で括って書く。

board コンポーネントもReagentでは関数の形で定義するので () で括って関数として呼び出すこともできるが、 [] で括ることでReactコンポーネントとして扱われ効率的に再描画される対象になる。

コンポーネントの props を扱う

props の導入

Reactコンポーネントに設定情報を渡すために利用する props は、Reagentではコンポーネント関数の引数として表現する。

例えば、Passing Data Through PropsSquare コンポーネントは props を利用して以下のように書かれている。

class Square extends React.Component {
  render() {
    return (
      <button className="square">
        {this.props.value}
      </button>
    );
  }
}

これをReagentで書くと、

(defn square [& {:keys [value]}]
  [:button.square
   value])

あくまでClojureScriptの関数引数なので、このように可変長引数や分配束縛によるオプション/デフォルト引数なども利用することができる(もちろん通常の位置引数でも良い)。

function components

Reactでは render メソッドのみのReactコンポーネントを function components として定義できる。

ここまでのReagentでのコンポーネント定義方法(A Simple Function)はちょうどそれに相当するものだといえる。

※ Reactのclassによるコンポーネント定義に相当する定義方法はこちら: A Class With Life Cycle Methods

Function Componentsにある Square コンポーネントの最終形も以下のように実装できる。

function Square(props) {
  return (
    <button className="square" onClick={props.onClick}>
      {props.value}
    </button>
  );
}

↓↓↓

(defn square [& {:keys [value on-click]}]
  [:button.square {:on-click on-click}
   value])

ちなみに、このように props を持つコンポーネントを利用するには、Reactで

<Square
  value={this.props.squares[i]}
  onClick={() => this.props.onClick(i)}
/>

と書くのに対して、Reagentでは

[square
 :value (squares i)
 :on-click #(on-click i)]

のように書ける。

コンポーネントの state を扱う

state の導入

最後に、Reactコンポーネントに個別の状態 state を持たせてみる。

Making an Interactive Componentでは、 Square コンポーネントが内部状態として state.value を持ち、buttonのonClickで null から 'X' に更新する。

class Square extends React.Component {
  constructor(props) {
    super(props);
    this.state = {
      value: null,
    };
  }

  render() {
    return (
      <button
        className="square"
        onClick={() => this.setState({value: 'X'})}
      >
        {this.state.value}
      </button>
    );
  }
}

このような更新可能な状態(つまり state)を扱うために、Reagentでは reagent.core/atom を利用する。

これは、コンポーネントの再描画に関わっていることを除いて clojure.core/atom と同様なので、 @deref したり reset! したり swap! したりできる(atomderef しているコンポーネントは自動的に再描画される)。

atom による状態を def でnamespaceのトップレベルに定義することもできるが、ここではコンポーネントの個々のインスタンスにローカルな内部状態として持たせたいので、 atom を参照するクロージャを返す方法(A Function Returning A Function)を利用する。

つまり、以下のようにReact要素(Hiccup風の式)をラムダ式でラップし、外側の atom を参照させる。

(ns app.core
  "This namespace contains your application and is the entrypoint for 'yarn start'."
  (:require
   [reagent.core :as reagent]))

(defn square []
  (let [value (reagent/atom nil)]
    (fn []
      [:button.square {:on-click #(reset! value "X")}
       @value])))

これにより、 square コンポーネントはインスタンスごとに変更可能なstateとして value を持ち、現在の値を表示するとともに、onClickで値を更新することが可能になる。

チュートリアルの実装例

以上のことを踏まえて、適宜Reagent公式ページも参照しつつReactチュートリアルを進めてみると、ClojureScriptとReagentでシンプルにチュートリアルアプリが実装できた。
lagenorhynque/react-tutorial

チュートリアル本編を最後まで進めたClojureScriptコードの最終形は以下の通り。

cf. 公式サンプルコード(JavaScript)のFinal Result

次は、チュートリアル末尾の追加課題(some ideas for improvements; cf. Wrapping Up)に取り組んでみたり、re-frameを導入してみたり、re-natalを試してみたりしても良いかもしれない。

src/app/core.cljs
(ns app.core
  "This namespace contains your application and is the entrypoint for 'yarn start'."
  (:require
   [reagent.core :as reagent]))

(defn square [& {:keys [value on-click]}]
  [:button.square {:on-click on-click}
   value])

(defn calculate-winner [squares]
  (let [lines [[0 1 2]
               [3 4 5]
               [6 7 8]
               [0 3 6]
               [1 4 7]
               [2 5 8]
               [0 4 8]
               [2 4 6]]]
    (reduce (fn [_ [a b c]]
              (when (and (squares a)
                         (= (squares a) (squares b))
                         (= (squares a) (squares c)))
                (reduced (squares a))))
            nil
            lines)))

(defn board [& {:keys [squares on-click]}]
  (letfn [(render-square [i]
            [square
             :value (squares i)
             :on-click #(on-click i)])]
    [:div
     [:div.board-row
      (render-square 0)
      (render-square 1)
      (render-square 2)]
     [:div.board-row
      (render-square 3)
      (render-square 4)
      (render-square 5)]
     [:div.board-row
      (render-square 6)
      (render-square 7)
      (render-square 8)]]))

(defn game []
  (let [state (reagent/atom {:history [{:squares (vec (repeat 9 nil))}]
                             :x-is-next? true
                             :step-number 0})]
    (letfn [(handle-click [i]
              (let [{:keys [history x-is-next? step-number]} @state
                    history (vec (take (inc step-number) history))
                    current (nth history (dec (count history)))
                    squares (:squares current)]
                (when-not (or (calculate-winner squares)
                              (squares i))
                  (swap! state assoc
                         :history (conj history
                                        {:squares
                                         (assoc squares i (if x-is-next? "X" "O"))})
                         :x-is-next? (not x-is-next?)
                         :step-number (count history)))))
            (jump-to [step]
              (swap! state assoc
                     :x-is-next? (even? step)
                     :step-number step))]
      (fn []
        (let [{:keys [history x-is-next? step-number]} @state
              current (nth history step-number)
              winner (calculate-winner (:squares current))
              status (if winner
                       (str "Winner: " winner)
                       (str "Next player: " (if x-is-next? "X" "O")))
              moves (map-indexed (fn [move _]
                                   (let [desc (if (zero? move)
                                                (str "Go to game start")
                                                (str "Go to move #" move))]
                                     [:li {:key move}
                                      [:button {:on-click #(jump-to move)}
                                       desc]]))
                                 history)]
          [:div.game
           [:div.game-board
            [board
             :squares (:squares current)
             :on-click handle-click]]
           [:div.game-info
            [:div
             status]
            [:ol
             moves]]])))))

(defn ^:dev/after-load render
  "Render the toplevel component for this app."
  []
  (reagent/render [game] (.getElementById js/document "app")))

(defn ^:export main
  "Run application startup logic."
  []
  (render))

実行イメージ(with React Developer Tools)

実行イメージ(最終)

まとめ

  • ClojureScript & Reagentで簡単にReactを始められる!
  • ClojureScript楽しい>ω</
  • Reagent楽しい>ω</

Further Reading

lagenorhynque
「楽しく楽にcoolにsmartに」を理想とするprogrammer/philosopher。好きな言語はClojure, Haskell, Elixir, Python, English, français。読書、プログラミング、語学、法学、数学が大好き! イルカと海も大好き(*> ᴗ •*)ゞ
https://scrapbox.io/lagenorhynque/
opt
"INNOVATION AGENCY" を標榜するインターネット広告代理店。エンジニア組織 "Opt Techonologies" を中心にアドテクetc...に取り組んでいます。
https://opt-technologies.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away