ReactとCSSで一定時間表示される通知をやるときの色々な方法

フロントエンド、というよりUI一般では、ユーザーの操作に対してまったく無反応なのは良くありません。ユーザーが意味のある操作をしたならば、何らかのフィードバックを返すべきです。

例えば、何かをユーザーのクリップボードにコピーするボタンというのはありがちですが、クリップボードに何かを書き込んでもそれだけだと目に見える変化がないので、追加で「コピーしました」のような通知を出すというのはよくあるパターンです。

ここでは、そのような通知が、ちょっとフェードインしながら表示され、一定時間後にフェードアウトしながら消えていくという要件を想定することにしましょう。

## transitionを使う基本的な実装

まずはReactの部分をざっくりお見せします。

:::note warn
このコードは要点だけを抑えた説明用のものです。実際には、エラーハンドリング、アクセシビリティ、ボタンを連打された場合の対処などを考える必要がありますが、この記事の本題から外れてしまうので省略しています。
:::

```tsx
import { useState } from "react";
import "./styles.css";

export default function App() {
  const [input, setInput] = useState("");
  const [notificationShown, setNotificationShown] = useState(false);
  return (
    <div className="App">
      <p>
        <input
          value={input}
          onChange={(e) => {
            setInput(e.currentTarget.value);
          }}
        />
      </p>
      <p>
        <button
          type="button"
          onClick={() => {
            navigator.clipboard.writeText(input).then(() => {
              setNotificationShown(true);
              setTimeout(() => {
                setNotificationShown(false);
              }, 3000);
            });
          }}
        >
          クリップボードにコピー
        </button>
      </p>
      <p className="notification" hidden={!notificationShown}>
        コピーしました。
      </p>
    </div>
  );
}
```

「コピーしました。」という通知を表示するかどうかはp要素のhidden属性で制御されています。ステート制御は以下の部分で、一定時間だけnotificationShownステートをtrueにすることで「一定時間だけ表示」をサポートしています。

```tsx
setNotificationShown(true);
setTimeout(() => {
  setNotificationShown(false);
}, 3000);
```

フェードイン・アウトについてはCSSで表現しており、以下のように実装することでフェードインとフェードアウトをさせることができます。

```css
.notification {
  opacity: 1;
  transition: ease-in-out 200ms allow-discrete;

  &[hidden] {
    display: none;
    opacity: 0;
  }

  @starting-style {
    opacity: 0;
  }
}
```

これでも悪い実装ではありませんが、個人的にはnotificationShownというステートをsetTimeoutを使って制御しているのが気に入らないと感じました。もっと、一定時間が経ったら勝手に消える感じの実装がいいです。

## CSSアニメーションを使った実装

ということで、**CSSアニメーション**を使った実装をやってみましょう。ざっくり言えば、CSSでアニメーション（表示の連続的な変化）をする方法はtransitionとanimationの2種類があり、先ほどの実装はtransitionを使うものです。

animationを使う実装としては、こんなのが考えられます。

```tsx
  <p>
    <button
      type="button"
      onClick={() => {
        navigator.clipboard.writeText(input).then(() => {
          setNotificationShown(true);
        });
      }}
    >
      クリップボードにコピー
    </button>
  </p>
  <p className="notification" hidden={!notificationShown}>
    コピーしました。
  </p>
```

```css
.notification {
  animation: 3.2s ease-in-out forwards fade-in-out;
}

@keyframes fade-in-out {
  from {
    opacity: 0;
  }
  6.25% /* 200 / 3200 */ {
    opacity: 1;
    display: block;
  }
  93.75% /* 3000 / 3200 */ {
    opacity: 1;
    display: block;
  }
  to {
    opacity: 0;
    display: none;
  }
}
```

そう、一連のアニメーションとしてフェードインして3秒間表示されてフェードアウトするところまで書いておけば、setTimeoutを使ってnotificationShownをfalseに戻さなくても勝手にフェードアウトしてくれます。

これならsetTimeoutが必要なくなって嬉しいですね。

……と言いたいところですが、この記事を読んでいる7割くらいの方がすでにお気づきのとおり、これでは問題があります。

それは、ボタンを2回目以降に押しても通知が出ないことです。なぜなら、2回目以降は`setNotificationShown(true)`としてステートをtrueにしても、trueからtrueになったということで何も変わっていないという判定になり、再レンダリングがされないからです。それに、よしんば再レンダリングされたとしても、アニメーションが再実行される理由にはなりません。

これに対する対応を考えましょう。方法は色々あります。

### アニメーションが終了したときにステートをfalseに戻す

アニメーションが終了したことを表す**animationendイベント**を監視すれば、アニメーションが終わったタイミングでステートをfalseに戻すことができます。

```tsx
  <p>
    <button
      type="button"
      onClick={() => {
        navigator.clipboard.writeText(input).then(() => {
          setNotificationShown(true);
        });
      }}
    >
      クリップボードにコピー
    </button>
  </p>
  <p
    className="notification"
    hidden={!notificationShown}
    onAnimationEnd={() => {
      setNotificationShown(false);
    }}
  >
    コピーしました。
  </p>
```

