useEffect は、こういったことに使うべきでは無さそうです。
TL;DR
- React で、兄弟コンポーネント間でイベントを送りたい。
-
useEffect()
ってイベントっぽくない? -
useState()
の本来と違う使い方なのでカスタムフックに抽出しよう。
背景
- Functional Component で書きます。
- 親コンポーネントPとその子コンポーネントAとBがあります。
- Aは複数選択可なアイテムで、新たに選択されたアイテムに対して、
- Web APIを叩いて得られた結果を全て保持し、Bでグラフとして表示します。
このようなアプリを作ろうとすると、非同期処理が入るので、選択された値の配列をPに保管して、Bに Attribute として渡してその中で処理しようと考えると、結果をキャッシュしないといけません。
~~キャッシュを実装するのが面倒だったので、~~そこで、イベント駆動プログラミングの出番です。Aで新たに選択された値をBに渡して、B内で処理させましょう。子コンポーネントでのデータの使い方について親が知る必要がないほうがたぶん楽なので。
以下は単純化されたサンプルアプリケーションで話を進めます。
#useEffectって、イベント駆動なのではないか
Reactには、useEffect(effect, deps)
というHookがあります。引数effect
として渡される関数は、前回のrender時と今回のrender時でdeps
(配列)の値のどれかが変わるときに実行されます。(初回render時にも実行されます。)
これって、EventやReactive ExtentionのStreamと似ていませんか?似てますよね。
interface NumberEvent { // numberそのものを使うと、同じ値が連続で出た時にコールバックが呼ばれない
value: number
}
function newRandomNum(): NumberEvent {
return { value: Math.round(Math.random() * 10) }
}
// 送信側のコンポーネント
// 0 ~ 10 のランダムな数を整数に丸めて送信します。
const Inputs: FC<{effect: Dispatch<SetStateAction<NumberEvent>>}> = (props) => {
return <button onClick={() => props.effect(newRandomNum())}>Fire Event!!</button>
}
// 受信側のコンポーネント
// 送信された数の累計を表示するコンポーネント
const DispSum: FC<{currentNum: NumberEvent}> = (props) => {
const [sum, setSum] = useState(0)
useEffect(() => {
setSum(p => p + props.currentNum.value)
}, [props.currentNum])
return <div>Sum : {sum}</div>
}
// 送信された数の履歴を新しい順で表示するコンポーネント
const DispHistory: FC<{currentNum: NumberEvent}> = (props) => {
const [history, setHistory] = useState<number[]>([])
useEffect(() => {
setHistory(p => [props.currentNum.value, ...p])
}, [props.currentNum])
return <div>History : [{history.join(", ")}]</div>
}
// 親コンポーネント
const App: FC = () => {
const [current, emit] = useState({value: 0})
return (
<div>
<Inputs effect={emit}></Inputs>
<DispSum currentNum={current}></DispSum>
<DispHistory currentNum={current}></DispHistory>
</div>
)
}
こうすると、DispSum
・DispHistory
各コンポーネントは独立にイベントを受け取ってコンポーネント内で処理・表示することが出来ています。
#本来の使い方とちょっとずれてる?
useState()
を、イベントの受け渡しに使うのは、状態の管理という本来の使い方と少しずれているような気がするので、カスタムフック化して、これを隠してしまいましょう。
const [current, emit] = useState({value: 0})
の部分は、
interface MsgEmitter<A> {
current: A
emit: Dispatch<SetStateAction<A>>
}
function useMsgEmitter<A>(init: A): MsgEmitter<A> {
const [current, emit] = useState(init)
return { current, emit }
}
に、そして、
useEffect(() => {
// ...
}, [props.currentNum])
の部分は,
function useMsgReceiver<A, B>(effect: (b: B) => void, emitter: Pick<MsgEmitter<B>, "current">) {
useEffect(() => effect(emitter.current), [emitter.current])
}
にします。(Pick<MsgEmitter<B>, "current">
は、MsgEmitter<B>
の内、current
だけ使えればいいという意味です。)
そもそも元の状態と渡されたイベントから一つのstateを変更するだけなら、更に抽象化が可能です。
function useCalculatedState<A, B>(effect: (setValue: (a: A) => void, prev: A, b: B) => void, emitter: Pick<MsgEmitter<B>, "current">, init?: A | (() => A)) {
const [value, setValue] = useState<A>(init)
useEffect(() => effect(setValue, value, emitter.current), [emitter.current])
return value
}
完成
一応useMsgReceiver()
とuseCalculatedState()
の両方を使うように書いています。
// MsgEmitter関連の定義
interface MsgEmitter<A> {
current: A
emit: Dispatch<SetStateAction<A>>
}
function useMsgEmitter<A>(init: A): MsgEmitter<A> {
const [current, emit] = useState(init)
return { current, emit }
}
function useMsgReceiver<A, B>(effect: (b: B) => void, emitter: Pick<MsgEmitter<B>, "current">) {
useEffect(() => effect(emitter.current), [emitter.current])
}
function useCalculatedState<A, B>(effect: (setValue: (a: A) => void, prev: A, b: B) => void, emitter: Pick<MsgEmitter<B>, "current">, init?: A | (() => A)) {
const [value, setValue] = useState<A>(init)
useEffect(() => effect(setValue, value, emitter.current), [emitter.current])
return value
}
// 送信側の実装
interface NumberEvent {
value: number
}
function newRandomNum(): NumberEvent {
return { value: Math.round(Math.random() * 10) }
}
const Inputs: FC<{emitter: MsgEmitter<NumberEvent>}> = (props) => {
return <button onClick={() => props.emitter.emit(newRandomNum())}>Fire Event!!</button>
}
// 受信側の実装
const DispSum: FC<{emitter: MsgEmitter<NumberEvent>}> = (props) => {
const [sum, setSum] = useState(0)
useMsgReceiver(n => {
setSum(p => p + n.value)
}, props.emitter)
return <div>Sum : {sum}</div>
}
const DispHistory: FC<{emitter: MsgEmitter<NumberEvent>}> = (props) => {
const history = useCalculatedState((setValue, prev: number[], n) => {
setValue([n.value, ...prev])
}, props.emitter, [])
return <div>History : [{history.join(", ")}]</div>
}
// 親コンポーネント
const App: FC = () => {
const emitter = useMsgEmitter({value: 0})
return (
<div>
<Inputs emitter={emitter}></Inputs>
<DispSum emitter={emitter}></DispSum>
<DispHistory emitter={emitter}></DispHistory>
</div>
)
}
TODO
複数のEmitterに依存するように書き換えることもできるはずなので次回でやってみます。