PythonでReactを実現するreactPyをさわってみた

# reactPy
PythonやReactを実装されている方は、既に目にしているかもしれませんが、6月になってからでしょうか。**reactPy**に関する話が増えた気がしています。
まず、どういうものなのか、[github](https://github.com/reactive-python/reactpy)を見て、情報を得ることにしました。
> ReactPy is a library for building user interfaces in Python without Javascript. ReactPy interfaces are made from components that look and behave similar to those found in ReactJS.

`JavaScriptを使わずに（Pythoを使って）、Reactのようなふるまいをするコンポーネントを構築するライブラリ`とのことです。

公式ドキュメントを見てみましたが、まだまだ機能追加中のようです。（工事中のようなアイコンがある機能は開発中）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/01cdcc0b-6abf-37c3-760b-256fe4ee753b.png)
今後実現できることが増えそうで楽しみではありますが、まずは機能がそれほど多くないこの段階で、一度キャッチアップをしておきたいと思い、簡単なアプリケーションを作成することにしました。

https://reactpy.dev/docs/guides/getting-started/index.html

https://zenn.dev/ddpn08/articles/reactpy-introduction

# 実装準備
### どんなものを作るか
React経験者には大変なじみのある`useState`や`useEffect`を用いたり、ルーティング機能を実装したりすることにしました。
デザインには`tailwindcss`や`Material UI`が使えるようです。今回は`tailwindcss`を導入することにしました。

https://tailwindcss.com/

また、デプロイ先は`fly.io`にしました。（既にデプロイ経験があり、作業が進めやすいと思ったため。）

https://fly.io/

### 必要なライブラリ
pipコマンドでインストールします。
（Pythonはインストール済の前提です。バージョンは`Python 3.11.3`です。）
- reactPy
- reactPy-router
```
pip install reactPy reactPy-router
```

### create-react-appコマンドはなさそう
公式ドキュメントで確認した限りは見つかりませんでした。
Reactでいうpackage.jsonを自動で作成してくれるわけではないので、自分で用意する必要があります。
今回は、fly.ioにデプロイした関係で、`requirements.txt`に最低限必要なライブラリを定義しています。
```text:requirements.txt
fastapi==0.96.0
fonttools==4.39.4
httpcore==0.16.3
httplib2==0.22.0
numpy==1.23.5
packaging==23.1
pytest==7.3.2
python-dotenv==1.0.0
reactpy==1.0.0
reactpy-router==0.0.1
uvicorn==0.22.0
websockets==11.0.3
wsproto==1.2.0
requests==2.30.0
requests-oauthlib==1.3.1
typing-inspect==0.8.0
typing_extensions==4.5.0
dataclasses-json==0.5.7
```

### FastAPIをベースに実装
FastAPIを使った実装経験がある関係で選択しました。
公式ドキュメントを見ますとFlaskやSanic?など、何種類か対応しているようです。
FastAPIを用いた実装例があります。今回も基本的な構造はこのファイルの通りです。

https://reactpy.dev/docs/guides/getting-started/running-reactpy.html

# 実装開始
※作成したアプリのリポジトリは最後に記載してます。

