🍫

React で Snackbar を作る

2024/03/25に公開

Snackbar とは

こういうやつ.

Snackbar の例: Material Design 3
Snackbars – Material Design 3

アイテムを削除したときなどに一時的に表示され, 一定時間が経過すると自動で消えるUIコンポーネントです. ライブラリで提供されていることが多いですが, 今回は React のみで実装します.

環境

  • React 18
  • tailwindcss 3.4.1
  • Storybook 8.0

https://github.com/toms74209200/react-snackbar-example

fowardRef で外部から参照する

Snackbar をポップアウトさせるため, コンポーネントを外部から操作できるようにします. これには forwardRefuseImperativeHandle を使います[1][2]. forwardRef によってコンポーネントを外部から参照できるようにし, useImperativeHandle によってコンポーネントに独自の公開ハンドラを追加することができます. onClick などの一般的な公開ハンドラとは異なり, 外部から対象のコンポーネントの状態を操作することができるようになります.

まず, forwardRef だけを使って親コンポーネントに ref を渡します.

Snackbar.tsx
export const useSnackbar = () => {
  const snackbarRef = useRef<() => void>(null);
  return {
    snackbarRef,
  };
};

export const Snackbar = forwardRef<() => void, {}>((_, ref) => {
  return <div className="bg-black p-2"></div>;
})

Snackbar.displayName = "Snackbar";

ref を返す関数 useSnackbaruseRef のラッパーで, この関数を使って ref を渡すようにします. forwardRef は引数として関数コンポーネントではなく, render 関数をとります. 関数コンポーネントとほとんど変わりませんが, 第二引数として ref を持ちます. これによって, 親コンポーネントから ref を指定することができます.

Parent.tsx
const Parent = () => {
  const { snackbarRef } = useSnackbar();
  return <Snackbar ref={snackbarRef}>;
}

これで外部からコンポーネントを参照して操作することができます.

useImperativeHandle で外部から操作する

次に useImperativeHandle を使って独自のハンドラを定義し, コンポーネントの状態を外部から操作できるようにします. useImperativeHandle の第二引数に関数 createHandle を渡します. createHandleObject を返す関数で, この Object にハンドラを登録します.

const createHandle = () => {
    return {
        show: () => {}
    }
}

useImperativeHandle を使って登録されたハンドラは Object のキーを参照して呼び出すことができます.

ref.current.show();

これを使って snackbar を実装していきます. snackbar にハンドラ show を用意します. show を呼び出すと, snackbar の内部状態 isDisplayed をトグルして snackbar の表示・非表示を切り替えます.

Snackbar.tsx
interface SnackbarHandles {
  show: () => void;
}

export const useSnackbar = () => {
  const snackbarRef = useRef<SnackbarHandles>(null);
  const showSnackbar = useCallback(() => {
    snackbarRef.current && snackbarRef.current.show();
  }, []);
  return {
    snackbarRef,
    showSnackbar
  };
};

export const Snackbar = forwardRef<SnackbarHandles, {}>((_, ref) => {
  const [isDisplayed, setIsDisplayed] = useState(false);
  
  useImperativeHandle(
    ref,
    () => ({
      show: () => {
        setIsDisplayed(!isDisplayed);
      },
    }), [isDisplayed]
  );

  return <div className={`${isDisplayed ? "block" : "hidden"} ` + "bg-black p-2"}></div>;
})

Snackbar.displayName = "Snackbar";

Storybook での表示

ここで Storybook を用いて表示の確認をします. Storybook では Meta に登録したコンポーネントがそのまま Story 内でレンダリングされます. しかし forwardRef を使う場合, 外部から ref を登録しなければなりません. そのため decorators プロパティを使って表示を制御します. 今回は button を使って snackbar を表示させてみましょう.

Snackbar.stories.tsx
export const Default: Story = {
  args: {},
  decorators: [
    () => {
      const { snackbarRef, showSnackbar } = useSnackbar();
      return (
        <>
          <button className="border p-2 w-max" onClick={() => showSnackbar()}>
            show
          </button>
          <Snackbar ref={snackbarRef} />
        </>
      );
    },
  ],
};

これで実際にボタンを押して表示・非表示を確認できました.

"show" ボタンをクリックすると黒色の領域が表示・非表示する

メッセージを設定する

Snackbar に表示するメッセージを設定できるようにします. コンポーネントの引数として設定することもできますが, 例えば成功・失敗時など表示するタイミングでメッセージを変更できるように show ハンドラの引数として設定してみましょう. これには useImperativeHandle に登録するハンドラに引数を設定して, バケツリレーするだけでよいです.

Snackbar.tsx
interface SnackbarHandles {
  show: (message: string) => void;
}

export const useSnackbar = () => {
  const snackbarRef = useRef<SnackbarHandles>(null);
  const showSnackbar = useCallback((message: string) => {
    snackbarRef.current && snackbarRef.current.show(message);
  }, []);
  return {
    snackbarRef,
    showSnackbar,
  };
};

