WebSocketで理解するReact Context
こんにちは!スペースマーケットフロントエンドエンジニアの和山です!
オリンピックが盛り上がっていますね!
僕はバドミントン部だったこともあり、すでに終わってしまいましたがバドミントンの試合をライブ配信で視聴していました!
僕の一推し選手は園田選手と嘉村選手です。
2007年の佐賀IHの男子団体である埼玉栄と八代東の決勝を監督が撮影してきてくれて何度も見た記憶があります。(あの映像どこにいっただろう・・・まだどこかにある気が)
そんな当時憧れだった選手がいまだに現役でプレーされており、オリンピックに出るというこんなに嬉しいことはありません。
負けてはしまいましたが、セティアワン選手とアッサン選手との試合は本当に勇気をもらえる良い試合でした!
と、このままだと話が止まらなくなってしまうので、本題に戻ります。
皆さん、Context使ったことがありますか?
おそらく今までReduxを使っていたプロジェクトだとそうそうContextへ移行することはなく、いまだによく分からないという方もいらっしゃるのではないでしょうか?
ということで、今回は弊社の事例も踏まえて「Contextの使い方を理解する」という部分に主軸を置き、サンプルのプロダクトを作りながら説明していきたいと思います。
あくまで「こうやって使うのか」という観点を主軸においているので、性能面は考慮していないのでご了承ください!
説明なんかいいからコードを見せて!という方はこちらのリポジトリにサンプルを用意したのでご覧ください。
ちなみに今回はNext.jsとExpressを使っていきます。
はじめに
ということで初めて行こうと思うのですが、そもそもどんなものを作るかという話をしなければいけません。
今回は弊社の事例も踏まえておりますので、一度弊社のサービスについてご紹介させてください。
弊社のサービスには「スペースマーケットリワード」というプログラムがあります。
簡単に説明すると、スペースの利用やポイント獲得に応じてランクが上がっていく、つまり、使えば使うほど「スペースマーケットの利用がお得になるよー」というとても嬉しいプログラムとなっています。
このスペースマーケットリワードの開発の際に大きな機能が2つありました。
アクションで獲得できるポイント(以降アクションポイントとします)の通知とランクアップの通知の2つの機能です。
アクションポイントとは、スペースマーケット内で使える便利な機能を使うことで得られるポイントになります。
これでは分かりづらいと思いますので具体例を挙げますと、「3つの好きなスペースをお気に入りする」といったものがあります。
このアクションを行うことでチャレンジが達成されると、ポイント付与されるのですが、画面側ではそのことを「○○を達成しました!」ということをトースト表示によって通知しています。
ランクアップの通知とは、見ての通りなのですが、ポイント獲得によって「あなたのランクが上がりました!」ということをお知らせする機能です。
こちらはモーダルによって通知しています。
そしてこの2つの機能は正確には少し違うのですが、サーバからくる非同期な通信によってユーザに通知するようになっています。
と、ここまで説明しましたが、文字だと分かりにくいと思います。
そこで、処理のイメージ図を作ったのでこちらをご覧ください。
ということで今回はこちらの非同期通信をWebSocketで実現し、サンプルとしてそれぞれモーダルトーストと表示するようなものを作っていきたいと思います。
また、こちらの通知機能はアクセスポイントは同じものを使っております。
そのため、コネクションはモーダルもトーストも同じところへ繋ぎ、受け取ったデータによって表示を切り替えるような作りにする必要があります。
ということで前置きが長くなりましたが早速作っていきましょう!
設計
早速作っていくという話をしたのですが、いきなり作り始めると後戻りが効かなくなるのでまずは設計していきましょう。
まず今回作るもののイメージです。
WebSocketでサーバから非同期に通信すると言っても、今回はサンプルです。
画面から操作できる方がいいと思うのでフォームを用意し、「モーダル表示のボタンを押したら通信を行いモーダル用のデータが送られてくる」「トースト表示のボタンを押したら通信を行いトースト用のデータが送られてくる」といった形としています。
今回接続するアクセスポイントは1箇所のみです。
それぞれのコンポーネントが同じアクセスポイントに接続するというのはあまり良い実装とは言えないでしょう。
最悪不具合の原因となる可能性もあります。
もしこれがそれぞれが違うアクセスポイントへ接続するということであれば、モーダルのコンポーネント内ではモーダル通知用のアクセスポイントへ接続し、トーストのコンポーネント内ではトースト通知用のアクセスポイントへ接続するということが可能になります。
その為、今回は1箇所で接続を行いデータをそれぞれのコンポーネントにpropsなどの形式で渡す必要があります。
そうなると、それぞれのコンポーネントを持つ親コンポーネントを作り、親でアクセスポイントへ接続し、モーダルとトーストそれぞれに受け取ったデータから表示非表示を切り替えるということも可能です。
しかしこれだと、「この画面ではトーストは出したくない」「他の通知も出したい」などの要件が上がってきてしまうと処理の分岐が増えたりとかなり複雑なコンポーネントになることが想像できます。
そこで今後出てくるであろう要件にも対応するためにContextを使うことで実装していきたいと思います。
実装したイメージはこんな感じです。
コンポーネントを実装する
まずは簡単にコンポーネントを実装していきましょう。
今回コンポーネントの実装自体はあまりメインではないので、Material-UIのサンプルを使ったりと少し楽させてもらいました笑
作るコンポーネントは、
・ ページ用のコンポーネント
・ モーダル 用のフォーム & トースト用のフォーム
・ モーダル
・ トースト
の4つです。
それぞれのコンポーネントのリンクを貼ったので良い感じに作ってもらえればと思います。
同じように作ってもらうとこんな感じの画面になると思います。
それでは次に本題のContext部分の実装に入っていきたいと思います。
Contextの実装
それではContext部分を実装していきましょう。
弊社ではこの部分を以下の3つのファイルに分けてそれぞれ実装しています。
・コアロジック(カスタムフック )
・Provider
・カスタムフック
今回の説明でも3つに分けて説明していきます。
コアロジックの実装
それではコアロジックの部分を実装していきます。
このコアロジックというのは、Contextとして渡すデータの部分を生成する部分を指しています。
と、言ってもややこしいと思うので実際に作ってみましょう。
import { useState, useRef, useEffect, useMemo, useCallback } from 'react'
import { attachListener } from './handlers'
export const useWebSocketCore = () => {
// Aブロック
// メッセージ
const [lastMessage, setLastMessage] = useState(null)
// JSON形式のメッセージ
const lastJsonMessage = useMemo(() => {
try {
const jsonMessage = JSON.parse(lastMessage?.data)
return jsonMessage
} catch {
return null
}
}, [lastMessage])
// WebSocketインスタンス
const webSocketRef = useRef(null)
// アンマウントかどうかの判別フラグ
const didUnmount = useRef(false)
// Bブロック
useEffect(() => {
webSocketRef.current = new WebSocket('ws://localhost:8000')
if (!webSocketRef.current) {
throw new Error('WebSocket failed to be created')
}
// アンマウント時に実行するListener
let removeListeners
// 接続処理
const start = () => {
if (!webSocketRef.current) return
removeListeners = attachListener(webSocketRef.current, setLastMessage)
}
start()
// アンマウント時の処理
return () => {
didUnmount.current = true
removeListeners?.()
setLastMessage(null)
}
}, [])
// Cブロック
const sendModalMessage = useCallback((message) => {
webSocketRef.current?.send(
JSON.stringify({
type: 'modal',
message,
})
)
}, [])
const sendToastMessage = useCallback((message) => {
webSocketRef.current?.send(
JSON.stringify({
type: 'toast',
message,
})
)
}, [])
return [
{ lastMessage, lastJsonMessage },
{ sendModalMessage, sendToastMessage },
]
}
とこんな感じの実装になります。
どうでしょう!と言ってもややこしいと思うので軽く説明していきますね。
まずコード内に書かれているAブロックというコメントの部分です。
こちらはこのカスタムフック内で保持しておきたいデータ部分です。
// WebSocketの通信で受け取ったメッセージ
const [lastMessage, setLastMessage] = useState(null)
// 受け取ったメッセージをJSON形式にParseした結果
const lastJsonMessage = useMemo(() => {
try {
const jsonMessage = JSON.parse(lastMessage?.data)
return jsonMessage
} catch {
return null
}
}, [lastMessage])
// WebSocketのインスタンスを持っておくRefオブジェクト
const webSocketRef = useRef(null)
// アンマウントかどうかの判別フラグを持っておくRefオブジェクト
const didUnmount = useRef(false)
見ての通りですがよくあるカスタムフックの定義部分だと思いますので、そんなに難しくないかと思います。
それでは次にBブロックの部分を見てみます。
こちらはuseEffectを使って、WebSocketのインスタンス生成からイベント登録を行なっています。
useEffect(() => {
// WebSocketのインスタンスを生成する
webSocketRef.current = new WebSocket('ws://localhost:8000')
// インスタンスがなければエラーとする
if (!webSocketRef.current) {
throw new Error('WebSocket failed to be created')
}
// アンマウント時に実行するListener
let removeListeners
// 接続処理
const start = () => {
if (!webSocketRef.current) return
// ※attachListenerは別ファイルに定義したのでそちらをご確認ください
// 何をしているかというと.addEventListenrしてイベント時のコールバックを定義しているだけです
removeListeners = attachListener(webSocketRef.current, setLastMessage)
}
// 接続処理の実行
start()
// アンマウント時の処理
return () => {
didUnmount.current = true
removeListeners?.()
setLastMessage(null)
}
}, [])
こちらも処理を読めば大したことをしていません。
そして最後にCブロックを見てみます。
こちらは今回のサンプル用にサーバからの非同期通信を受け取るためのイベントを用意しています。
// ここが実行されたらモーダル用の通知が送られてくる
const sendModalMessage = useCallback((message) => {
webSocketRef.current?.send(
JSON.stringify({
type: 'modal',
message,
})
)
}, [])
// ここが実行されたらトースト用の通知が送られてくる
const sendToastMessage = useCallback((message) => {
webSocketRef.current?.send(
JSON.stringify({
type: 'toast',
message,
})
)
}, [])
という形です。
コアロジックの部分は本当にいつも書いているであろうカスタムフック などを記述するようなイメージです。
Context特有の処理ではないので、そんなに難しくないのではないかと思います。
Provider
まず説明するよりも先に実装を見ていただこうと思います。
import React, { createContext } from 'react'
import { useWebSocketCore } from './useWebSocketCore'
export const WebSocketStateContext = createContext(null)
export const WebSocketActionContext = createContext(null)
export const WebSocketProvider = ({ children }) => {
const [states, actions] = useWebSocketCore()
return (
<WebSocketStateContext.Provider value={states}>
<WebSocketActionContext.Provider value={actions}>
{children}
</WebSocketActionContext.Provider>
</WebSocketStateContext.Provider>
)
}
こんな感じになります。
今回はStateとActionでそれぞれContextを切り分けました。
切り分けた理由としては使うコンポーネントがそれぞれ違うためです。
特に理由がなければ一緒でも平気だと思います。
まず最初にcreateContextでContextオブジェクトを生成します。
そのあとで今回使うProvider用のWrapperコンポーネントとして、WebSocketProviderを定義しています。
その中で先ほど作ったコアロジックのuseWebSocketCoreを使います。
useWebSocketCoreはカスタムフックですので、その戻り値を受け取ります。
今回は配列の先頭をstate、2番目をactionという形で設定しているのでそのように受け取っています。
そして受け取った値を先ほど生成したContextオブジェクトのProviderに設定します。
ここのvalueに設定した値が各子供のコンポーネントで使える値になります。
要はこのWebSocketProviderでWrapしたコンポーネントであれば好きな時にProviderに設定されてある値を使うことができるのです。
またvalueに設定した値はどのコンポーネントで見ても同じ値、同じインスタンスを参照することになります。
今回で言うと、モーダルとトーストで同じデータを受け取ることができるようになりました。
とは言え、このままだと少し使いづらいので、Contextをカスタムフック化し、各コンポーネントで使いやすくしてあげます。
カスタムフック
import { useContext } from 'react'
import { WebSocketStateContext, WebSocketActionContext } from './Provider'
export const useWebSocketState = () => {
const context = useContext(WebSocketStateContext)
if (context === null) {
throw new Error(
`useWebSocketStateはWebSocketのProviderの子要素で使用してください`
)
}
return context
}
export const useWebSocketAction = () => {
const context = useContext(WebSocketActionContext)
if (context === null) {
throw new Error(
`useWebSocketActionはWebSocketのProviderの子要素で使用してください`
)
}
return context
}
Providerファイル内で作成したStateとActionのコンテキストを読み込み、useContextを使いその結果を返却するようなカスタムフック です。
useContextはcreateContextしたコンテキストオブジェクトを引数に設定することで現在設定されている値が取得できます。
どういうことかというと先ほどそれぞれのコンテキストオブジェクトのProviderのvalueに設定した値がここで使えるようになるということです。
つまりuseWebSocketCoreで作られた値がProviderを通してContextオブジェクトに設定され、その値をこのカスタムフックを通して各コンポーネントで使えるようになるということです。
図にすると以下のようになります。
この時に気をつけたいのが、今回で言うと、WebSocketProviderというWrapperコンポーネントの配下にあるコンポーネントでないと値が使えないと言うことです。
これは、Contextのツリー構造の内部に存在しないコンポーネントとなるので、値が存在していないということになります。
その為ツリー内に存在しない場合は、エラーをスローするようにしています。
ということでなんとなくですがContextのつくりの部分は分かってもらえたんじゃないかと思います。
では利用側のコンポーネントはどのようになるか見てみましょう。
Contextを使う
今回はモーダルとトーストのインターフェイスを同じようにしたので、共通のカスタムフックを作ることで実装を実現しています。
とは言え、この辺り実際のビジネス要件によって変わってくるところですので、ベストな実装を目指してもらえればと思います。
import { useState, useCallback, useEffect } from 'react'
import { useWebSocketState } from '../context/websocket'
export const useWebSocketMessage = (messageType) => {
const { lastJsonMessage } = useWebSocketState()
const [open, setOpen] = useState(false)
const [message, setMessage] = useState('')
useEffect(() => {
if (lastJsonMessage?.messageType !== messageType) return
if (!lastJsonMessage?.message) return
setMessage(lastJsonMessage.message)
setOpen(true)
}, [lastJsonMessage, messageType])
const onClose = useCallback(() => {
setOpen(false)
setMessage('')
}, [])
return [{ message, open }, { onClose }]
}
まず最初にuseWebSocketStateをContextに設定されている値を取得しています。
今回で言うとjson化されたメッセージのみを使うのでlastJsonMessageを受け取っています。
もしこのカスタムフックが使われているコンポーネントがWebSocketProviderの子に存在していなければエラーがスローされます。
その後に各コンポーネントで表示するメッセージや表示状態のStateを設定します。
次にuseEffectの処理を見ていきます。
この中では引数で設定されたmessageTypeとWebSocketで受け取ったmessageTypeが同じであるかどうかを見ています。
つまり、モーダル・トーストのそれぞれのコンポーネントが「自分が表示すべきメッセージなのか」という部分の確認をしています。
そして自分が表示すべきメッセージであった場合は、表示状態を切り替えメッセージを設定します。
一つ一つの実装を見ていくと、難しいものはなかったのではないかと思います。
ContextAPIを使うという選択肢を取ることでメリットもあるので今後の皆さんの実装の役に立てるなら嬉しいです。
Contextを使う上での注意点
と、ここまでContextのお話をしてきましたがContextを使うことの注意点もあるので最後にこちらのお話をさせてください。
先ほどの説明のProvider部分の実装をご覧ください。
export const WebSocketProvider = ({ children }) => {
const [states, actions] = useWebSocketCore()
return (
<WebSocketStateContext.Provider value={states}>
<WebSocketActionContext.Provider value={actions}>
{children}
</WebSocketActionContext.Provider>
</WebSocketStateContext.Provider>
)
}
ProviderでWrapしたchildrenがContextの値を使えるようになるコンポーネントになります。
ということは、Providerのvalueが切り替わるたびにツリーの子コンポーネント全てで再レンダリングが行われるのです。
その為、再レンダリングでの負荷がかかります。
今回のように小さなプロダクトであればNext.jsの_appといった最上位のコンポーネントでWrapを行ったとしても再レンダリングのコストは限りなく小さいと言えます。
また今回はデータを受信するためのアクションを用意したために上の改装でWrapしていますが、イベントがなければModalとToastの2つのコンポーネントをWrapするだけで問題ないと言えます。
実際に動いているプロダクトで、なんでもかんでもContext化し最上位のコンポーネントでWrapするようにしてしまうと非常に重たいアプリケーションが出来上がってしまうので気をつけなければいけません。
公式でも記載がありますが、CSSのカラーテーマや言語設定などの値を使うのに適していたり、コンポーネントをPropsとして渡すことで回避できる場合もあると記載があります。
このように「なんかContextがいいらしいから使ってみよう」と決めてしまうと今後の改修がハードになりかねないので、「本当にこの設計で正しいのか」という視点を持っておきたいですね。
さいごに
今回はReact Contextのお話をさせていただきました。
まだ使ったことがなかったり、こんな風に使うんだと言うのが分かってもらえればとても嬉しいです。
用法用量を守ってContextを使っていきましょう!
話は変わりますが、スペースマーケットでは現在一緒にサービスを盛り上げてくれるエンジニアを募集中しております!
気になる方は是非以下のリンクからチェックしていただけると嬉しいです。
この記事が気に入ったらサポートをしてみませんか?