さめたコーヒー

kbaba1001のブログ

ClojureScript でふつうの React 開発をしたい

背景

ClojureScript には Reagent や RUM などの React のラッパーライブラリが複数あり、 React のライブラリを使うことができる。 しかし実際に使ってみるとなんか上手く動かないこともあって結局 ClojureScript 向けのライブラリを使うほうが無難という現実がある。

例えば、

  • ルーティング ... reitit
  • バリデーション処理 ... Malli
  • Form 処理 ... fork
  • CSS in JS (CSS in CLJS?) ... stylefy
  • 状態管理 ... re-frame

という感じで、React しかさわってない人にとっては初耳のライブラリばかりになる。 なんかこれってもったいないというか、せっかく「ReactのリソースをClojureScriptでつかってやるぜ」といういつものClojureのずるい思想で着手しても、なんだかんだReactのライブラリを活用できなくてくやしい思いをするというのもなんか嫌だ。

TypeScript みたいに JS のライブラリを単に ClojureScript で使うだけじゃ駄目なんだろうか、と思い始めた。

Helix を使う

僕の肌感覚としては React のラッパーライブラリは現在 Reagent が一番使われていると思うが、この Reagent は少し古いので React Hooks とかが若干使いづらい。逆に Atom を活用することは得意なので Signal とか Recoil みたいなものがなくても ClojureScript の Atom を活用することで状態の管理ができる。一方でそれが故にReact系のライブラリが上手く動かないことがある。

で、もうちょっと素直に React Hooks とか使えるラッパーがないかと思って探してみたら次を見つけた。

github.com

Helix はかなり薄い React ラッパーでほとんどの処理はマクロで React や JS に投げているだけだ。 これなら React のライブラリも動きそうだ。

React Router , React Hook Form などを使う

というわけで素振りリポジトリを作ってみた。

github.com

Readme にあるように次のようなライブラリを使ってみている。(HTTP リクエストまわりはまだ途中)

  • Shadow-cljs
  • helix
  • Tailwind.css
  • react-router-dom
  • jotai
  • react-query (jotai があれば不要かも)
  • react-hook-form

Tailwind.css

Tailwind.css は正直あまり好きになれないのだが、 Shadow-cljs が css のビルドはできないので使ってみることにした。

github.com

このライブラリが便利だったので使ってみることにしたが、無事に ClojureScript でビルドした JS ファイルを Tailwind.css に食わせて CSS を出力することができた。

最近、若い開発者と一緒に仕事をしていると案外DOMに対して style 属性でデザインを当てようとしがちなので Tailwind.css みたいな方が存外いいのかもしれない。

React Router

reactrouter.com

React Router の Tutorial に従って createBrowserRouter を使おうとしたが、これはうまく動かなかったので、 BrowserRouter component を直接作ったらうまく動いた。

(ns helix-init.app
  (:require [helix.core :refer [defnc $]]
            ["react" :as r]
            ["react-dom/client" :as rdom]
            ["react-router-dom" :as rrd]
            ["jotai" :refer [useAtom]]
            [helix-init.layouts.base :as base]
            [helix-init.pages.sign-in :as sign-in]
            [helix-init.pages.about :as about]
            [helix-init.pages.sign-up :as sign-up]
            [helix-init.pages.top :as top]
            [helix-init.atoms :refer [sign-in-token-atom]]
            [helix-init.components.require-auth :refer [require-auth]]))

(defnc app []
  (let [[sign-in-token _] (useAtom sign-in-token-atom)]
    ($ rrd/BrowserRouter
       ($ rrd/Routes
          ($ rrd/Route {:path "/" :element ($ base/layout)}
             ($ rrd/Route {:index true :element (if sign-in-token
                                                  ($ require-auth top/top-page)
                                                  ($ sign-in/sign-in-page))})
             ($ rrd/Route {:path "sign-up" :element ($ sign-up/sign-up-page)})
             ($ rrd/Route {:path "about" :element ($ require-auth about/about-page)}))))))

(defonce root (rdom/createRoot (js/document.getElementById "app")))
(defn ^:export init []
  (.render root ($ r/StrictMode ($ app))))

helix-init/app.cljs at main · neumann-tokyo/helix-init · GitHub

React Hook Form

react-hook-form.com

Get Started にある次のコードを参考にして実装しようとした。

import { useForm } from "react-hook-form";

export default function App() {
  const { register, handleSubmit, watch, formState: { errors } } = useForm();
  const onSubmit = data => console.log(data);

  console.log(watch("example")); // watch input value by passing the name of it

  return (
    /* "handleSubmit" will validate your inputs before invoking "onSubmit" */
    <form onSubmit={handleSubmit(onSubmit)}>
      {/* register your input into the hook by invoking the "register" function */}
      <input defaultValue="test" {...register("example")} />
      
      {/* include validation with required or other standard HTML validation rules */}
      <input {...register("exampleRequired", { required: true })} />
      {/* errors will return when field validation fails  */}
      {errors.exampleRequired && <span>This field is required</span>}
      
      <input type="submit" />
    </form>
  );
}

ちょっと待て、 <input defaultValue="test" {...register("example")} /> って ClojureScript でどう書くんだ?

これはパラメータを展開しているわけだから merge してやればいいか、というわけで

(d/input (merge {:defalutValue "test"} (register "example")))

