【React】createPortalとContextによるトースト(Toast)実装

JavaScript

前回、【React】createPortalによるポータルの作成・利用方法でポータルの概要について紹介しました。
トーストは代表的なポータルの応用例です。今回はポータルとContextを利用してトーストを実装する方法について紹介します。

Contextの概要については【React】useContexでContextを参照する手順で紹介しています。

検証環境はnext 12.1.4react 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が実行されるためvisibletrueになります。加えて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)やってます。フォローしてもらえるとうれしいです!

参考記事