react-rewards の紙吹雪を setInterval で繰り返す
Next.js で作っている静的サイトで紙吹雪をいい感じに散らしたくなったので、react-rewards
で色々試した記録です。
結論
setInterval
に渡す callback 関数を useRef
経由で渡すカスタムフックを使いつつ、 useReward
が返す isAnimating
で reward()
の実行を制御するといい感じに繰り返せる。
GitHub にも上げています 👇
余分なものを省いた時のコードの全体像 👇
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-confetti
や react-tsparticles
なども軽く試したが、最終的に以下でも紹介されている react-rewards
が圧倒的に使いやすかった。
しかし、 react-rewards はボタンを押した時に紙吹雪が飛び出すといった能動的なユースケースのみを想定している。でもこれをいい感じに繰り返したい。
react-rewards
の使い方から
まず 以下のように useReward
というカスタムフックを使うだけで、ボタンクリックで簡単にいい感じの紙吹雪を出すことが出来る。
README より引用
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()
という関数をループで実行する環境さえ整えれば一定間隔で紙吹雪が出せそう。
試行錯誤
useEffect
内で使ってみる 🤔
Step 1. App のレンダリングが更新されるタイミングで呼ばれるだけ。さみしい。
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>
);
};
setInterval
にそのまま reward()
を渡してみる 🤔
Step 2. reward()
実行中に次の reward()
が実行されるので放置していると処理が重複してきてめっちゃ詰まってくる。
タブ開いたまましばらくして戻ってきたらブワァってなる。
+ import { setInterval } from "timers";
...
useEffect(() => {
+ const timerId = setInterval(() => {
reward();
+ }, 1000);
+ return () => clearInterval(timerId);
}, [reward]);
...
この時点のコードの全体像
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>
);
};
reward()
を useRef 経由で setInterval
に渡すカスタムフックを用意する 🤔
Step 3. 以下で言及されているように、 useRef を使って callback 関数の更新を参照しながら使うと良さそう。
しかし結局処理は詰まる。
- 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]);
+ });
...
この時点のコードの全体像
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>
);
};
isAnimating
で実行を制御する 🎉
Step 4. useReward
が返す真偽値 isAnimating
が false
の時は reward()
を実行しないようにする。これで処理が詰まることなく安全に一定間隔で実行される。最初からこれを使うべきだった。
...
useInterval(() => {
+ if (!isAnimating) {
reward();
+ }
});
...
この時点のコードの全体像
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 にも上げています 👇
react-rewards
を繰り返し使う時の注意点
position: fixed
なのでスクロールしてもついてくるぞ!
デフォルトで 解決策:
useReward
の第 3 引数に position 指定の変更などができる config オブジェクト を渡せるので、ある表示領域内に留めたい時は position: "absolute"
など fixed
以外を指定してあげよう!
...
const { reward, isAnimating } = useReward("rewardId", "confetti", {
position: "absolute",
});
...
position: "absolute"
を渡している時に viewport
からはみ出るように紙吹雪を飛ばすと盛大にレイアウトが崩れるぞ!
config に デフォルトの position: fixed
なら問題ない。
繰り返し実行しなくても注意。
解決策:
親要素に position: relative
と overflow: hidden
を付与しよう!
.parent {
position: relative;
overflow: hidden;
}
...
const { reward, isAnimating } = useReward("rewardId", "confetti", {
position: "absolute",
});
...
return (
<div className="parent">
<span id="rewardId" />
</div>;
)
Discussion