9
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Reactでスクロールしたらヘッダーを表示・非表示にするコードを書いたらバグったので忘備録としてまとめた

Last updated at Posted at 2021-10-02

この記事について

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の基礎を理解していなかったというところが今回の原因でした。
直し方は他にもたくさんあるかと思うので「こういう直し方なんだなー」くらいで見てもらえたら嬉しいです。
雰囲気でコードを書くとちゃんとバグるタイミングが来るので、しっかりドキュメントを読んで理解すべし!

参考

React Docs

useEffect完全ガイド

9
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?