### コンポーネント実装
ReactPyというだけあって、Reactで実装している感覚とそれほど変わらない、と思っています。画面に`Hello, World!`をと表示する場合は以下のように実装します。
（[参考ページ](https://github.com/reactive-python/reactpy)）
```react:sample.py
# 必要なライブラリ定義
from reactpy import component, html 
from reactpy.backend.fastapi from configure
from fastapi import FastAPI

@component
def hello_world():
    # 画面に表示する内容を戻り値に設定
    return html.h1("Hello, World!")

app = FastAPI()
configure(app, HelloWorld)
```

### Routing
FastAPIベースといいましたが、どのPathに対してどのコンポーネントを表示するのか、ライブラリ`reactpy-router`を使って定義します。

https://github.com/reactive-python/reactpy-router

定義した`my_router`コンポーネントをFast APIのconfigureに対して設定して、Routingを実現します。
```python:view.py
from fastapi import FastAPI
from uvicorn import run
from reactpy import component, html
from reactpy.backend.fastapi import configure, Options
from reactpy_router import route, simple

from src.config import head
from src.pages import Home, Detail, Data, FilterableList, Error

@component
def my_router():
    return simple.router(
        route("/", Home()),
        route("/data", Data()),
        route("/table", FilterableList()),
        route("/detail/{names}", Detail()),
        route("/er", Error()),
        route("*", html.h1("Not Found")) # 404エラー対応
    )

# Router情報を設定
configure(
    app,
    my_router,
    Options(head=head.create_head(tailwind_config={})),
)

if __name__ == "__main__":
    run(app)

```
※いずれのRouteにも該当しない場合（404エラー対応）、参考にしていた動画や、[こちら](https://reactive-python.github.io/reactpy-router/usage/)でも以下のように実装しています。ですが動作確認してみると、存在しないパスにアクセスした際、画面が真っ白になってしまいます・・・。ここは今後追加確認を行い、修正が必要な箇所です。
> route("*", html.h1("Not Found"))

### tailwindcssでデザイン適用
htmlでは、headタグ内部にscriptタグを記述して、必要なライブラリを定義します。その要領でtailwindcssを定義します。
今回は純正なhtmlではないので、同じとはいきませんが、CDNを適用したい場合に、以下のように記述します。

https://tailwindcss.com/docs/installation/play-cdn

今回参考した記事と同じ実装ですが、CDNの適用をReactPyバージョンで実装しています。
```react
return (
        html.title("reactPy Sample Site"),
        html.link(
            {
                "href": "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap",
                "rel": "stylesheet",
            }
        ),
        html.script({"src": "https://cdn.tailwindcss.com"}, ""),
    )
```

### アプリケーション作成
> React経験者には大変なじみのあるuseStateやuseEffectを用いたり、ルーティング機能を実装したりすることにしました。

始めの方に記載した上記内容の詳細です。以下の機能を実装しました。
1. qiita APIからデータを取得して表示 + 条件を変更して再取得する機能
2. use_stateを用いて、リアルタイム検索
3. pathParameterを用いて、別ページ遷移時に、パラメータの値を画面に表示

参考にした記事・動画は以下の通りです。
**use_effectを用いてデータ取得**

https://www.youtube.com/watch?v=Bfi9jT9WZtk

**use_stateを用いて、リアルタイム検索**

https://reactpy.dev/docs/guides/managing-state/sharing-component-state/index.html#filterable-list

**pathParameterを用いて別ページでその情報を表示**

https://www.youtube.com/watch?v=6EwZBU65bHs&t=3s
### フォルダ構成
Reactに近い形にしたいと思って、pagesとcomponentsとconfigにしました。
(componentsフォルダには参考記事のリポジトリのコードも含まれています。)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/32c4d432-8784-76bb-d928-6e3e24e70d8f.png)
# 実装を通じて
### 全体的な実装の進めやすさ
他の言語と比べれば、Pythonは比較的シンプルに記述できる言語だと思ってます。
Reactの要素が入った場合に、複雑になってしまうのか？実装がほぼ完成した段階で、コードを眺めてみました。個人的な感覚で恐縮ですが、Djangoのように「pythonで処理を実装し、htmlファイルに対して変数の値を埋め込む」といった、ファイルを見比べて実装するよりは、進めやすかったです。
##### アプリケーションの起動
ここはFastAPIの話になりますが、以下のようにPythonコマンドで起動できます。(`view.py`はリポジトリのルートディレクトリにあるファイル)
```
python view.py
```
ですが、開発中はホットリロードが助かるので、以下コマンドで起動した方がいいかもしれません。
```
uvicorn view:app --reload
```
##### 共通Layoutの作成
reactとほぼ同じでした。使用するライブラリがどれか分かれば、あと実装するだけでした。
ページの中心部分に表示する内容をページごとに設定します。
今回以下画像の黒線の外側（ピンク線の内側）を、共通レイアウトとしました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/cb42e416-c725-d08f-cc49-e082450978ed.png)
```react:layout.py
from reactpy.core.types import VdomChildren

@component
def Layout(children: VdomChildren):
return html.main(
        html.div(
            {
                "class": "bg-gray-200 min-h-screen dark:bg-gray-900 dark:text-white",
            },
            html.div(
                {
                    "class": "container mx-auto p-2",
                },
                html.div(
                    {"class": "flex justify-center items-center my-8"},
                    html.img(
                        {
                            "src": "https://reactpy.dev/docs/_static/reactpy-logo-landscape.svg",
                            # "src": "https://avatars.githubusercontent.com/u/106191177?s=200&v=4",
                            "style": {"width": "35%"},
                            "alt": "reactPy",
                        }
                    ),
                ),
                children,
            ),
        ),
    )
```

