前回、【React】createPortalによるポータルの作成・利用方法でポータルの概要について紹介しました。
トーストは代表的なポータルの応用例です。今回はポータルとContextを利用してトーストを実装する方法について紹介します。
Contextの概要については【React】useContexでContextを参照する手順で紹介しています。
検証環境はnext 12.1.4
、react 18.0.0
です。
下準備
- トーストを呼び出すExampleコンポーネントの作成
- PagesでExampleコンポーネントを呼び出す
- Contextを作成するToastlProviderの作成
- ToastProviderを_app.tsxに組み込む
src/pages/_app.tsx
import "../../styles/globals.css";
import type { AppProps } from "next/app";
import { ToastProvider } from "src/components/shared/ToastProvider";
function MyApp({ Component, pageProps }: AppProps) {
return (
<ToastProvider>
<Component {...pageProps} />
</ToastProvider>
);
}
export default MyApp;
src/pages/index.tsx
import type { NextPage } from "next";
import { Example } from "../components/Example";
const Home: NextPage = () => {
return <Example />;
};
export default Home;
src/components/Example.tsx
import { FC } from "react";
export const Example: FC = () => {
return <div>Example</div>;
};
src/components/shared/ToastProvider.tsx
import { createContext, FC, ReactNode, useEffect, useState } from "react";
import { createPortal } from "react-dom";
type Props = {
children: ReactNode;
};
const initialState = "";
export const ToastContext = createContext<string>(initialState);
export const ToastProvider: FC<Props> = ({ children }) => {
const [showPortal, setShowPortal] = useState(false);
useEffect(() => {
setShowPortal(true);
}, []);
if (!showPortal) {
return null;
}
return (
<ToastContext.Provider value={""}>
{children}
{createPortal(<h1>Hello Toast</h1>, document.getElementById("__next")!)}
</ToastContext.Provider>
);
};
Contextを利用したトーストの表示・非表示の制御方法
Contextを利用したトースト表示・非表示のアプローチは以下の通りです。
- ProviderのローカルState(visible)を利用してトーストの表示を制御する
- visibleをPropsとして受け取るトーストコンポーネントを作成
- トーストコンポーネントはvisibleの真偽値に応じて表示状態を変える
- visibleをfalseにする関数(hideToast)と、trueにする関数(showToast)を用意する
- showToastをContextに渡す
- コンポーネントがContextから受け取ったshowToastを実行することでトーストが表示される
コードは以下の通りです。
なお、今回はEmotionを利用して実装しています。Emotionの導入方法の詳細はEmotionをNext.js x TypeScriptの環境にインストールする手順で紹介しています。
src/components/Example.tsx
import { FC, useContext } from "react";
import { ToastContext } from "src/components/shared/ToastProvider";
export const Example: FC = () => {
const showToast = useContext(ToastContext);
const openToast = () => {
showToast && showToast("Hello Toast");
};
return (
<>
<div>Example</div>
<button onClick={openToast}>表示</button>
</>
);
};
src/components/shared/ToastProvider.tsx
import {
createContext,
FC,
ReactNode,
useCallback,
useEffect,
useState,
} from "react";
import { createPortal } from "react-dom";
import { Toast } from "src/components/shared/Toast";
type Props = {
children: ReactNode;
};
type ContextType = (message: string) => void;
export const ToastContext = createContext<ContextType | undefined>(undefined);
export const ToastProvider: FC<Props> = ({ children }) => {
const [visible, setVisible] = useState(false);
const [message, setMessage] = useState(""); // トーストで表示するメッセージ
const [showPortal, setShowPortal] = useState(false);
const showToast = (message: string) => {
setVisible(true); // トーストが表示状態になる
setMessage(message); // トーストにメッセージがセットされる
};
const hideToast = useCallback(() => setVisible(false), []);
useEffect(() => {
setShowPortal(true);
}, []);
if (!showPortal) {
return null;
}
return (
<ToastContext.Provider value={showToast}>
{children}
{createPortal(
<Toast visible={visible} hideToast={hideToast} message={message} />,
document.getElementById("__next")!
)}
</ToastContext.Provider>
);
};
src/components/shared/Toast.tsx
import React, { FC, memo, useEffect, useState } from "react";
import { css } from "@emotion/react";
type Props = {
visible: boolean;
message: string;
hideToast: () => void;
};
export const Toast: FC<Props> = ({ visible, hideToast, message }) => {
useEffect(() => {
// 表示状態になってから5秒後、hideToastが遅延実行されて非表示になる
if (visible) {
window.setTimeout(() => {
hideToast();
}, 5000);
}
}, [visible]);
return <h1 css={ToastStyle(visible)}>{message}</h1>;
};
const ToastStyle = (visible: boolean) =>
css({
display: visible ? "block" : "none",
});
上記の例では『表示』ボタンを押すとshowToast
が実行されるためvisible
がtrue
になります。加えてmessage
に文字列がセットされます。
その結果、visible
の真偽値によって表示制御をしているトーストコンポーネントが可視化されます。
トーストコンポーネントは表示から5秒後、hideToast
を遅延実行するため、自動で非表示になります。
この時点で以下のような画面が作成できます。
参考: メッセージに内容によってトーストの背景色を変更したい場合
トーストの種類を複数用意したい場合のアプローチは以下の通りです。
- Contextでトースト種別(toastType)を新たに追加管理する
- showToastでメッセージに加えてトースト種別も更新するように修正する
- トーストを表示する際はメッセージに加えてトースト種別も指定する
- トースト種別に応じて背景色を変えるようにトーストコンポーネントを修正する
src/components/Example.tsx
import { FC, useContext } from "react";
import { ToastContext } from "src/components/shared/ToastProvider";
import { TOAST_TYPE } from "src/components/shared/ToastProvider";
export const Example: FC = () => {
const showToast = useContext(ToastContext);
const openToast = () => {
showToast && showToast({ message: "Hello Toast" });
};
const openErrorToast = () => {
showToast && showToast({ message: "Error Toast", type: TOAST_TYPE.ERROR });
};
return (
<>
<div>Example</div>
<button onClick={openToast}>通常表示</button>
<button onClick={openErrorToast}>エラー表示</button>
</>
);
};
src/components/shared/ToastProvider.tsx
import {
createContext,
FC,
ReactNode,
useCallback,
useEffect,
useState,
} from "react";
import { createPortal } from "react-dom";
import { Toast } from "src/components/shared/Toast";
export const TOAST_TYPE = {
NORMAL: "normal",
ERROR: "error",
} as const;
export type ToastTypes = typeof TOAST_TYPE[keyof typeof TOAST_TYPE];
type Props = {
children: ReactNode;
};
type ToastDataType = {
message: string;
type?: ToastTypes;
};
type ContextType = (toastData: ToastDataType) => void;
export const ToastContext = createContext<ContextType | undefined>(undefined);
export const ToastProvider: FC<Props> = ({ children }) => {
const [visible, setVisible] = useState(false);
const [message, setMessage] = useState("");
const [toastType, setToastType] = useState<ToastTypes>(TOAST_TYPE.NORMAL);
const [showPortal, setShowPortal] = useState(false);
const showToast = ({ message, type = TOAST_TYPE.NORMAL }: ToastDataType) => {
setVisible(true);
setMessage(message);
setToastType(type);
};
const hideToast = useCallback(() => setVisible(false), []);
useEffect(() => {
setShowPortal(true);
}, []);
if (!showPortal) {
return null;
}
return (
<ToastContext.Provider value={showToast}>
{children}
{createPortal(
<Toast
visible={visible}
hideToast={hideToast}
message={message}
type={toastType}
/>,
document.getElementById("__next")!
)}
</ToastContext.Provider>
);
};
src/components/shared/Toast.tsx
import React, { FC, useEffect } from "react";
import { css } from "@emotion/react";
import { ToastTypes, TOAST_TYPE } from "src/components/shared/ToastProvider";
type Props = {
visible: boolean;
hideToast: () => void;
message: string;
type: ToastTypes;
};
export const Toast: FC<Props> = ({ visible, hideToast, message, type }) => {
useEffect(() => {
if (visible) {
window.setTimeout(() => {
hideToast();
}, 5000);
}
}, [visible, message]);
return <h1 css={ToastStyle(visible, type)}>{message}</h1>;
};
const ToastStyle = (visible: boolean, type: ToastTypes) =>
css({
display: visible ? "block" : "none",
background: type === TOAST_TYPE.NORMAL ? "lightblue" : "pink",
});
『通常表示』を押した場合は以下のようになります。
『エラー表示』を押した場合は以下のようになります。
トーストの最終的なコード例
以下のような仕様を満たすトーストの実装例を紹介します。
- 『通常表示』ボタンで青色背景のトーストが表示される
- 『エラー表示』ボタンで赤色背景のトーストが表示される
- トーストは5秒後自動で消える
- トーストは画面上部にぴったりくっつく形で表示される
- トーストの表示・非表示はアニメーションによりゆっくり行われる
src/components/Example.tsx
import { css } from "@emotion/react";
import { FC, useContext } from "react";
import { ToastContext } from "src/components/shared/ToastProvider";
import { TOAST_TYPE } from "src/components/shared/ToastProvider";
export const Example: FC = () => {
const showToast = useContext(ToastContext);
const openToast = () => {
showToast && showToast({ message: "Hello Toast" });
};
const openErrorToast = () => {
showToast && showToast({ message: "Error Toast", type: TOAST_TYPE.ERROR });
};
return (
<div css={ExampleStyle}>
<div>Example</div>
<button onClick={openToast}>通常表示</button>
<button onClick={openErrorToast}>エラー表示</button>
</div>
);
};
const ExampleStyle = () =>
css({
padding: "4rem 0",
flex: "1",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
});
src/components/shared/ToastProvider.tsx
import {
createContext,
FC,
ReactNode,
useCallback,
useEffect,
useState,
} from "react";
import { createPortal } from "react-dom";
import { Toast } from "src/components/shared/Toast";
export const TOAST_TYPE = {
NORMAL: "normal",
ERROR: "error",
} as const;
export type ToastTypes = typeof TOAST_TYPE[keyof typeof TOAST_TYPE];
type Props = {
children: ReactNode;
};
type ToastDataType = {
message: string;
type?: ToastTypes;
};
type ContextType = (toastData: ToastDataType) => void;
export const ToastContext = createContext<ContextType | undefined>(undefined);
export const ToastProvider: FC<Props> = ({ children }) => {
const [visible, setVisible] = useState(false);
const [message, setMessage] = useState("");
const [toastType, setToastType] = useState<ToastTypes>(TOAST_TYPE.NORMAL);
const [showPortal, setShowPortal] = useState(false);
const showToast = ({ message, type = TOAST_TYPE.NORMAL }: ToastDataType) => {
setVisible(true);
setMessage(message);
setToastType(type);
};
const hideToast = useCallback(() => setVisible(false), []);
useEffect(() => {
setShowPortal(true);
}, []);
if (!showPortal) {
return null;
}
return (
<ToastContext.Provider value={showToast}>
{children}
{createPortal(
<Toast
visible={visible}
hideToast={hideToast}
message={message}
type={toastType}
/>,
document.getElementById("__next")!
)}
</ToastContext.Provider>
);
};
src/components/shared/Toast.tsx
import React, { FC, useEffect } from "react";
import { css } from "@emotion/react";
import { ToastTypes, TOAST_TYPE } from "src/components/shared/ToastProvider";
type Props = {
visible: boolean;
hideToast: () => void;
message: string;
type: ToastTypes;
};
export const Toast: FC<Props> = ({ visible, hideToast, message, type }) => {
// setTimeoutのタイムアウト識別子を管理するローカルState
const [timeoutId, setTimeoutId] = useState<number>();
useEffect(() => {
if (visible) {
// タイムアウト識別子をクリア => 識別子が存在している、つまりトーストが既に表示されている場合『5秒後に消える』という遅延実行がクリアされる
clearTimeout(timeoutId);
// 5秒後にトーストを非表示にする
const id = window.setTimeout(() => {
hideToast();
}, 5000);
// タイムアウト識別子を保存。この後clearTimeoutが実行されなければ5秒後にトーストが非表示になる
setTimeoutId(id);
}
}, [visible, message]);
return <div css={ToastStyle(visible, type)}>{message}</div>;
};
const ToastStyle = (visible: boolean, type: ToastTypes) =>
css({
// opacityとtransitionを組み合わせて開閉をアニメーション化
opacity: visible ? 1 : 0,
transition: "all 0.5s",
// 『opacity 0』だと非表示でもイベントが発行されるので、visibilityを追加して非表示の際にイベントが発行されないようにする
visibility: visible ? "visible" : "hidden",
// トーストの配置(画面上部にぴったりくっつける)
position: "absolute",
top: "0",
// トーストのサイズ
width: "100%",
padding: "10px 0",
// テキストの中央よせ
textAlign: "center",
// トースト種別によって背景を変更する
background: type === TOAST_TYPE.NORMAL ? "lightblue" : "pink",
// 『opacity』と『opacity + visibility』の違いを確認したい場合は以下をアンコメント↓
// cursor: 'pointer'
});
『通常表示』を押した場合は以下のようになります。

『エラー表示』を押した場合は以下のようになります。
さいごに
Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!