悪くはないですが、結局ステートを戻すんかいという感じもしますね。ちなみに、この実装だと、通知が出ている途中にボタンを連打した場合は無視される挙動になります。

### ステートをbooleanではなくnumberにする

次に紹介するのは、こういう時に引き出しに入っていると意外と便利な手です。それは、ステートをbooleanではなく「通知を表示するたびに1増える数値」にすることです。そして、`key`を使って通知のp要素をリセットします。

```tsx
export default function App() {
  const [input, setInput] = useState("");
  const [notificationShownCount, setNotificationShownCount] = useState(0);
  return (
    <div className="App">
      <p>
        <input
          value={input}
          onChange={(e) => {
            setInput(e.currentTarget.value);
          }}
        />
      </p>
      <p>
        <button
          type="button"
          onClick={() => {
            navigator.clipboard.writeText(input).then(() => {
              setNotificationShownCount((c) => c + 1);
            });
          }}
        >
          クリップボードにコピー
        </button>
      </p>
      <p
        className="notification"
        hidden={!notificationShownCount}
        key={notificationShownCount}
      >
        コピーしました。
      </p>
    </div>
  );
}
```

こうすると、通知が表示される（＝notificationShownCountが変化する）度にp要素が再生成され、アニメーションが最初から再生されます。この実装の場合、通知の表示中にボタンを連打した場合、そのたびにアニメーションが最初からになります。

筆者個人的にはこのやり方はけっこう好きなのですが、ステート設計が要件とあまり紐づかず、読解難易度が高くなる問題があります。

## JavaScriptからアニメーションを再生する

よくよく考えると、今どきはJavaScriptからもCSSアニメーションを再生することができます。これを使うのも一つの手です。

```tsx
export default function App() {
  const [input, setInput] = useState("");
  const notificationRef = useRef<HTMLParagraphElement | null>(null);
  return (
    <div className="App">
      <p>
        <input
          value={input}
          onChange={(e) => {
            setInput(e.currentTarget.value);
          }}
        />
      </p>
      <p>
        <button
          type="button"
          onClick={() => {
            navigator.clipboard.writeText(input).then(() => {
              notificationRef.current?.animate(
                [
                  { opacity: 0 },
                  {
                    opacity: 1,
                    display: "block",
                    offset: 200 / 3200,
                  },
                  {
                    opacity: 1,
                    display: "block",
                    offset: 3000 / 3200,
                  },
                  {
                    opacity: 0,
                    display: "none",
                  },
                ],
                {
                  duration: 3200,
                  fill: "forwards",
                }
              );
            });
          }}
        >
          クリップボードにコピー
        </button>
      </p>
      <p ref={notificationRef} className="notification">
        コピーしました。
      </p>
    </div>
  );
}
```

この場合、通知が最初から表示されないように、初期状態で `display: none;` としておく必要があります。

```css
.notification {
  display: none;
}
```

あまり宣言的UIらしからぬ実装になっている感はしますが、個人的にはそれでもよいと思います。「イベントに合わせて通知が一定時間表示されて消える」という挙動はもともとそんなに宣言的UIが適した要件ではないという気もします。

ただ、この実装だとrefの保持が必要で少しだるいですね。このような場合は、カスタムフックにややこしい部分を押し込めるのが有効です。

ということで、通知のフェードイン・アウトを担当する`useNotification`を作ってみると、このようになります。

```tsx
function useNotification(
  content: React.ReactNode
): [showNotification: () => void, content: React.ReactNode] {
  const ref = useRef<HTMLDivElement | null>(null);
  const showNotification = useCallback(() => {
    ref.current?.animate(
      [
        { opacity: 0 },
        {
          opacity: 1,
          display: "block",
          offset: 200 / 3200,
        },
        {
          opacity: 1,
          display: "block",
          offset: 3000 / 3200,
        },
        {
          opacity: 0,
          display: "none",
        },
      ],
      {
        duration: 3200,
        fill: "forwards",
      }
    );
  }, []);
  const notificationContent = (
    <div ref={ref} className="notification">
      {content}
    </div>
  );
  return [showNotification, notificationContent];
}
```

これを使う側はこうです。

```tsx
export default function App() {
  const [input, setInput] = useState("");
  const [showNotification, notificationContent] = useNotification(
    <p>コピーしました。</p>
  );
  return (
    <div className="App">
      <p>
        <input
          value={input}
          onChange={(e) => {
            setInput(e.currentTarget.value);
          }}
        />
      </p>
      <p>
        <button
          type="button"
          onClick={() => {
            navigator.clipboard.writeText(input).then(() => {
              showNotification();
            });
          }}
        >
          クリップボードにコピー
        </button>
      </p>
      {notificationContent}
    </div>
  );
}
```

ここではいわゆる**render hooksパターン**が使われています。こうすれば、通知を表示させたい側は`showNotification();`を呼び出すだけであとはよしなにやってくれるという設計ができました。使う側は直観的かつ簡潔なコードになったのではないでしょうか。

## まとめ

Render hooksパターンの活用例を思い付いたので宣伝したかっただけです。