##### エラーへの対応
特定の画面を表示しようとした場合に、エラーが発生することはあります。そのときは、コンソールにその内容が表示されます。本格的な実装までしてはいないので断定できないですが、Chromeのデベロッパーツールにエラーは表示されず、基本的にはコンソールに表示されていました。

```react
return html._(
    html.label(
        "Search by Food Name: ",
    ),
    html.input(
        {
            "class": "my-2 round",
        },
        {"value": value, "on_change": handle_change}),
)
```
**コンソール**
> TypeError: 'input' nodes cannot have children.

ブラウザではエラーが発生すると、該当箇所（ピンク枠部分）は表示されない
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/9794ad57-d1d3-6d2b-63e4-de286f801530.png)
指示に従って修正します。
```react
return html._(
    html.label(
        "Search by Food Name: ",
    ),
    html.span(
        {
            "class": "round",
        },
        html.input(
            {"value": value, "on_change": handle_change}),
    )
)
```
デザイン適用前なので、見栄えはよろしくないですが・・・
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/f1cdb76d-f55f-d0a3-5a69-450ca956b5a6.png)
##### エラーはないが、画面に要素が表示されない
恥ずかしい話ですが、Pythonのインデント階層を間違えました。
テキストフィールドを表示しようと思って実装しましたが、画面に表示されず・・・
間違っていたときの実装は以下の通りです。
正しくは`Search`メソッドに対するreturnに対してhtml要素を設定するのですが、
そのすぐ下にある`handle_change`メソッドのreturnとしてhtml要素を設定してしまいました。pythonの文法上はエラーではないため、なぜ？？と15分くらいは時間を要したところでした。
```react
@component
def Search(value, set_value):
    def handle_change(event):
        set_value(event["target"]["value"])

        return html._(
            html.label(
                "Search by Food Name: ",
            ),
            html.span(
                html.input(
                    {"value": value, "on_change": handle_change}),
            )
        )
```
##### use_effectを用いたデータ取得
qiita APIからデータを取得する処理を実装しました。
データが取得できる場合・そうでない場合があるので、出力制御を加えるといった処理を加えてみました。（Qiita APIでデータ取得するときに、たまに403エラーが発生することがある・・・）
またデータ取得するだけでは少々面白さに欠けるので、日付とストック数を入力して、動的にデータを取得できる機能を追加しました。
入力項目が設定されているので、バリデーションも合わせて実装しています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/098a95a9-ccda-2f9a-fe38-5d13c4109ed1.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/b0311104-2f68-8105-d259-a8abbd23469d.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/44d860dd-1d00-08fe-8f0d-c1448867a3c5.png)

実装を貼り付けました！多少長めかと思うので、折りたたんでいます。
最後にコードのリポジトリも載せていますので、そちらで確認いただく形でも大丈夫です！！
<details><summary>【実装】</summary>

