日曜日。
コードを書く。
window.confirmというAPIがある。呼べば確認ダイアログが出て、ユーザーがOKを押せばtrue、キャンセルを押せばfalseが返る。
const handleClick = () => {
if (window.confirm("本当に削除しますか?")) {
// 削除処理
}
};
呼ぶ。訊かれる。答える。返る。
余計なものが、何もない。
なんか素敵だ。
ところが、Reactの世界だと、こうはいかない。
const [pendingId, setPendingId] = useState<string | null>(null);
const handleClick = (id: string) => {
setPendingId(id);
};
const handleConfirm = () => {
if (pendingId) {
// pendingIdを使って削除処理
}
setPendingId(null);
};
stateが散る。ボイラープレートが増える。
ダイアログが一つ増えるたびに、同じことを繰り返す。
これでは、いけない。
window.confirmの、あの素朴な体験を、Reactのレンダリングモデルを壊さずに取り戻していく。
完成のイメージ
インターフェイスから考えるのが好きだ。
使う側の手触りを先に決めて、実装はあとから整える。完成のイメージはこんな感じ。
const confirm: (message: string) => Promise<boolean>;
const handleClick = async () => {
if (await confirm("本当に削除しますか?")) {
// 削除処理
}
};
呼ぶだけで良い。
awaitひとつで、ユーザーの返答を待つ。window.confirmと同じ佇まいだ。
stateもhooksも用意する必要はない。
必要なのは、confirmを呼ぶこと。たったこれだけ。
アプリのルートには、<ConfirmHost />を一つ、置いておく。
function App() {
return (
<>
<ConfirmHost />
{/* その他のコンポーネント */}
</>
);
}
<ConfirmHost />は、ダイアログの表示を一手に引き受ける。アプリのどこからconfirmを呼んでも、ここが応える。
たったこれだけのコードだ。
実装
仕込んでいく。
まず、キューを用意する。confirmが呼ばれるたびに、メッセージとresolve関数をここに積んでいく。
type QueueItem = {
id: string;
message: string;
resolve: (value: boolean) => void;
};
const queue: QueueItem[] = [];
export function confirm(message: string): Promise<boolean> {
return new Promise((resolve) => {
const id = crypto.randomUUID();
queue.push({
id,
message,
resolve: (value) => {
queue.shift();
resolve(value);
},
});
});
}
Promiseのresolveをそのまま外に渡すのではなく、一枚ラップしている。ユーザーがOKまたはキャンセルを押した瞬間に、キューの先頭をshiftで取り除き、それからresolveで結果を返す。キューの管理と結果の返却を、一手にまとめている。
<ConfirmDialog />コンポーネントをこしらえる。
function ConfirmDialog({ item }: { item: QueueItem }) {
return (
<div className="confirm-dialog">
<p>{item.message}</p>
<button onClick={() => item.resolve(true)}>OK</button>
<button onClick={() => item.resolve(false)}>キャンセル</button>
</div>
);
}
どんな実装をしたって構わない。
最後に、<ConfirmHost />コンポーネントを実装する。
function ConfirmHost() {
const current = queue[0];
if (!current) {
return null;
}
return <ConfirmDialog key={current.id} item={current} />;
}
これでは、いけない。
queueはReactの状態管理の外にある。Reactはこの配列の変化を知らない。これでは、キューにアイテムが追加されても、<ConfirmHost />は再レンダリングされない。
外部ストアと連携するには、useSyncExternalStoreフックが使える。Reactの外にある状態を、Reactのレンダリングサイクルに同期させる。
購読の仕組みを整える。
const subscribers = new Set<() => void>();
function notify() {
subscribers.forEach((callback) => callback());
}
function subscribe(callback: () => void) {
subscribers.add(callback);
return () => subscribers.delete(callback);
}
function getSnapshot() {
return queue[0] || null;
}
notifyで全員に通知し、subscribeで登録と解除を行う。getSnapshotは、キューの先頭を返す。useSyncExternalStoreはこのgetSnapshotの戻り値が変わったとき、コンポーネントを再レンダリングしてくれる。
confirm関数に、通知を仕込む。
function confirm(message: string): Promise<boolean> {
return new Promise((resolve) => {
const id = crypto.randomUUID();
queue.push({
id,
message,
resolve: (value) => {
queue.shift();
resolve(value);
notify();
},
});
notify();
});
}
notify()は二箇所に入る。キューに積んだとき。キューから取り除いたとき。これで、ダイアログの出現と消滅の両方が、Reactに伝わる。
function ConfirmHost() {
const current = queue[0];
const current = useSyncExternalStore(subscribe, getSnapshot);
if (!current) {
return null;
}
return <ConfirmDialog key={current.id} item={current} />;
}
これで完成だ。
keyにcurrent.idを渡しているのは、ダイアログが入れ替わるたびにコンポーネントを新しく作り直すため。前のダイアログのstateが次のダイアログに引き継がれない。地味だが、大事な仕事をしている。
全体像
import { useSyncExternalStore } from "react";
type QueueItem = {
id: string;
message: string;
resolve: (value: boolean) => void;
};
const queue: QueueItem[] = [];
const subscribers = new Set<() => void>();
function notify() {
subscribers.forEach((callback) => callback());
}
function subscribe(callback: () => void) {
subscribers.add(callback);
return () => subscribers.delete(callback);
}
function getSnapshot() {
return queue[0] || null;
}
export function confirm(message: string): Promise<boolean> {
return new Promise((resolve) => {
const id = crypto.randomUUID();
queue.push({
id,
message,
resolve: (value) => {
queue.shift();
resolve(value);
notify();
},
});
notify();
});
}
function ConfirmDialog({ item }: { item: QueueItem }) {
return (
<div className="confirm-dialog">
<p>{item.message}</p>
<button onClick={() => item.resolve(true)}>OK</button>
<button onClick={() => item.resolve(false)}>キャンセル</button>
</div>
);
}
export function ConfirmHost() {
const current = useSyncExternalStore(subscribe, getSnapshot);
if (!current) {
return null;
}
return <ConfirmDialog key={current.id} item={current} />;
}
必要なのはたったこれだけ。
応用
このパターンは、confirmダイアログに限らない。
トースト通知。同じキューベースで作れる。resolveの代わりにtimeoutでshiftすれば良い。
何に使ったって構わない。
共通するのは、「任意の場所から命令的に呼び出したいが、レンダリングは宣言的に行いたい」という要件。
まとめ
Reactの外にキューを積むことで、どこからでも呼び出せるようにする。useSyncExternalStoreで、Reactと外の状態を同期する。Promiseのresolveをラップすることで、キュー操作と結果の返却を一手にまとめる。キューで、複数のダイアログを直列に繋ぐ。
Reactの宣言的なUIと、命令的なAPIの橋渡し。
window.confirmのような体験を、Reactの世界でも実現できるようになった。
なければないで構わない。
みんなもやってみてね。