この記事について
Reactでスクロール制御が含まれるヘッダーを作った際にハマったので忘れないように書き残します。
もし同じようなバグに悩まされている方の参考になったら嬉しいです。
ヘッダーのスクロール仕様
- 初期表示時はヘッダーとグローバルナビゲーションを表示する(ヘッダーの下にグローバルナビゲーションがくるイメージ)
- 下にスクロールするとヘッダーが消えてグローバルナビゲーションのみ固定
- 上にスクロールするとヘッダーが復活する
大体以下のサイトのような挙動です
https://www.cyberagent.co.jp/
バグの詳細
- ヘッダーの高さ以上にスクロールした後にページ遷移すると、ページ遷移先でヘッダーが隠れたままになり表示されなくなる
- リロードすると何事もなかったかのように表示される
問題のコード
HTMLは一旦省きますが、ロジックはこんな感じです。
isHeaderShown
がtrueの時にヘッダーが表示されて、falseの時は非表示になるイメージです。
const Sample () => {
const [isHeaderShown, setIsHeaderShown] = useState(true);
const [lastPosition, setLastPosition] = useState(0);
const [currentPosition, setCurrentPosition] = useState(0);
const headerHeight = 40;
const scrollEvent = useCallback(() => {
setCurrentPosition(window.pageYOffset);
if (currentPosition > headerHeight) {
setIsHeaderShown(false);
} else {
setIsHeaderShown(true);
}
if (currentPosition < lastPosition) {
setIsHeaderShown(true);
}
setLastPosition(currentPosition);
}, [currentPosition, lastPosition]);
useEffect(() => {
window.addEventListener('scroll', scrollEvent);
return () => {
window.removeEventListener('scroll', scrollEvent);
};
}, [scrollEvent]);
return (<ヘッダーのHTMLが入るよ>)
};
解決したコード
問題のコードとの違いは、if文の比較で stateを参照しているか否か というところになります。
const Sample () => {
const [isHeaderShown, setIsHeaderShown] = useState(true);
const [lastPosition, setLastPosition] = useState(0);
const headerHeight = 40;
const scrollEvent = useCallback(() => {
const offset = window.pageYOffset;
if (offset > headerHeight) {
setIsHeaderShown(false);
} else {
setIsHeaderShown(true);
}
if (offset < lastPosition) {
setIsHeaderShown(true);
}
setLastPosition(offset);
}, [lastPosition]);
useEffect(() => {
window.addEventListener('scroll', scrollEvent);
return () => {
window.removeEventListener('scroll', scrollEvent);
};
}, [scrollEvent]);
return (<ヘッダーのHTMLが入るよ>)
};
今回のバグの原因
setCurrentPosition
でstate更新した直後に、currentPosition
を使ってif文で比較していた事が原因でした。
if文で比較で使用しているstateにはsetCurrentPosition
で値をセットしても、すぐ最新の値が見れるようにはなりません。
setCurrentPosition
を参照して比較すると以下のような挙動になります。
1回目のレンダリング
const Sample () => {
const [isHeaderShown, setIsHeaderShown] = useState(true);
const [currentPosition, setCurrentPosition] = useState(0);
const headerHeight = 40;
const scrollEvent = useCallback(() => {
setCurrentPosition(window.pageYOffset); // window.pageYOffset: 0
// 0 > 40
if (currentPosition > headerHeight) {
setIsHeaderShown(false);
} else {
setIsHeaderShown(true);
}
(中略)
};
// currentPosition には 0 が入る
2回目のレンダリング
const Sample () => {
const [isHeaderShown, setIsHeaderShown] = useState(true);
const [currentPosition, setCurrentPosition] = useState(0);
const headerHeight = 40;
const scrollEvent = useCallback(() => {
setCurrentPosition(window.pageYOffset); // window.pageYOffset: 10
// 0 > 40
if (currentPosition > headerHeight) {
setIsHeaderShown(false);
} else {
setIsHeaderShown(true);
}
(中略)
};
// currentPosition には 0 が入る
3回目のレンダリング
const Sample () => {
const [isHeaderShown, setIsHeaderShown] = useState(true);
const [currentPosition, setCurrentPosition] = useState(0);
const headerHeight = 40;
const scrollEvent = useCallback(() => {
setCurrentPosition(window.pageYOffset); // window.pageYOffset: 20
// 10 > 40
if (currentPosition > headerHeight) {
setIsHeaderShown(false);
} else {
setIsHeaderShown(true);
}
(中略)
// currentPosition には 10 が入る
(中略)
ページの1番下までスロールしてwindow.pageYOffset
が500になる
100回目(適当)のレンダリング
const Sample () => {
const [isHeaderShown, setIsHeaderShown] = useState(true);
const [currentPosition, setCurrentPosition] = useState(0);
const headerHeight = 40;
const scrollEvent = useCallback(() => {
setCurrentPosition(window.pageYOffset); // window.pageYOffset: 500
// 450 > 40
if (currentPosition > headerHeight) {
setIsHeaderShown(false);
} else {
setIsHeaderShown(true);
}
(中略)
// currentPosition には 450 が入る
101回目のレンダリング
ページ遷移してスクロール量が0になる
const Sample () => {
const [isHeaderShown, setIsHeaderShown] = useState(true);
const [currentPosition, setCurrentPosition] = useState(0);
const headerHeight = 40;
const scrollEvent = useCallback(() => {
setCurrentPosition(window.pageYOffset); // window.pageYOffset: 0
// 500 > 40
if (currentPosition > headerHeight) {
setIsHeaderShown(false);
} else {
setIsHeaderShown(true);
}
(中略)
// currentPosition には 500 が入る
// isHeaderShown が true になるためヘッダーが消える
currentPosition
を参照する形では最新のwindow.pageYOffset
を受け取る事ができなかった為、一旦変数に代入し、それを使用してif文で比較する形に書き換えるとうまくいくようになりました。
stateの挙動については、Reactドキュメントにもちゃんと書かれています。
ユーザがクリックした時に、新しい値で setCount を呼びます。
React は Example コンポーネントを再レンダーし、その際には新たな count の値を渡します。
React Docs
まとめ
かなりピンポイントなバグの記事を書いてしまいましたが、結局stateの基礎を理解していなかったというところが今回の原因でした。
直し方は他にもたくさんあるかと思うので「こういう直し方なんだなー」くらいで見てもらえたら嬉しいです。
雰囲気でコードを書くとちゃんとバグるタイミングが来るので、しっかりドキュメントを読んで理解すべし!