Radix Toast をもっと使いやすくしたい!命令型インターフェイスを目指して実装する
ちょっと株式会社で Web エンジニアをしているすてぃんと申します。今回はヘッドレスコンポーネントの Radix で提供される Toast のお話です。
Toast は、ユーザーのアクションの結果、成功したり失敗したことをフィードバックするために一時的にぴょこっと表示される UI です。たくさんのヘッドレスコンポーネントを提供する Radix には、Toast の機能とアクセシビリティを提供するパッケージがあります。
npm install @radix-ui/react-toast
この記事では Radix Toast をより使いやすくするための実装案を紹介します。
モチベーション
React は宣言的 UI ライブラリです。ステートが先にあり、そのステートから計算された結果として完成形のビューを宣言します。Radix も React コンポーネントを提供する以上、このスタイルで扱うことになります。次のコードブロックは Radix Toast のドキュメントのトップで提供されているサンプルコードの抜粋です。
import * as React from "react";
import * as Toast from "@radix-ui/react-toast";
const ToastDemo = () => {
const [open, setOpen] = React.useState(false);
const timerRef = React.useRef(0);
React.useEffect(() => {
return () => clearTimeout(timerRef.current);
}, []);
return (
<Toast.Provider swipeDirection="right">
<button
className="略"
onClick={() => {
setOpen(false);
window.clearTimeout(timerRef.current);
timerRef.current = window.setTimeout(() => {
setOpen(true);
}, 100);
}}
>
Add to calendar
</button>
<Toast.Root className="略" open={open} onOpenChange={setOpen}>
{/* 略 */}
</Toast.Root>
<Toast.Viewport className="略" />
</Toast.Provider>
);
};
open というトーストの開閉状態を指すステートがあり、それを Toast.Root に渡すことで表示状態を制御します。
ボタンをクリックしたら open ステートを一旦 false にしています。そして setTimeout で 100ms 後に open ステートを true にしています。こうすることでアニメーションを発火することを保証しているのでしょう。タイマー制御のために useRef も使っていて、コードを追いにくくなっています。
実際にはトーストは処理の結果をフィードバックするために使うので、どちらかといえば命令的な使い方をしたいはず。つまり次のように書けると嬉しいのではないでしょうか。
const handleClick = async () => {
try {
await createTweet(text);
openToast({
type: "success",
message: "ツイートしました",
duration: 3000,
});
} catch (e) {
openToast({
type: "error",
message: "ツイートに失敗しました。APIされてます。",
});
}
};
処理が成功したら type: "success" で openToast 関数を呼び出し、失敗したら type: "error" で呼び出しています。このような命令的に使えると、トーストを使う側のコードがシンプルになります。ブラウザ JavaScript 標準の alert 関数と同じような使い勝手になりますね。
実装
それでは、命令的にトーストを表示できるようなインターフェイスになるように実装していきましょう。
最終的な形
どんなコードを目指すかを最初に示しておきます。
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { ToastProvider } from "./components/Toast.tsx";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<ToastProvider>
<App />
</ToastProvider>
);
import { useToast } from "../components/Toast";
export const SomeComponent = () => {
const openToast = useToast();
const handleClick = async () => {
await createTweet("Hello World!");
openToast({
type: "success",
title: "ツイートしました",
duration: 3000,
});
};
return <button onClick={handleClick}>ツイートする</button>;
};
よくあるライブラリのように ToastProvider コンポーネントをツリーのトップに配置しておきます。この ToastProvider が内部でステートをよしなに管理します。
そして ToastProvider の子孫コンポーネントが使えるようになる useToast カスタムフックを提供します。その戻り値はトーストを表示するための関数になっているので、各コンポーネントがそれを好きなタイミングで呼び出すだけです。
この記事では、 openToast を複数回実行すればその回数だけトーストも積み上げられていくように実装します。
ToastProvider で管理するステートの型
ToastProvider では次のような型のステートを配列で管理します。
type ToastItem = {
id: string;
type: "success" | "error";
title: ReactNode;
description?: ReactNode;
duration?: number;
isOpen: boolean;
};
id はトーストを一意に識別する値ですが、これは useToast には公開しません。 ToastProvider の中で削除対象を特定したり key に渡すために内部でのみ使います。
isOpen も useToast を使う側からは操作することはありません。 ToastProvider の中で時間を測って勝手に false になるようにします。
duration はトーストが表示されてから消え始めるまでのミリ秒数です。
title と description は、 Radix Toast のコンポーネントが Toast.Title, Toast.Description のように分かれているため、それに合わせています。
type は独自の値なので、 "warn" とか "info" のような値を自由に増やすこともできます(対応する UI はその数だけ用意する必要があります)。
上記のオブジェクト 1 つで 1 つのトーストを描画することになります。
Context で トーストを開くための関数を配信する
子孫コンポーネントが必要なのは、トーストを表示するための関数だけです。それを配信するための React Context を用意しましょう。
ToastItem 型のうち id と isOpen は ToastProvider の内部で制御される値です。なので、トーストを表示する関数の引数は ToastItem 型からそれらを除いたものになります。
type OpenToastParams = Omit<ToastItem, "id" | "isOpen">;
const OpenToastContext = createContext<(params: OpenToastParams) => void>(
() => null
);
export function useToast() {
return useContext(OpenToastContext);
}
OpenToastContext を export するのではなく、 useToast という名前を付けてから export しましょう(react-refresh の eslint が怒る?さぁ…興味ないから…。)。
実際に Context.Provider で値を配信する部分は後述。
Toast コンポーネントを実装する
ToastItem 型がトースト 1 つ分のステートオブジェクトでした。それを受け取って実際にビューを表示するコンポーネントを実装しましょう。尚、本記事ではロジックに焦点を当てるため、スタイリングはすべて省略させていただきます。コピーして使う場合はお好みの CSS と className を追記してください。スタイリングのための div なども自由に追加できます。また、icon 類もお好きなものに読み替えてください。
import * as RadixToast from "@radix-ui/react-toast";
const Toast: FC<{
value: ToastItem;
onClose: (id: string) => void;
}> = ({ value, onClose }) => {
return (
<RadixToast.Root
open={value.isOpen}
onOpenChange={(isOpen) => !isOpen && onClose(value.id)}
duration={value.duration}
>
{value.type === "success" ? <SuccessIcon /> : <ErrorIcon />}
<RadixToast.Title>{value.title}</RadixToast.Title>
{value.description && (
<RadixToast.Description>{value.description}</RadixToast.Description>
)}
<RadixToast.Close>
<CloseIcon />
</RadixToast.Close>
</RadixToast.Root>
);
};
RadixToast.Root はトーストのロジックを引き受けるコンポーネントです(それ自体が li 要素でもあります)。 open に true を渡すとトーストが表示され、表示状態が変わると onOpenChange イベントが発火します。 onOpenChange といっても、open={true} の状態でマウントされて閉じるのを待つだけなので、 onOpenChange の引数には false だけが渡ってきます。なので、 onOpenChange の中で isOpen が false になったときだけ onClose を呼び出すようにしています。
その onClose ですが、ステート自体はこの親コンポーネントで管理しているため、イベントハンドラーだけを受け取る形にしています。実行時には閉じようとしているトーストの id を指定し、あとは親側に処理を任せます。
ToastItem の duration もここで渡します。 duration で指定したミリ秒数が経過すると onOpenChange を引数 false で発火してくれます。duration が未指定の場合の挙動は後述。
value.type の値によってアイコンを変えています。もし value.type の値を変更する場合は、この Toast コンポーネントでバリエーションを増やしていきます。または、タイプ別に Toast コンポーネントを用意するのも手かもしれません。
RadixToast.Close は button 要素をレンダリングします。スタイリング以外は特に渡す必要はなく、クリックするとやはり onOpenChange を引数 false で発火してくれます。
ToastProvider を実装する
いよいよトーストの本丸である ToastProvider を実装します。 ToastProvider は ToastItem の配列をステートとして持ち、 useToast で配信する関数を実装します。
まず、内部で ID をランダムに生成する関数を用意しておきます。トーストが識別できる程度のランダム性があれば十分なので、 Math.random でサクッと作ります。
const genRandomId = () => Math.random().toString(32).substring(2);
ToastProvider は ToastItem の配列をステートとして持つことになっていましたね。持ちましょう。これを素材として Toast コンポーネントが複数レンダリングされます。
export const ToastProvider: FC<{ children: ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<ToastItem[]>([]);
// ...
};
続いて、子孫コンポーネントに渡すための、トーストを開く関数を実装します。これは、toasts ステートにアイテムを追加するようにステート更新する関数として実装します。引数は事前に定義した OpenToastParams です。
const openToast = useCallback((params: OpenToastParams) => {
const id = genRandomId();
setToasts((prev) => [...prev, { id, isOpen: true, ...params }]);
}, []);
関数の実行によって id をランダムに生成します。また、トーストを開くための関数なので、 isOpen は固定で true にします。それらと引数で受け取った値を 1 つにまとめたオブジェクトを新たな配列の要素として追加し、ステートの更新を行います。
次はトーストを閉じる関数です。これは先に紹介した Toast コンポーネントに渡すためのもので、 id を受け取ってクローズする対象を特定します。
const closeToast = useCallback((id: string) => {
setToasts((prev) =>
prev.map((value) => (value.id === id ? { ...value, isOpen: false } : value))
);
setTimeout(() => {
setToasts((prev) => prev.filter((value) => value.id !== id));
}, 200);
}, []);
注意点としてはいきなり配列ステートから消してしまわずに、一旦削除対象の isOpen を false にします。これはトーストのフェードアウトアニメーションを待つためです。いきなり配列から削除してしまうと DOM からも消えることになるのでアニメーションが見れません。
setTimeout でしばらく待った後に改めてステートから対象を削除します。この待ち時間にあたる setTimeout の第 2 引数は、フェードアウトアニメーションの時間以上にしておくと良いです(duration とは関係ない値であることに注意。duration はトーストを開いてから閉じるまでの待機時間です)。
最後に ToastProvider から return する JSX です。トーストを開く関数を配信する Context.Provider と、 Radix Toast を使う上で必要な RadixToast.Provider で子要素をラップします。
<OpenToastContext.Provider value={openToast}>
<RadixToast.Provider duration={5000}>
{children}
{toasts.map((value) => (
<Toast key={value.id} value={value} onClose={closeToast} />
))}
<RadixToast.Viewport />
</RadixToast.Provider>
</OpenToastContext.Provider>
openToast 関数は OpenToastContext.Provider で配信します。これで、子孫コンポーネントは useToast によって openToast を取得することになります。
toasts 配列ステートから Toast コンポーネントのレンダリングも行います。その時、closeToast 関数を onClose に渡しています。
RadixToast.Viewport は実際にトーストを画面に表示するエリアになります。ちらっと RadixToast.Root が li 要素であると書きましたが、 RadixToast.Viewport は ul 要素になっていて DOM として正しい構造になります。画面右下に fixed するようなスタイリングをするとよいでしょう。
RadixToast.Provider には duration を渡すことができます。これは RadixToast.Root に duration を渡さなかった時のデフォルト値になります。RadixToast.Provider すら duration が渡されなかった場合は 5000 ミリ秒がデフォルト値になります。
ここまでをまとめると、ToastProvider は以下のようになります。
import * as RadixToast from "@radix-ui/react-toast";
const genRandomId = () => Math.random().toString(32).substring(2);
export const ToastProvider: FC<{ children: ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const openToast = useCallback((params: OpenToastParams) => {
const id = genRandomId();
setToasts((prev) => [...prev, { id, isOpen: true, ...params }]);
}, []);
const closeToast = useCallback((id: string) => {
setToasts((prev) =>
prev.map((value) =>
value.id === id ? { ...value, isOpen: false } : value
)
);
setTimeout(() => {
setToasts((prev) => prev.filter((value) => value.id !== id));
}, 200);
}, []);
return (
<OpenToastContext.Provider value={openToast}>
<RadixToast.Provider duration={5000}>
{children}
{toasts.map((value) => (
<Toast key={value.id} value={value} onClose={closeToast} />
))}
<RadixToast.Viewport />
</RadixToast.Provider>
</OpenToastContext.Provider>
);
};
これで目標としていた最終的な形になりました!あとは使ってみるだけです。
使ってみる
Codesandbox を用意しました。onClick で openToast を呼び出すだけの簡単な例ですが、読みやすくなっていると思います。
まとめ
Radix Toast を使いやすくする方法を紹介しました。
React の宣言的な思想とは相反して、トーストは命令的に使えるようになっていると嬉しいです。また、今回の実装によって Radix Toast が一箇所のモジュールに隠蔽されるので、トーストを使うコンポーネントは何も意識する必要がなくなることも嬉しいですね。Radix Toast ではないヘッドレスコンポーネントに乗り換えることになっても、変更が一箇所で済みます。
これでトーストを使うときは
const openToast = useToast();
//...
openToast({...});
と書くだけでよくなります。簡単!
最後に
🍞 ← これはトーストではない
それでは良い React ライフを!
ちょっと株式会社(chot-inc.com)のエンジニアブログです。 フロントエンドエンジニア募集中! カジュアル面接申し込みはこちらから chot-inc.com/recruit/iuj62owig
Discussion