export const Snackbar = forwardRef<SnackbarHandles, {}>((_, ref) => {
  const [isDisplayed, setIsDisplayed] = useState(false);
  const [message, setMessage] = useState("");
  
  useImperativeHandle(
    ref,
    () => ({
      show: (message) => {
        setMessage(message);
        setIsDisplayed(!isDisplayed);
      },
    }), [isDisplayed]
  );

  return (
    <div
      className={
        `${isDisplayed ? "block" : "hidden"} ` +
        "bg-black"
      }
    >
      <p className="p-2 text-white">{message}</p>
    </div>
  );

外部から呼び出すときも通常通り引数をつけてやるだけです.

Snackbar.stories.tsx
export const Default: Story = {
  args: {},
  decorators: [
    () => {
      const { snackbarRef, showSnackbar } = useSnackbar();
      return (
        <>
          <button className="border p-2 w-max" onClick={() => showSnackbar("Hello, world!")}>
            show
          </button>
          <Snackbar ref={snackbarRef} />
        </>
      );
    },
  ],
};

"show" ボタンをクリックするとメッセージ付きのの領域が表示・非表示する

Snackbar を自動で消す

Snackbar の動作としては一定時間経過すると自動で消えるのが一般的です. そこで表示後一定時間が経過したら消えるように変更します. これは単純に useEffect で表示状態を検知して, setTimeout を使って一定時間後に非表示に更新すればよいだけです.

Snackbar.tsx
export const Snackbar = forwardRef<SnackbarHandles, {}>((_, ref) => {
  const [isDisplayed, setIsDisplayed] = useState(false);
  
  useImperativeHandle(
    ref,
    () => ({
      show: (message) => {
        setMessage(message);
        setIsDisplayed(true);
      },
    }), []
  );
  
  useEffect(() => {
    if (isDisplayed) {
      const timeout = setTimeout(() => setIsDisplayed(false), 1000);
      return () => clearTimeout(timeout);
    }
  }, [isDisplayed]);

  return (
    <div
      className={
        `${isDisplayed ? "block" : "hidden"} ` +
        "bg-black"
      }
    >
      <p className="p-2 text-white">{message}</p>
    </div>
  );

表示する時間はてきとうな定数としていますが, これも message と同様に show ハンドラの引数としたり, コンポーネントの引数として渡すこともできます.

"show" ボタンをクリックして表示された領域が自動で消える

opacity のアニメーション

さて最後に, アニメーションをつけてリッチな見た目にしてみましょう. Snackbar の表示が消えるときにフェードアウトさせます. CSSの opacitytranslation を使います. フェードアウトは opacity を 0にするときだけ, translation を設定することで実現することができます.

  return (
    <div
      className={
        `${isPopping ? "opacity-100" : "opacity-0 transition-opacity delay-150 duration-300 ease-in-out"} ` + // アニメーションを制御する変数を isPopping とする
        "bg-black"
      }
    >
      <p className="p-2 text-white">{message}</p>
    </div>
  );

ここで表示に関する状態管理とアニメーションの状態管理について考えます. この snackbar は

  1. なんらかのトリガによって表示状態になる.
  2. 一定時間後, フェードアウトのアニメーションが開始される.
  3. フェードアウトのアニメーションが完了後, 非表示状態になる.

という動作が期待されます. これらの状態は isDisplayedisPopping との 2つの状態変数によって管理されるため, このように言い換えられます.

  1. なんらかのトリガによって isDisplayedisPoppingtrue になる.
  2. 一定時間後, isPoppingfalse になる(isDisplayedtrue のまま).
  3. フェードアウトのアニメーションが完了後, idDisplayedfalse になる.

これはコードでは以下のように書くことができます. 1つ目の useEffect では snackbar の見た目上の表示を制御しています. 一定時間経過後にフェードアウトのアニメーションが開始されます. 2つ目の useEffect ではCSSの diplay プロパティを制御しています. フェードアウトのアニメーションが開始された時点, つまり isPoppingfalse になると else 節が実行され, 一定時間経過後に isDisplayedfalse になります.

  useEffect(() => {
    if (isPopping) {
      const timeout = setTimeout(() => setIsPopping(false), 1000);
      return () => clearTimeout(timeout);
    }
  }, [isPopping]);

  useEffect(() => {
    if (isPopping) {
      setIsDisplayed(true);
    } else {
      const timeout = () => setTimeout(() => setIsDisplayed(false), 500);
      return () => clearTimeout(timeout());
    }
  }, [isPopping]);

  return (
    <div
      className={
        `${isDisplayed ? "block" : "hidden"} ` +
        `${isPopping ? "opacity-100" : "opacity-0 transition-opacity delay-150 duration-300 ease-in-out"} ` +
        "bg-black"
      }
    >
      <p className="p-2 text-white">{message}</p>
    </div>
  );

これでフェードアウトを表現することができました.

"show" ボタンをクリックして表示した領域がフェードアウトして消える


この snackbar の例から, さらに undoclose ボタンを追加したり, さらには opacity ではなく translate を使うことで toast バナーを実装することもできます. ライブラリを使わずに実装することで, 多彩な表現を実現することができます.

脚注
  1. forwardRef – React ↩︎

  2. useImperativeHandle – React ↩︎

Discussion