Reflex+PyScriptでFull Python, No BackendなReactアプリを作る

## 概要

PyScriptと連携して使えそうなブラウザUI(Dashboard系)ライブラリを探していたところ、[Reflex](https://reflex.dev/)というフレームワークを見つけまして、すごく面白そうだと思いましたので深堀りしてみました。

## Reflexとは

表層的な説明やインストール方法はググってもらうとして、このフレームワークの特徴はPython言語で記述したUI定義をJavaScript…正確にはNext.jsページコンポーネントにトランスパイルしてブラウザで動作させ、それ以外はPythonコードのままFastAPIサーバーで動作させてお互いをWebSocketで連携することにより、Python言語のコーディングだけでReactのエコシステムに便乗したフルスタックのWebアプリを成立させようというフレームワークです。

トランスパイルする部分をUI定義だけに限定し、UI定義内のロジックの記述も制限することでトランスパイル工程をシンプルに保っているのがミソなんだと思います。

これを見てUI定義をNext.jsページにトランスパイルする仕組みを利用しつつ、本来FastAPI上でバックエンドとして実行するPythonロジック部分をブラウザ上でPythonを動かすPyScript上で実行するようにすればバックエンドなしでReact(Next.js)アプリを作れるんじゃないかと思いました。

ちなみにNext.jsの機能として静的HTML+jsにエクスポートする機能があるのでバックエンドが絡む機能を使わなければバックエンドは不要です(公式のReflexフレームワーク的にはバックエンドなしでPythonロジックをほとんどかけないので実質意味がないだけ)

## 実装

よくあるDashboard系ライブラリと違い、ReflexはReactのエコシステムに乗っかることを前提としているためなのか、UI定義内へのJavaScriptコードの挿入に非常に寛容な設計となっています。
このおかげでシンプルにPyScriptへの対応ができました、Reflexコンポーネントの流儀にそってPythonコードと最小限のJavaScriptコードを記述するだけといった感じでした。

```pyscomponent.py
import reflex as rx
from reflex.utils import imports


class InitPyScript(rx.Fragment):
    """
    PyScriptの初期化を行うコンポーネント
    """

    @classmethod
    def create(cls, auto_ready=True, version="2025.3.1"):
        """
        PyScriptの初期化を行うコンポーネントをページに追加します

        HooksでPyScript実行前にReflexからPyScriptの関数呼び出しが行われないようにglobalThis.pyScriptReadyというPromiseを使って呼び出しを待機しています。
        通常はPyScriptのdoneイベントで解決されますが、doneイベントを発火しないpy-gameモードやPyScriptコードがループしている等で終了しない場合は
        auto_readyをFalseに設定し、PyScriptコード内でjs.pyScriptReady.resolve()を適切なタイミングで手動で呼び出して解決する必要があります。

        :param auto_ready: PyScriptの実行が完了したら自動でpyScriptReadyを解決するかどうか
        :param version: PyScriptのバージョン
        """
        return super().create(
            rx.Script.create(
                src=f"https://pyscript.net/releases/{version}/core.js",
                custom_attrs={"type": "module"},
                strategy="beforeInteractive",
            ),
            rx.Script.create(
                f"""
                globalThis.pyScriptReady = (() => {{
                    let resolve;
                    const promise = new Promise((res) => resolve = res);
                    return {{ promise, resolve }};
                }})();

                if ({'true' if auto_ready else 'false'}) {{
                    addEventListener("py:done", globalThis.pyScriptReady.resolve, {{ once: true }});
                    addEventListener("mpy:done", globalThis.pyScriptReady.resolve, {{ once: true }});
                }}
                """,
                custom_attrs={"type": "module"},
                strategy="beforeInteractive",
            ),
        )


class InlinePyScript(rx.Script):
    """
    PyScriptコードをインラインで記述するコンポーネント
    """

    @classmethod
    def create(cls, code, type="mpy", config="{}"):
        """
        PyScriptのコードをインラインで記述するコンポーネントをページに追加します

        :param code: PyScriptのコード
        :param type: PyScriptのタイプ(py, mpy, py-game)
        :param config: PyScriptの設定
        """
        from textwrap import dedent

        formatted_code = dedent(code).strip() + "\n"

        return super().create(
            formatted_code,
            custom_attrs={"type": type, "config": config},
        )


class ExternalPyScript(rx.Script):
    """
    PyScriptコードを外部Pythonファイルから読み込んで実行するコンポーネント
    """

    @classmethod
    def create(cls, src, type="mpy", config="{}"):
        """
        PyScriptコードを外部Pythonファイルから読み込んで実行するコンポーネントをページに追加します

        外部ファイルにアクセスするためには、あらかじめInitPyScriptコンポーネントのconfigでPyScriptの設定を行っておく必要があります

        :param src: PyScriptのコードを記述したPythonファイルのパス
        :param type: PyScriptのタイプ(py, mpy, py-game)
        :param config: PyScriptの設定
        """
        return super().create(
            src=src,
            custom_attrs={"type": type, "config": config},
        )


class BasicHooksImport:
    """
    ReflexでHooksを使用するためのインポートを行うメソッドを定義したクラス

    ReflexやPyScriptコンポーネントでHooksを使用する場合はこのクラスを継承してください
    """

    def add_imports(self) -> imports.ImportDict:
        """
        PyScriptのHooksを使用するためのインポートを行います
        """
        return {
            "react": [
                imports.ImportVar(tag="useState"),
                imports.ImportVar(tag="useRef"),
                imports.ImportVar(tag="useEffect"),
            ]
        }


class UseHooksComponent(BasicHooksImport, rx.Fragment):
    """
    ReflexやPyScriptコンポーネントでHooksを使用できるように設定済みのベースコンポーネント
    """

    pass


def sanitize_value(data):
    """
    値がNoneの場合はnullを返し、値が文字列の場合はエスケープ処理を行ったうえで""で囲む
    """
    return (
        "null"
        if data is None
        else f'"{data.replace("\\", "\\\\").replace('"', '\\"')}"' if isinstance(data, str) else str(data)
    )


def define_useState(var_name, initial_value=None, prefix=""):
    """
    useStateを定義して、PyScriptからアクセスできるようglobalThisに登録します

    :param var_name: useStateの変数名
    :param initial_value: useStateの初期値、Noneの場合はnullを設定する
    :param prefix: グローバル変数登録時に付与するプレフィックス
    """
    initial_value = sanitize_value(initial_value)
    return f"""
        var [{var_name}, set{var_name.capitalize()}] = useState({initial_value});
        globalThis.{prefix}{var_name} = {var_name};
        globalThis.{prefix}set{var_name.capitalize()} = set{var_name.capitalize()};
    """


def define_useRef(ref_name, ref_var=None, prefix=""):
    """
    useRefを定義して、PyScriptからアクセスできるようglobalThisに登録します

    :param ref_name: useRefの変数名
    :param ref_var: useRefの初期値、Noneの場合はnullを設定する
    :param prefix: グローバル変数登録時に付与するプレフィックス
    """
    ref_var = sanitize_value(ref_var)
    return f"""
        var {ref_name} = useRef({ref_var});
        globalThis.{prefix}{ref_name} = {ref_name};
    """


def define_useEffect(effect_func, effect_vars=None):
    """
    useEffectを設定して、useEffectの処理をPyScript内の関数で実装できるようにします

    :param effect_func: useEffectから呼び出す関数名
    :param effect_vars: useEffectの依存関係
    """
    return f"""
        useEffect(() => {{
            async function runEffect() {{
                await globalThis.pyScriptReady.promise;
                globalThis.{effect_func}();
            }}
            runEffect();
        }}{', [' + ', '.join(effect_vars) + ']' if effect_vars is not None else ''});
    """


def call_pyscript_func(func_name, *args):
    """
    コールバックとしてPyScriptの関数を呼び出せるようにします

    :param func_name: PyScriptの関数名
    :param args: PyScriptの関数に渡す引数
    """
    return rx.call_script(
        f"""
            (async () => {{
                await globalThis.pyScriptReady.promise;
                globalThis.{func_name}({', '.join(args)});
            }})();
        """
    )
```

このコードをインポートすればこんな感じでReflexコンポーネントのロジックをPyScriptで実行してブラウザだけで完結することができます。

```py
import reflex as rx
import pyscomponent as pys


# PyScriptを利用してフロントエンドだけで動くシンプルなカウンタコンポーネント
class SimpleCounterComponent(pys.UseHooksComponent):
    @classmethod
    def create(cls):
        return super().create(
            # PyScriptのコードをインラインで記述(ExternalPyScriptコンポーネントで外部PyScriptファイルを指定して実行も可能)
            pys.InlinePyScript.create(
                """
                import js

                # useStateで保持しているカウンタを1進めて、useRefで参照している要素の表示を更新する
                def increment_counter():
                    js.setCounter(js.counter + 1)
                    js.text_ref.current.innerHTML = f"Count: {js.counter + 1}  using useRef and innerHTML"

                # カウンタの値が変わったらuseEffect経由でこの関数が呼ばれるのでタイトルを更新する
                def change_title():
                    js.document.title = f"Counter: {js.counter} - Reflex PyScript Example"

                # Reflexから呼び出せるようにjs(globalThis)に関数を登録する
                js.increment_counter = increment_counter
                js.change_title = change_title
                """
            ),
            rx.vstack(
                # useStateの値をコンポーネントに渡す場合はrx.Varを使う
                rx.heading(f"Count: {rx.Var('counter')} (Using useState)", size="5"),
                rx.button(
                    "Increment",
                    # ボタンをクリックしたらPyScriptの関数を呼び出す
                    on_click=pys.call_pyscript_func("increment_counter"),
                ),
                rx.text(
                    "This is a simple counter example using PyScript.",
                    font_size="2xl",
                    # useRefにこの要素の参照を登録する
                    custom_attrs={"ref": f"{rx.Var('text_ref')}"},
                ),
                spacing="5",
                justify="center",
                min_height="85vh",
            ),
        )

    # Hooks関係の定義はadd_hooksメソッドを継承して行う
    def add_hooks(self) -> list[str | rx.Var]:
        return [
            # useStateでカウンタの値を記録
            pys.define_useState("counter", 0),
            # useRefでDOM要素の参照を保持
            pys.define_useRef("text_ref"),
            # useEffectでカウンタの値が変わったらPyScriptの関数を呼び出す
            pys.define_useEffect("change_title", ["counter"]),
        ]


def index() -> rx.Component:
    # Welcome Page (Index)
    return rx.container(
        pys.InitPyScript.create(),
        rx.color_mode.button(position="top-right"),
        rx.vstack(
            rx.heading("Welcome to Reflex!", size="9"),
            SimpleCounterComponent.create(),
        ),
    )


app = rx.App()
app.add_page(index)

```

## いろいろ改造してみた

この方法だとどうしてもグローバル変数に頼らざるを得ない関係上複数同時にコンポーネントを使いまわすときに問題が出てくるので、そこら辺を対策したらちょっと複雑になってしまいました。

https://github.com/uniras/PyScriptBridge