【Reflex】Spinnerいろいろ

## はじめに
Reflexでボタンを押したら処理が動いて、スピナーを表示しつつボタンや画面全体を非活性にする。といったことがやりたかったので方法をいろいろ調べてみました。

※ドキュメント

https://reflex.dev/docs/library/datadisplay/spinner/

## 実装したもの

https://reflex-spinners.reflex.run/

![画面録画-2024-06-27-181358.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/649608/18b13327-0121-afcc-4534-5b8a2f294992.gif)

![画面録画-2024-06-27-181459.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/649608/07203120-75fc-dc71-c0a2-29d508ee2fb9.gif)


※リポジトリ

https://github.com/ppspps824/reflex_spinners

## つまづいたところ
### 一連の処理の中でSteteの更新をUIに反映させる方法がわからない。

単純に↓のように書いても```heavy_process```内でUIは更新されません。(```heavy_process```が終了したタイミングで一括で更新されるので、```self.inside_process = True```が効かない)

```python
class State(rx.State):
    inside_process: bool = False
    
    def heavy_process(self):
        self.inside_process = True
        time.sleep(3)
        self.inside_process = False

def index():
    return rx.center(
            rx.button(
                "Inside",
                loading=State.inside_process,
                on_click=State.heavy_process,
            ),
```

どのようにすれば反映されるかというと、以下のように```yield```を挟んでやるとうまくいきました。
```python
class State(rx.State):
    inside_process: bool = False
    
    def heavy_process(self):
        self.inside_process = True
+       yield
        time.sleep(3)
        self.inside_process = False

def index():
    return rx.center(
            rx.button(
                "Inside",
                loading=State.inside_process,
                on_click=State.heavy_process,
            ),
```

https://reflex.dev/docs/events/yield-events/

> 関数内のすべてのyieldに対して、イベントハンドラの実行のこの時点までの変更とともにフロントエンドに送信されます。

Spinnerに限らずイベントハンドラの中でUIを細かく更新したい場合はyieldを利用すればよさそうです。

### 各ボタンを非同期で動かす方法がわからない

- 必須ではないですが、便利なのでデコレータを作りました。

    - 非同期処理におけるStateの更新はコンテキストマネージャで行う必要があります。
        ```async with self:```を指定すれはOKです。この指定をしないと以下のエラーになります。
        ```
        reflex.utils.exceptions.ImmutableStateError: Background task StateProxy is immutable outside of a context manager. Use async with self to modify state.
        ```
        ※コンテキストマネージャ(with)使わないと値は書き換えられないよ、ということですね。
    - バックグラウンドタスクとして```@rx.background```を付与します。
        https://reflex.dev/docs/events/background-events/



```python
def set_loading(attr_name):
    def decorator(func):
        @wraps(func)
        @rx.background
        async def wrapper(self, *args, **kwargs):
            async with self:
                setattr(self, attr_name, True)
            yield
            try:
                await func(self, *args, **kwargs)
            finally:
                async with self:
                    setattr(self, attr_name, False)

        return wrapper

    return decorator
```

こんな感じで使えます。
``` python
class AsyncState(rx.State):
    overlay_processing: bool = False
    inside_processing1: bool = False
    inside_processing2: bool = False

    @set_loading("inside_processing1")
    async def heavy_process1(self):
        await asyncio.sleep(3)

    @set_loading("inside_processing2")
    async def heavy_process2(self):
        await asyncio.sleep(7)

    @set_loading("overlay_processing")
    async def heavy_process3(self):
        await asyncio.sleep(5)

def index():
    return rx.box(
        CircleSpinnerOverlay.create(
            loading=AsyncState.overlay_processing, message="Processing..."
        ),
        rx.center(
            rx.heading("Async Spinners"),
            rx.button(
                "Inside1",
                loading=AsyncState.inside_processing1,
                on_click=AsyncState.heavy_process1,
            ),
            rx.button(
                rx.spinner(loading=AsyncState.inside_processing2),
                "Inside2",
                on_click=AsyncState.heavy_process2,
                disabled=AsyncState.inside_processing2,
            ),
            rx.button(
                "Overlay",
                on_click=AsyncState.heavy_process3,
            ),
            direction="column",
            spacing="4",
            height="100vh",
            justify="center",
        ),
    )
```


### 全画面にオーバーレイする方法がわからない
CSSを頑張れば行けると思うんですが、今回は[react-spinner-overlay
](https://www.npmjs.com/package/react-spinner-overlay/v/0.1.12)を利用しました。

- npmのパッケージから取り込み(```message```や```loading```などは必要なpropsを指定できます。)
```python
class CircleSpinnerOverlay(rx.Component):
    library = "react-spinner-overlay"
    tag = "CircleSpinnerOverlay"
    message: rx.Var[str] = ""
    loading: rx.Var[bool] = False
```


このような感じで呼び出せます。
```python 

def index():
    return rx.box(
        CircleSpinnerOverlay.create(
            loading=AsyncState.overlay_processing, message="Processing..."
        ),
        rx.button(
                "Overlay",
                on_click=AsyncState.heavy_process3,
            )
        )    
```

## おわりに
Streamlitより複雑な感じがしますが、細かい調整ができる点や、npmのパッケージを簡単に取り込めるのは非常に便利だなと思いました。