🎉

react-rewards の紙吹雪を setInterval で繰り返す

2022/12/01に公開

Next.js で作っている静的サイトで紙吹雪をいい感じに散らしたくなったので、react-rewards で色々試した記録です。

結論

setInterval に渡す callback 関数を useRef 経由で渡すカスタムフックを使いつつ、 useReward が返す isAnimatingreward() の実行を制御するといい感じに繰り返せる。

GitHub にも上げています 👇
https://github.com/taigakiyokawa/react-rewards-set-interval

余分なものを省いた時のコードの全体像 👇

App.tsx
import { FC, useEffect } from "react";
import { useReward } from "react-rewards";
import { setInterval } from "timers";

const useInterval = (callback: () => void) => {
  const callbackRef = useRef(() => {});

  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  useEffect(() => {
    const timerId = setInterval(() => callbackRef.current(), 1000);
    return () => clearInterval(timerId);
  }, []);
};

export const App: FC = () => {
  const { reward, isAnimating } = useReward("rewardId", "confetti");

  useInterval(() => {
    if (!isAnimating) {
      reward();
    }
  });

  return (
    <div>
      <span id="rewardId" />
    </div>
  );
};

概要

Next.js で作っている静的サイトで紙吹雪をいい感じに散らしたくなった。

  • トップページのキービジュアル領域内で使いたい。
  • クラッカーみたいにパァンってしたい。
  • 勝手に繰り返されるようにしたい。
  • 紙吹雪の色とか大きさをサイトのデザインに合わせてカスタマイズしたい。

react-confettireact-tsparticles なども軽く試したが、最終的に以下でも紹介されている react-rewards が圧倒的に使いやすかった。
https://blog.ojisan.io/react-rewards-osusume/

しかし、 react-rewards はボタンを押した時に紙吹雪が飛び出すといった能動的なユースケースのみを想定している。でもこれをいい感じに繰り返したい。

まず react-rewards の使い方から

以下のように useReward というカスタムフックを使うだけで、ボタンクリックで簡単にいい感じの紙吹雪を出すことが出来る。

README より引用

App.tsx
import { FC } from "react";
import { useReward } from "react-rewards";

export const App: FC = () => {
  const { reward, isAnimating } = useReward("rewardId", "confetti");
  return (
    <button disabled={isAnimating} onClick={reward}>
      <span id="rewardId" />
      🎉
    </button>
  );
};

この reward() という関数をループで実行する環境さえ整えれば一定間隔で紙吹雪が出せそう。

試行錯誤

Step 1. useEffect 内で使ってみる 🤔

App のレンダリングが更新されるタイミングで呼ばれるだけ。さみしい。

App.tsx
import { FC, useEffect } from "react";
import { useReward } from "react-rewards";

export const App: FC = () => {
  const { reward, isAnimating } = useReward("rewardId", "confetti");

  useEffect(() => {
    reward();
  }, [reward]);

  return (
    <div>
      <span id="rewardId" />
    </div>
  );
};

Step 2. setInterval にそのまま reward() を渡してみる 🤔

reward() 実行中に次の reward() が実行されるので放置していると処理が重複してきてめっちゃ詰まってくる。

タブ開いたまましばらくして戻ってきたらブワァってなる。

App.tsx
+ import { setInterval } from "timers";
...
    useEffect(() => {
+     const timerId = setInterval(() => {
        reward();
+     }, 1000);
+     return () => clearInterval(timerId);
    }, [reward]);
...
この時点のコードの全体像
App.tsx
import { FC, useEffect } from "react";
import { useReward } from "react-rewards";
import { setInterval } from "timers";

export const App: FC = () => {
  const { reward, isAnimating } = useReward("rewardId", "confetti");

  useEffect(() => {
    const timerId = setInterval(() => {
      reward();
    }, 1000);
    return () => clearInterval(timerId);
  }, [reward]);

  return (
    <div>
      <span id="rewardId" />
    </div>
  );
};