```python
from reactpy import html, component, use_state, use_effect, event
from src.components.layout import Layout
from src.components.returnhome import ReturnHomeButton
import requests
from datetime import date, timedelta


@component
def pageTitle():
    return html.div(
        {
            "class": "text-center mb-4",
        },
        html.label(
            {
                "class": "text-4xl font-serif text-green-500",
            },
            "Qiita Titles"
        ),
    )


@component
def TableHeader():
    style_table_header_td = ["text-center w-1/3 text-xl font-semibold"]
    return html.tr(
        html.td(
            {"class": style_table_header_td},
            "作成日(from)"
        ),
        html.td(
            {"class": style_table_header_td},
            "作成日(to)"
        ),
        html.td(
            {"class": style_table_header_td},
            "ストック数"
        )
    )


@component
def Titles(data, search_content):
    # Qiitaのデータ取得結果によって、表示内容制御
    if (len(data) > 0):
        display_html = html.div(
            {
                "class": "text-center",
            },
            html.ul(
                [
                    html.li(
                        {
                            "class": "text-xl font-serif my-2",
                        },
                        html.span(
                            {
                                "class": "text-xl my-2 text-blue-500 hover:underline hover:text-blue-700",
                            },
                            html.a(
                                {"href": f"{i['url']} ", "target": "_blank"},
                                f"{i['title']}",
                            ),
                        ),
                        html.span(
                            f" ( get {i['stocks_count']} stock Count ) "
                        ),
                    ) for i in data
                ])
        )
    else:
        display_html = html.div(
            {
                "class": "text-center text-xl text-red-500 mt-8",
            },
            "Sorry... Fetch No Data Or Happened Error"
        )
    return html.div(
        {
            "class": "text-center",
        },
        html.div(
            {
                "class": "text-md font-semibold mb-8 text-gray-500",
            },
            search_content
        ),
        display_html
    )


# 画面に表示するDummyデータ
dummy_data_obj = {
    "title": "Dummy(Failed Fetch)",
    "url": "https://qiita.com/api/v2/docs",
    "stocks_count": 10
}


@component
def Data():
    # 直近2週間に公開されて、100より多くストックされている記事のデータ取得
    init_to_date = date.today()
    td = timedelta(days=14)
    init_from_date = init_to_date - td
    # 画面で管理する値
    from_date, set_from_date = use_state(str(init_from_date))
    to_date, set_to_date = use_state(str(init_to_date))
    stock_count, set_stock_count = use_state(100)
    search_content, set_search_content = use_state('')
    msg, set_msg = use_state('')
    data, set_data = use_state([])

    # QiitaのAPIからデータ取得
    url, set_url = use_state(
        f'https://qiita.com/api/v2/items?page=1&per_page=50&query=created:>={from_date}+created:<={to_date}+stocks:>={stock_count}')

    def get_data():
        set_search_content(
            f'※{from_date}～{to_date}の作成記事で、ストック数が{stock_count}以上を表示しています')
        r = requests.get(url)
        if r.status_code == 200:
            data = r.json()
            set_data(data)
        else:
            print("Fetch Data Error")
            set_data([dummy_data_obj])

    # 初回データの読み込み
    use_effect(get_data, [])

    # available research button
    def available_research():
        if msg == '':
            return False
        else:
            return True

    # validation helper
    def check_under_one(value=stock_count):
        if not value:
            return True
        if int(value) < 1:
            set_msg("ストック数は1以上を設定してください")
            return True
        return False

    # validation helper
    def check_date_order(from_date, to_date):
        if from_date >= to_date:
            set_msg("作成日(to)は作成日(from)より未来を設定してください")
            return True
        return False

    # validation
    def check_allowed_search(value, field):
        if field == 'from_date':
            if check_date_order(value, to_date):
                return False
            if check_under_one():
                return False
        if field == 'to_date':
            if check_date_order(from_date, value):
                return False
            if check_under_one():
                return False
        if field == 'stock_count':
            if check_under_one(value) or check_date_order(from_date, to_date):
                return False
        set_msg('')
        return True

    # 作成日(from)更新時の処理
    def update_from_date(value):
        set_from_date(value)
        if check_allowed_search(value, 'from_date'):
            set_url(
                f'https://qiita.com/api/v2/items?page=1&per_page=50&query=created:>={value}+created:<={to_date}+stocks:>={stock_count}')

    # 作成日(to)更新時の処理
    def update_to_date(value):
        set_to_date(value)
        if check_allowed_search(value, 'to_date'):
            set_url(
                f'https://qiita.com/api/v2/items?page=1&per_page=50&query=created:>={from_date}+created:<={value}+stocks:>={stock_count}')

    # ストック数更新時の処理
    def update_stock_count(value):
        if not value:
            # invalid literal for int() with base 10: ''
            return
        set_stock_count(value)
        if check_allowed_search(value, 'stock_count'):
            set_url(
                f'https://qiita.com/api/v2/items?page=1&per_page=50&query=created:>={from_date}+created:<={to_date}+stocks:>={value}')

    # テーブルの列要素
    @component
    def TableBody():
        style_table_rows = ["px-2 py-2 rounded-md m-2"]
        from_date_td = html.td(
            html.input(
                {
                    "class": style_table_rows,
                    "type": "date",
                    "value": from_date,
                    "on_change": lambda event:
                    {
                        update_from_date(event["target"]["value"])
                    },
                },
            ),)
        to_date_td = html.td(
            html.input(
                {
                    "class": style_table_rows,
                    "type": "date",
                    "value": to_date,
                    "on_change": lambda event:
                    {
                        update_to_date(event["target"]["value"])
                    }
                },
            )
        )
        stock_count_td = html.td(
            html.input(
                {
                    "class": style_table_rows,
                    "type": "number",
                    "value": stock_count,
                    "on_change": lambda event:
                    {
                        update_stock_count(event["target"]["value"])
                    }
                },
            )
        )
        return html.tr(from_date_td, to_date_td, stock_count_td)

    @component
    def Table():
        # 検索情報設定
        return html.div(
            {"class": "flex justify-center"},
            html.table(
                html.thead(TableHeader()),
                html.tbody(TableBody())
            ),
        )

    @component
    def ValidationMsg():
        # バリデーションメッセージ表示
        return html.div(
            {"class": "text-center font-bold text-red-600 mb-4"},
            msg
        )

    # データ再取得
    def re_get_data():
        get_data()

    @component
    def ReGetButton():
        # 再検索ボタン
        return html.div(
            {"class": "flex justify-center mt-2"},
            html.button(
                {
                    "class": "font-semibold rounded items-center bg-green-400 disabled:bg-gray-400 disabled:text-gray-600 hover:bg-green-600 px-4 py-2 mb-4",
                    "disabled": available_research(),
                    "on_click": lambda event: {re_get_data()}
                },
                "再検索"
            ),
        )

    children = html.div(
        ReturnHomeButton(),
        pageTitle(),
        Table(),  # 検索情報設定
        ValidationMsg(),  # バリデーションメッセージ表示
        ReGetButton(),  # 再検索ボタン
        Titles(data, search_content)  # 取得結果表示
    )
    return Layout(children)

```
</details>