みたいなコードを書いていたのだが、うまく動かなかった。 Helix のソースを確認したところ、 d/input みたいな HTML を作る関数はすべてマクロになっていて、マクロの第1引数が HashMap のときに パラメータとして展開して、それ以外のときは子コンポーネントとして扱うようになっていた。 上記の (merge {:defalutValue "test"} (register "example")) は評価される前にマクロにわたってしまうので、第1引数が関数として処理されるため子コンポーネントとして扱おうとしてエラーになる。。。

Clojarian の slack でも同じことで困っている人がいて、現状対処のしようがないようだ。 仕方がないので register の引数を分解して愚直に HashMap に入れることにした。

(let [form (useForm)
        register (.-register form)
        register-email (register "email" #js {:required true})]
(d/input
        {:type "email"
         :id "email"
         :class-name "border border-gray-300 rounded-md p-2"
         :placeholder "Email"
         :name (.-name register-email)
         :on-blur (.-onBlur register-email)
         :on-change (.-onChange register-email)
         :ref (.-ref register-email)}))

helix-init/sign_in.cljs at main · neumann-tokyo/helix-init · GitHub

素の Form

複雑なフォームでないなら React Hook Form を使わないほうがシンプルになりそうだったのでその例も作った。

github.com

HTML5で対応しきれないバリデーションとか Select の扱いなどを気にしなければこれでよいかも。

jotai

コンポーネント内部の状態は React Hooks で十分なのだが、グローバルに状態を扱いたい時 useContext がどうも使いにくくて Recoil や Jotai などのライブラリが乱立している感じがある。 今回は前から評判が良い jotai を使ってみることにした。

セットアップしたらすんなりうまく動いたので、JWTサインインを想定したダミーのコードを作ってみた。

(let [on-submit (fn [data]
                    (if (and (= (.-email data) "user1@example.com")
                             (= (.-password data) "password"))
                      (do (set-sign-in-token (fn [_token] "sign-in-token"))
                          (.set Cookies "sign-in-token" "sign-in-token"))
                      (set-api-errors ["Invalid email or password."])))])

helix-init/sign_in.cljs at main · neumann-tokyo/helix-init · GitHub

sign-in-token-atom"sign-in-token" という文字列が入っていたらサインイン状態とする。(実際には jwt トークンが入る)

HTTP リクエスト

jotai は react-query みたいに HTTP リクエストの結果をキャッシュするような機能があるのでこれをつかって HTTP リクエストのサンプルコードを作ってみた(まだ途中)

(ns helix-init.components.random-cat
  (:require [helix.core :refer [defnc $]]
            [helix.dom :as d]
            [cljs.core.async :refer [go <!]]
            [cljs-http.client :as http]
            ["jotai" :as jotai]
            ["jotai/utils" :as jotai-utils]))

;; TODO cljs-http 用の loadable を自作する
(defonce random-cat-api-atom
  (jotai/atom
   (go (fn [_get] (<! (http/get "https://cataas.com/cat"
                                {:with-credentials? false
                                 :query-params {"json" true}}))))))
(defonce random-cat-api-loadable-atom
  (jotai-utils/loadable random-cat-api-atom))

(defnc random-cat []
  (let [[random-cat-api] (jotai/useAtom random-cat-api-loadable-atom)]
    (print (.-data random-cat-api))
    (d/div
     #_(case (.-state random-cat-api)
         "loading" ($ d/div "Loading...")
         "hasError" ($ d/div (.-error random-cat-api))
         (d/div (.-data random-cat-api))))))

helix-init/random_cat.cljs at main · neumann-tokyo/helix-init · GitHub

cljs-http を使っているのだが、 jotai-utils/lodable は js の promise のみをターゲットとしているのでこのコードではだめ。

対策を2つ考えていて、

  • cljs-http 用の lodable みたいなものを自作する
  • axios や got などの js の http client を使う

前者を試したらちょっといまいちだったので後者を試して見る予定。

[追記] axios + jotai loadable による http 通信

axios + jotai loadable で http 通信するサンプルコードできました。

(ns helix-init.components.random-cat
  (:require [helix.core :refer [defnc]]
            [helix.dom :as d]
            ["axios$default" :as axios]
            ["jotai" :as jotai]
            ["jotai/utils" :as jotai-utils]))

(defonce random-cat-api-atom
  (jotai/atom (axios/get
               "https://cataas.com/cat"
               #js {:withCredentials false
                    :params #js {:json true}})))
(defonce random-cat-api-loadable-atom
  (jotai-utils/loadable random-cat-api-atom))

(defnc random-cat []
  (let [[random-cat-api] (jotai/useAtom random-cat-api-loadable-atom)
        image-url (some-> (.-data random-cat-api)
                          (js->clj :keywordize-keys true)
                          (get-in [:data :url])
                          ((fn [path]
                             (str "https://cataas.com" path))))]
    (if image-url
      (d/img {:src image-url :alt "cat"})
      (d/div "Loading..."))))

helix-init/random_cat.cljs at main · neumann-tokyo/helix-init · GitHub

["axios$default" :as axios] という読み込み方法が長らく分からず困った。

感想

思ったよりちゃんと React のライブラリが Helix で動いたので嬉しかった。 これなら ClojureScript で積極的にいろいろな React ライブラリを使っても良さそう。

ただ ClojureScript で SSR する方法などを僕は知らないので、JS や TS の下記心地が許せるなら素直にそっち使うほうがいいかもしれない。(元も子もないこと言うけども。。。)

github.com

この辺が実用的なレベルになればもっとJSのライブラリを CLJS で使えるのだが。。。