Step 3. reward() を useRef 経由で setInterval に渡すカスタムフックを用意する 🤔

以下で言及されているように、 useRef を使って callback 関数の更新を参照しながら使うと良さそう。
https://zenn.dev/akhr_s/articles/065e18ab3c4883

しかし結局処理は詰まる。

App.tsx

- import { FC, useEffect } from "react";
+ import { FC, useEffect, useRef } from "react";
...
+ const useInterval = (callback: () => void) => {
+   const callbackRef = useRef(() => {});
+
+   useEffect(() => {
+     callbackRef.current = callback;
+   }, [callback]);
+
+   useEffect(() => {
+     const timerId = setInterval(() => callbackRef.current(), 1000);
+     return () => clearInterval(timerId);
+   }, []);
+ };
...

-   useEffect(() => {
-     const timerId = setInterval(() => {
+   useInterval(() => {
-       reward();
+     reward();
-      }, 1000);
-     return () => clearInterval(timerId);
-   }, [reward]);
+   });
...
この時点のコードの全体像
App.tsx
import { FC, useEffect, useRef } from "react";
import { useReward } from "react-rewards";
import { setInterval } from "timers";

const useInterval = (callback: () => void) => {
  const callbackRef = useRef(() => {});

  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  useEffect(() => {
    const timerId = setInterval(() => callbackRef.current(), 1000);
    return () => clearInterval(timerId);
  }, []);
};

export const App: FC = () => {
  const { reward, isAnimating } = useReward("rewardId", "confetti");

  useInterval(() => {
    reward();
  });

  return (
    <div>
      <span id="rewardId" />
    </div>
  );
};

Step 4. isAnimating で実行を制御する 🎉

useReward が返す真偽値 isAnimatingfalse の時は reward() を実行しないようにする。これで処理が詰まることなく安全に一定間隔で実行される。最初からこれを使うべきだった。

App.tsx
...
   useInterval(() => {
+    if (!isAnimating) {
      reward();
+    }
   });
...
この時点のコードの全体像
App.tsx
import { FC, useEffect } from "react";
import { useReward } from "react-rewards";
import { setInterval } from "timers";

const useInterval = (callback: () => void) => {
  const callbackRef = useRef(() => {});

  useEffect(() => {
    callbackRef.current = callback;
  }, [callback]);

  useEffect(() => {
    const timerId = setInterval(() => callbackRef.current(), 1000);
    return () => clearInterval(timerId);
  }, []);
};

export const App: FC = () => {
  const { reward, isAnimating } = useReward("rewardId", "confetti");

  useInterval(() => {
    if (!isAnimating) {
      reward();
    }
  });

  return (
    <div>
      <span id="rewardId" />
    </div>
  );
};

あとは諸々仕上げていい感じしたら完成 🎉

GitHub にも上げています 👇
https://github.com/taigakiyokawa/react-rewards-set-interval

react-rewards を繰り返し使う時の注意点

デフォルトで position: fixed なのでスクロールしてもついてくるぞ!

解決策:

useReward の第 3 引数に position 指定の変更などができる config オブジェクト を渡せるので、ある表示領域内に留めたい時は position: "absolute" など fixed 以外を指定してあげよう!

App.tsx
...
const { reward, isAnimating } = useReward("rewardId", "confetti", {
  position: "absolute",
});
...

config に position: "absolute" を渡している時に viewport からはみ出るように紙吹雪を飛ばすと盛大にレイアウトが崩れるぞ!

デフォルトの position: fixed なら問題ない。
繰り返し実行しなくても注意。

解決策:

親要素に position: relativeoverflow: hidden を付与しよう!

styles.css
.parent {
  position: relative;
  overflow: hidden;
}
App.tsx
...
const { reward, isAnimating } = useReward("rewardId", "confetti", {
  position: "absolute",
});
...
return (
  <div className="parent">
    <span id="rewardId" />
  </div>;
)

参考リンク

GitHubで編集を提案

Discussion