
Reactサンプルアプリ開発 プラニングポーカー (10) Bidの通知
まず初めに前回の投稿で書いた以下の設定はNGだったので修正する。具体的には、WebSocket通信が一定数で404 Bad request
になってしまう現象が起こった。どうやらgunicorn
のロードバランシング機能がスティッキーセッションをサポートしていないのが原因らしい。
web: gunicorn app:app --workers 2 --thread 4 --log-file -
代わりにeventlet
やらをインストールして非同期モードで動作させればよいらしい。
(venv) $ pip install eventlet
(venv) $ pip freeze > requirements.txt
SocketIO
初期化時の引数にasync_mode
を指定する。
# SocketIO
socketio = SocketIO(app, engineio_logger=app.logger, async_mode="eventlet")
Procfile
は以下。
web: gunicorn --worker-class eventlet app:app --workers 1 --thread 1 --log-file -
Bad request問題はこれで解決。
今回のプラニングポーカーの機能実装内容は以下。
- 自分の手札だけ入札(=ポイントを入れること)できるようにする
- 入札したら他のプレイヤーに通知する
- 親プレイヤーが場札をオープンするまでは他のプレイヤーの入札した値は見えないようにする
以下のキャプチャは、他のプレイヤーの入札が親プレイヤーに通知され、?
で表示されている様子を表す。

まず、入札はカードのクリックによって開始するため、自分のカードのみクリックイベントを発火するようにする。Player
コンポーネントのプロパティPlayerProps
にisMe
属性を追加し、この値の真偽によって切り替えるようにした。
<div className={`player-card ${playerCardOpen}`} onClick={props.isMe ? handleModalOpen : undefined}>
<span className="point">{point}</span>
</div>
onClick={props.isMe ? handleModalOpen : undefined}
という記述により条件分岐するようにしている。

上記のモーダルダイアログでポイントを選択するとイベントが発火し、親コンポーネントに伝搬する(propsに渡されたコールバックを通じて)。
そのイベントハンドラはTablePage
コンポーネントに定義してある。
const handleBidChange = (bid: string, idx: number) => {
const ps = players.slice();
console.log('handleBidChange was called.')
ps[idx].bid = bid;
setPlayers(ps);
setTimeout(() => {
socket.emit("bidding", idx, bid);
}, 0);
}
return (
<>
<header className="header">
<span className="table-name">{props.tableName}</span>
</header>
<main>
<ParentOperations bidding={bidding}
onOpen={handleOpen} onNewGame={handleNewGame} />
<GameResults />
<Players players={
players.map((p, idx) => Object.assign(p,
{ onBidChange: (bid: string) => handleBidChange(bid, idx) }))
} />
</main>
</>
22行目、子コンポーネントのonBidChange
プロパティからコールバック関数(1-9行目)を呼ぶようにしている。7行目で、WebSocketのイベントを(非同期で)emit。
このbidding
イベントを受けるFlaskアプリケーションのハンドラは以下。
@socketio.on("bidding")
def handle_bidding(player_id, bid):
payload = {"playerId": player_id, "bid": bid}
print(f"Player {player_id} has made a bid of {bid}, broadcasting...")
emit("bidded", payload, broadcast=True)
Flask-SocketIO
のemit
関数を使って、全クライアントにブロードキャストを行う(5行目)。
ブロードキャストされたbidded
イベントのクライアント側ハンドラは、これもTablePage
関数コンポーネント内に定義している。
useEffect(() => {
socket.on("bidded", (data: any) => {
console.log(`Player ${data.playerId} has changed the bid: ${data.bid}`);
const idx: number = data.playerId;
const updatedPlayer = { ...players[idx], ...{ bid: data.bid } };
const updatedPlayers = players.map((p, i) => i === idx ? updatedPlayer : p);
setPlayers(updatedPlayers);
});
return () => {
socket.off("bidded");
}
}, [players]);
まず第一に、WebSocketのイベント受信を受けた画面更新はバリバリの副作用なので、当然のごとくuseEffect
を使って記述する。socket.on
の第2引数にイベント受信時の処理を記述(2-8行目)。サーバーから入札したプレイヤーのIDと入札値を受け取るので、プレイヤー情報を更新する。ここでsetPlayers
はステート変数更新用の関数であり、useState
により作成したものである(以下のコード)。
const [bidding, setBidding] = useState(true);
const [players, setPlayers] = useState<PlayerProps[]>([]);
さて先程のuseEffect
で注意すべき点が2つある。
一つ目。この処理ではステート変数players
を参照し、setPlayers
により更新をかけている。つまりplayers
に依存している。この場合、useEffect
の第2引数に指定することでReactにその依存性を教えてあげなければならない。
useEffect
の第1引数に渡したコールバック関数内で参照するplayers
は、コンポーネントのレンダリング時点のplayers
であり、最新の状態とは限らない。第2引数の配列にplayers
を渡すと、players
が変更されたことをReactが検知してコンポーネントの再レンダリングを行う際に、当該useEffect
のコールバック関数が再度実行されることになる。つまり、コールバック関数内のplayers
が最新の状態を参照していることが保証されるのだ。
最初に一度だけ実行したい処理の場合はuseEffect
の第2引数を空配列にせよ、という理由はこれである。空配列→依存するステート変数やプロパティがなしということで、再レンダリングが走ってもコールバック関数は再実行されないということだ。
さきほどのuseEffect
のコードを再掲する。
useEffect(() => {
socket.on("bidded", (data: any) => {
console.log(`Player ${data.playerId} has changed the bid: ${data.bid}`);
const idx: number = data.playerId;
const updatedPlayer = { ...players[idx], ...{ bid: data.bid } };
const updatedPlayers = players.map((p, i) => i === idx ? updatedPlayer : p);
setPlayers(updatedPlayers);
});
return () => {
socket.off("bidded");
}
}, [players]);
先に述べたとおり、コンポーネントの再レンダリングが走り、players
の中身が変更されている場合はuseEffect
に渡したコールバック関数が再度実行される。
ということは、2行目のsocket.on
が再度呼ばれることになるのだが、socket.io
では一つのイベントに複数のリスナーが登録可能なため、置き換えではなく追加となってしまう。
このままでは同じ内容の複数のイベントリスナーが登録されてしまう。再レンダリングの度に倍々ゲームで増えていく…。
この現象を回避するために、再度コールバック関数が呼ばれるまえに後始末のコードを実行したい。これは、コールバック関数の戻り値として、後始末用のコールバック関数を返せばよいことになっている。
上記コードの9-11行目がそれだ。socket.off
により、bidded
イベントを監視するリスナーが全削除される。
これが二つ目の注意点。必要に応じ、後始末用の関数を返すこと。
最後に、場札がオープンされる前は他のプレイヤーの入札値は見えないようにする対応。これは単なる条件式をつけるだけ。
// オープン前で入力済みの他ユーザーは?を表示する
const point = (!props.open && !props.isMe && props.bid) ? "?" : props.bid;
return (
<div className={`player ${playerIsMe}`}>
<div className="player-info">
<div className={"player-icon-" + props.icon}>
</div>
<div className="player-name">{props.name}</div>
</div>
<div className={`player-card ${playerCardOpen}`} onClick={props.isMe ? handleModalOpen : undefined}>
<span className="point">{point}</span>
</div>
<Modal isOpen={modalIsOpen} onRequestClose={handleModalClose}
contentLabel="test" style={customStyles} >
<Deck points={points} onCardSelected={handleCardSelect} />
</Modal>
</div>
)
今回は少し長くなったが、以上。
現時点のクライント側のコードは次のページに載せておく。