まえがき
React(Typescript)でアプリケーションを作っておりまして、コンポーネント間でイベントのやりとりをしたいケースに遭遇しました。
解決策として
- 高階コンポーネント+コンテキストを利用してコンポーネントにPubSubを提供する
- 各コンポーネントは受け取ったPubSubを利用してイベントをやりとりする
という方法を取りました。
その際に簡単なPubSubならライブラリに頼らずとも実装できることに気づいたので、メモがてらこちらに残しておこうと思います。
実装
Reactのコンポーネント1(Component1)からコンポーネント2(Component2)にイベントを送る例を作ってみます。
まずはやりとりするイベントの型を定義します。
ここではEvent1とEvent2を定義してみました。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
// やりとりするイベント
type PubSubEvent =
| {
type: 'Event1';
payload: {
event1Data: string;
};
}
| {
type: 'Event2';
payload: {
event2Data: number;
}
};
|
次に高階コンポーネントが子コンポーネントに提供するコンテキストを作成しましょう。
1
2
3
4
5
6
7
8
9
10
11
12
13
|
import { createContext } from 'react';
type PubSubEventContextType = {
// イベントを購読する関数
// SubscribePropsとSubscriptionは後で定義します。
subscribe: (props: SubscribeProps) => Subscription;
// イベントを発行する関数
publish: (message: PubSubEvent) => void;
};
const PubSubEventContext = createContext<PubSubEventContextType>(
{} as PubSubEventContextType,
);
|
これでComponent1はこんな感じでイベントを送信でき、
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
|
import { useCallback, useContext } from 'react';
const Component1 = () => {
const { publish } = useContext(PubSubEventContext);
const onEvent1ButtonClick = useCallback(() => {
// イベントを送信!
// 購読しているコンポーネント達に届け!
publish({ type: 'Event1', payload: { event1Data: "これはイベント1のデータです" } });
}, [publish]);
const onEvent2ButtonClick = useCallback(() => {
// イベントを送信!
// 購読しているコンポーネント達に届け!
publish({ type: 'Event2', payload: { event2Data: 10 } });
}, [publish]);
// イベントを送信するボタンを表示
return (
<>
<button onClick={onEvent1ButtonClick}>Event1</button>
<button onClick={onEvent2ButtonClick}>Event2</button>
</>
);
};
|
Component2はイベントを受け取るたびに実行する処理をこんな感じで書けるように作っていきたいと思います。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
import { useContext, useEffect, useState } from 'react';
const Component2 = () => {
const [event1Data, setEvent1Data] = useState<string>('');
const [event2Data, setEvent2Data] = useState<number>(0);
const { subscribe } = useContext(PubSubEventContext);
useEffect(() => {
// Event1が発行されたらsetEvent1Data()を呼び出したい!
const subscription = subscribe({
type: 'Event1',
callback: ({ event1Data }) => setEvent1Data(event1Data),
});
return () => subscription.unsubscribe();
}, [subscribe]);
useEffect(() => {
// Event2が発行されたらsetEvent2Data()を呼び出したい!
const subscription = subscribe({
type: 'Event2',
callback: ({ event2Data }) => setEvent2Data(event2Data),
});
return () => subscription.unsubscribe();
}, [subscribe]);
return (
<>
<div>{event1Data}</div>
<div>{event2Data}</div>
</>
);
};
|
まず、実装を後回しにしていたsubscribeの引数(SubscribeProps)と戻り値(Subscription)の型です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
|
////// SubscribeProps (subscribe()の引数) /////
//PubSubEvent型を以下の形になるように整形していきます。
/*
{
type: "Event1";
callback: (payload: {
event1Data: string;
}) => void;
} | {
type: "Event2";
callback: (payload: {
event2Data: number;
}) => void;
}
*/
// ユニオンタイプをmapするようなイメージ
type Distribute<U> = U extends PubSub
? { type: U['type']; callback: (payload: U['payload']) => void }
: never;
type SubscribeProps = Distribute<TalkMessage>;
///// Subscription (subscribe()の戻り値)
type Subscription = {
// 購読をやめる
unsubscribe: () => void;
};
|
次に階層コンポーネントを実装していきます。これがPubSubの核の部分になります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
|
import { ReactNode } from 'react';
import { v4 as uuidv4 } from 'uuid';
export const PubSubProvider = ({
children,
}: { children: ReactNode; }) => {
// サブスクリプションの一覧
const [subscriptions, setSubscriptions] = useState<
(SubscribeProps & {
subscriptionId: string;
})[]
>([]);
// 購読する
const subscribe = useCallback((props: SubscribeProps): Subscription => {
const subscriptionId = uuidv4();
// subscriptionsに追加
setSubscriptions((prev) => [
...prev,
{
subscriptionId,
...props,
},
]);
// Subscriptionオブジェクトを返す
return {
// subscriptionsから今回登録したサブスクリプションを削除する
unsubscribe: () => {
setSubscriptions((subscriptions) =>
subscriptions.filter(
(subscription) => subscription.subscriptionId !== subscriptionId,
),
);
},
};
}, []);
// イベント発行
const publish = useCallback(
(event: PubSubEvent) => {
// イベントが発行されたら、登録されているsubscriptionsのコールバック関数を呼び出す
subscriptions.forEach((subscription) => {
if (
subscription.type === 'Event1' &&
event.type === 'Event1'
) {
// Event1のコールバック達にペイロードを渡す
subscription.callback(event.payload);
}
if (
subscription.type === 'Event2' &&
event.type === 'Event2'
) {
// Event2のコールバック達にペイロードを渡す
subscription.callback(event.payload);
}
});
},
[subscriptions],
);
return (
<PubSubEventContext.Provider
value={{
subscribe,
publish,
}}
>
{children}
</PubSubEventContext.Provider>
);
};
|
最後にPubSubProviderでComponent1とComponent2を包んでやれば完成です!
1
2
3
4
5
6
7
8
|
function App() {
return (
<PubSubProvider>
<Component1 />
<Component2 />
</PubSubProvider>
);
}
|
最後に
今回はTypescriptで簡単なPubSubを実装しました。
型をつけて書いているので、subscribeやpublishをする際にtypeだけ指定すればpayloadやcallbackの補完が効くようになっているのが、推しポイントです。
publish()内部のif文で、イベントを列挙しなくてはいけなくなっているのがモヤっとポイントです。いい解決策をご存知の方いらっしゃったら教えてください。
最後にコードを全文掲載します。ご自由にお使いください。
この記事がどなたかの助けになれば幸甚です。
最後まで読んでいただきありがとうございました 🙇
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
|
import React, { createContext, useCallback, useContext, useEffect, useState, ReactNode } from 'react';
import './App.css';
import { v4 as uuidv4 } from 'uuid';
// やりとりするイベント
type PubSubEvent =
| {
type: 'Event1';
payload: {
event1Data: string;
};
}
| {
type: 'Event2';
payload: {
event2Data: number;
}
};
export type Distribute<U> = U extends PubSubEvent
? { type: U['type']; callback: (payload: U['payload']) => void }
: never;
export type SubscribeProps = Distribute<PubSubEvent>;
type Subscription = {
unsubscribe: () => void;
};
type PubSubEventContextType = {
// イベントを購読する関数
// SubscribePropsとSubscriptionは後で定義します。
subscribe: (props: SubscribeProps) => Subscription;
// イベントを発行する関数
publish: (message: PubSubEvent) => void;
};
const PubSubEventContext = createContext<PubSubEventContextType>(
{} as PubSubEventContextType,
);
const Component1 = () => {
const { publish } = useContext(PubSubEventContext);
const onEvent1ButtonClick = useCallback(() => {
// イベントを送信!
// 購読しているコンポーネント達に届け!
publish({ type: 'Event1', payload: { event1Data: "これはイベント1のデータです" } });
}, [publish]);
const onEvent2ButtonClick = useCallback(() => {
// イベントを送信!
// 購読しているコンポーネント達に届け!
publish({ type: 'Event2', payload: { event2Data: 10 } });
}, [publish]);
// イベントを送信するボタンを表示
return (
<>
<button onClick={onEvent1ButtonClick}>Event1</button>
<button onClick={onEvent2ButtonClick}>Event2</button>
</>
);
};
const Component2 = () => {
const [event1Data, setEvent1Data] = useState<string>('empty');
const [event2Data, setEvent2Data] = useState<number>(0);
const { subscribe } = useContext(PubSubEventContext);
useEffect(() => {
// Event1が発行されたらsetEvent1Data()を呼び出したい!
const subscription = subscribe({
type: 'Event1',
callback: ({ event1Data }) => setEvent1Data(event1Data),
});
return () => subscription.unsubscribe();
}, [subscribe]);
useEffect(() => {
// Event2が発行されたらsetEvent2Data()を呼び出したい!
const subscription = subscribe({
type: 'Event2',
callback: ({ event2Data }) => setEvent2Data(event2Data),
});
return () => subscription.unsubscribe();
}, [subscribe]);
return (
<>
<div>{event1Data}</div>
<div>{event2Data}</div>
<button onClick={() => {
setEvent1Data('empty');
setEvent2Data(0);
}}>reset</button>
</>
);
};
export const PubSubProvider = ({
children,
}: { children: ReactNode; }) => {
// サブスクリプションの一覧
const [subscriptions, setSubscriptions] = useState<
(SubscribeProps & {
subscriptionId: string;
})[]
>([]);
// 購読する
const subscribe = useCallback((props: SubscribeProps): Subscription => {
const subscriptionId = uuidv4();
// subscriptionsに追加
setSubscriptions((prev) => [
...prev,
{
subscriptionId,
...props,
},
]);
// Subscriptionオブジェクトを返す
return {
// subscriptionsから今回登録したサブスクリプションを削除する
unsubscribe: () => {
setSubscriptions((subscriptions) =>
subscriptions.filter(
(subscription) => subscription.subscriptionId !== subscriptionId,
),
);
},
};
}, []);
// イベント発行
const publish = useCallback(
(event: PubSubEvent) => {
// イベントが発行されたら、登録されているsubscriptionsのコールバック関数を呼び出す
subscriptions.forEach((subscription) => {
if (
subscription.type === 'Event1' &&
event.type === 'Event1'
) {
// Event1のコールバック達にペイロードを渡す
subscription.callback(event.payload);
}
if (
subscription.type === 'Event2' &&
event.type === 'Event2'
) {
// Event2のコールバック達にペイロードを渡す
subscription.callback(event.payload);
}
});
},
[subscriptions],
);
return (
<PubSubEventContext.Provider
value={{
subscribe,
publish,
}}
>
{children}
</PubSubEventContext.Provider>
);
};
function App() {
return (
<PubSubProvider>
<Component1 />
<Component2 />
</PubSubProvider>
);
}
export default App;
|