Streamlitで追加・削除ができる動的なユーザ入力を実装する方法

# 1. はじめに
Pythonで手軽にWebアプリが実装できるStreamlitですが、動的なユーザの入力を行うためには工夫が必要です。本記事ではStreamlitでの動的なユーザ入力の実装方法について説明していこうと思います。

# 2. Streamlitとは
StreamlitはPythonで簡単にWebアプリケーションを作成できるオープンソースのライブラリです。
詳細は以下の記事で説明しています。

https://qiita.com/sypn/items/80962d84126be4092d3c

# 3. 動的なユーザ入力の実装方法
次のような足し算アプリをサンプルとして説明していきます。
- デフォルトでは2つの数値入力欄が存在する
- 入力欄にユーザは数字を入力し、"合計"ボタンをクリックすると全テキストボックスに入力された数字の合計が表示される
- ユーザは"追加"ボタンを使って入力欄を自由に追加することができる
- ユーザは"削除"ボタンを使って入力欄を自由に削除することができる
- ただし、入力欄の数は2より少なくならない
## 3.1. ベースの実装
まずは入力欄の数を2つに固定した場合のコードをベースコードとして書いていきます。ベースコードは以下になります。
```python:app.py
import streamlit as st


st.title("足し算アプリ")

# st.number_inputを2つ用意
value_1 = st.number_input("数字1", value=0)
value_2 = st.number_input("数字2", value=0)

# 合計ボタン
if st.button("合計"):
    # 合計ボタンが押されたら数値を合計し表示
    total = value_1 + value_2
    st.write(f"合計値: {total}")
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3323087/9aad1e29-e438-8464-68ac-7cf0ee094d34.png)

ここから追加と削除ができるように修正していきます。
## 3.2. 入力欄の追加
入力欄の追加には`st.session_state`と`st.button()`の引数`on_click`を使います。
`st.session_state`は画面の再表示の際に再表示前の値を保持することのできる機能です。
Streamlitはボタンを押した際に画面の更新が行われます。`st.button()`の引数`on_click`にコールバック関数を指定することでボタンの押下と画面の更新の間に処理を挟むことができます。
こちらの参考サイトではより詳細に解説をしてくださっています。

https://data-analytics.fun/2022/07/11/streamlit-state-callback/

:::note warn
Streamlitでは`st.rerun()`を使って任意のタイミングで画面を更新することが可能ですが、副作用が生じる可能性があるため、コールバック関数が設定できる場合はコールバック関数を利用することが公式では推奨されています。

https://docs.streamlit.io/library/api-reference/control-flow/st.rerun

:::

```python:app.py
import streamlit as st


# 追加用コールバック関数
def add_number_input():
    # 初期値0のnumber_inputを増やす
    st.session_state["values"].append(0)


st.title("足し算アプリ")

# session_stateで入力された数値を管理
if "values" not in st.session_state:
    # 初期化。初期のnumber_inputは2つで、初期値は0
    st.session_state["values"] = [0, 0]

# st.number_inputを用意
for i, number in enumerate(st.session_state["values"]):
    st.number_input(
        # widgetごとに一意のKeyを用意
        key = f"number_input_{str(i+1)}",
        label = f"数字{i+1}", 
        value=number
    )

# 追加ボタン
add_button = st.button("追加", on_click=add_number_input)

# 合計ボタン
if st.button("合計"):
    total = 0
    for i, number in enumerate(st.session_state["values"]):
        # session_stateにnumber_inputのKeyで入力値が取得可能
        total += st.session_state[f"number_input_{str(i+1)}"]
    st.write(f"合計値: {total}")
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3323087/eb629e96-9440-4d65-ee9e-e82919d5ab26.png)

## 3.3. 入力欄の削除
入力欄の削除にも`st.session_state`とコールバック関数を使います。
現在入力されている値を画面更新後に表示する`number_inut`の初期値に設定することで、入力欄を削除しても他の入力された数値を保持することができます。また、削除ボタンもそれぞれ一意のKeyを設定する必要があるため、Keyに番号を振っています。
また、Streamlitでは一意のウィジェットのKeyとそのウィジェットの値が`st.session_state`に保持されます。そのため、削除した`number_input`と同じKeyのウィジェットを削除後に作成すると削除前の値が入ってしまいます。なので、ウィジェットは削除の前後で異なるKeyを持たなければなりません。今回は削除ボタンを押した回数を`st.session_state`で保持し、その回数をKeyに利用することで一意のKeyを新しく生成しています。

```python:app.py
import streamlit as st


# 追加用コールバック関数
def add_number_input():
    # 初期値0のnumber_inputを増やす
    st.session_state["values"].append(0)

# 削除用コールバック関数
def delete_number_input(target_no):
    # number_inputは必ず2つ以上
    if len(st.session_state["values"]) > 2:
        # 新しい初期値のリスト
        new_values = []
        for j in range(len(st.session_state["values"])):
            # 削除対象のnumber_inputはスキップ
            if j != target_no:
                # 初期値に現在の値を追加
                new_values.append(st.session_state[f"number_input_{str(j+1)}_{str(st.session_state['count'])}"])
            # st.session_stateに不要な要素が残るので削除           
            del st.session_state[f"number_input_{str(j+1)}_{str(st.session_state['count'])}"]
        # 初期値のリストを更新
        st.session_state["values"] = new_values
        # 削除回数をインクリメント
        st.session_state["count"] += 1

st.title("足し算アプリ")

# session_stateで入力された数値を管理
if "values" not in st.session_state:
    # 初期化。初期のnumber_inputは2つで、初期値は0
    st.session_state["values"] = [0, 0]

# session_stateで削除ボタンが押された回数を管理
if "count" not in st.session_state:
    # 初期化
    st.session_state["count"] = 0

# st.number_inputを用意
for i, number in enumerate(st.session_state["values"]):
    st.number_input(
        # widgetごとに一意のKeyを用意
        key = f"number_input_{str(i+1)}_{str(st.session_state['count'])}",
        label = f"数字{i+1}", 
        value=number
    )
    # 削除ボタン、複数個あるため一意のKeyを設定
    delete_button = st.button("削除", key=f"delete_{str(i+1)}", on_click=delete_number_input, args=(i, ))

# 追加ボタン
add_button =  st.button("追加", on_click=add_number_input)

# 合計ボタン
if st.button("合計"):
    total = 0
    for i, number in enumerate(st.session_state["values"]):
        # session_stateのnumber_inputのKeyで入力値が取得可能
        total += st.session_state[f"number_input_{str(i+1)}_{str(st.session_state['count'])}"]
    st.write(f"合計値: {total}")
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3323087/7f682455-8dbb-f41d-9658-869cf7b21af8.png)


# 4. まとめ
Streamlitで動的なユーザの入力を実装する方法を紹介しました。
ポイントは以下の3つになります。

- `st.session_state`で入力欄の個数と初期値を保持する
- コールバック関数を利用して画面更新の前に入力欄増減の処理を入れる
- 画面更新前後でウィジェットには異なる一意のKeyを付与する

筆者も動的なユーザの入力の実装で困った経験があるので、この記事が誰かの助けになれば嬉しいです。