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


## この記事について
Reactでスクロール制御が含まれるヘッダーを作った際にハマったので忘れないように書き残します。
もし同じようなバグに悩まされている方の参考になったら嬉しいです。

## ヘッダーのスクロール仕様
- 初期表示時はヘッダーとグローバルナビゲーションを表示する（ヘッダーの下にグローバルナビゲーションがくるイメージ）
- 下にスクロールするとヘッダーが消えてグローバルナビゲーションのみ固定
- 上にスクロールするとヘッダーが復活する

大体以下のサイトのような挙動です
https://www.cyberagent.co.jp/

## バグの詳細
- ヘッダーの高さ以上にスクロールした後にページ遷移すると、ページ遷移先でヘッダーが隠れたままになり表示されなくなる
- リロードすると何事もなかったかのように表示される

## 問題のコード
HTMLは一旦省きますが、ロジックはこんな感じです。
`isHeaderShown`がtrueの時にヘッダーが表示されて、falseの時は非表示になるイメージです。

```js
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を参照しているか否か** というところになります。

```js
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回目のレンダリング

```js
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回目のレンダリング

```js
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回目のレンダリング

```js
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回目(適当)のレンダリング

```js
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になる

```js
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](https://ja.reactjs.org/docs/hooks-state.html)



## まとめ
かなりピンポイントなバグの記事を書いてしまいましたが、結局stateの基礎を理解していなかったというところが今回の原因でした。
直し方は他にもたくさんあるかと思うので「こういう直し方なんだなー」くらいで見てもらえたら嬉しいです。
雰囲気でコードを書くとちゃんとバグるタイミングが来るので、しっかりドキュメントを読んで理解すべし！


## 参考
[React Docs](https://ja.reactjs.org/docs/hooks-state.html)

[useEffect完全ガイド](https://overreacted.io/ja/a-complete-guide-to-useeffect/#%E3%81%9D%E3%82%8C%E3%81%9E%E3%82%8C%E3%81%AE-render-%E3%81%AF%E7%8B%AC%E8%87%AA%E3%81%AE%E3%82%A4%E3%83%99%E3%83%B3%E3%83%88%E3%83%8F%E3%83%B3%E3%83%89%E3%83%A9%E3%82%92%E4%BF%9D%E6%8C%81%E3%81%97%E3%81%A6%E3%81%84%E3%82%8B)