##### tailwindcssの補完機能
reactでtailwindcssを使って、デザイン適用をされたことがある方はご存じかと思いますが、VsCodeで実装中、classNameの中で、途中まで入力して`ctrl + space`を押すと、候補が表示されます。
ですが、reactPyでtailwindcssを用いた場合に、それが今のところは機能しませんでした。未経験の状態で導入される場合は、最初は大変かもしれません！

# 完成
### 画面レイアウト
以下のような画面が完成しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/b649a404-1e4e-2cd2-2995-8373fc9d2b72.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/b10bec65-63bc-d510-a8e4-49caa01cadcf.png)


### デプロイ
冒頭にも記載した通り、fly.ioにデプロイしました。
ターミナルで以下を入力します。（インストール手順は[こちら](https://fly.io/docs/hands-on/install-flyctl/)です。）
```
flyctl launch
```
Regionを最初に聞かれ、その後Database(Postgresql),UpStash RedisについてSet Upするか質問されます。今回は`No`を選択して進めます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/cc340226-dd07-f826-970f-7d68412ce71e.png)
コマンド実行後に、ファイルが追加されていることが確認できます。（文字色が緑色のファイルが追加されたファイルです）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/3ff60d9b-6917-025a-fcdb-a5d3e49c07ca.png)
この後、`Procfile`というファイルを編集します。`to fit your needs`とあるように、自身の環境に適した形に修正します。
```
# Modify this Procfile to fit your needs
web: gunicorn server:app
```
私は以下のように変更しました。
```
# Modify this Procfile to fit your needs
web: uvicorn view:app --host "0.0.0.0" --port "8080"
```
※ホストの設定については、fly.ioのログに以下エラーが表示されたため
```
instance refused connection. is your app listening on 0.0.0.0:8080? make sure it is not only listening on 127.0.0.1
```
fastapiのサイトを参考に設定しました。

https://fastapi.tiangolo.com/ja/deployment/docker/
以下コマンドを実行してデプロイが開始されます。
```
flyctl deploy
```

**デプロイ完了後のマネジメントコンソール**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/5be98589-b7bd-ec4b-f77b-0198947d10b5.png)
**ブラウザで確認**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/5c0636fb-2b9c-041c-d2b2-6a4f3ea94410.png)
**fly.ioのMonitoring画面**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/4db3dc0b-3083-8e08-1d14-63316d71d98b.png)

# 今後と実装詳細
今回はGETリクエスト中心のサイトとなっています。公式ドキュメントを見ると、今後登場する機能で気になるところはあります。
今後の機能拡張を眺めながら、POSTリクエストを用いた実装・DBとのやりとり、などなど、より大きなものにできればと思います。

作成したサイトのコードです。

https://github.com/eno-conan/reactPy-sample

--- 

**更新履歴**
| 更新日付 | 内容| 
|:------------:|:-----------|
| 23/06/15     | Qiita APIのデータ取得：動的に再検索できるよう、日付とストック数の条件追加|