ブラウザのウィンドウサイズの変更を検知するカスタムフックを2種類試してみました

# はじめに

こんにちは、Gakken LEAPのフロントエンドエンジニアの Okuma です。

引き続きフロントエンドエンジニアとして、UI・UXを通してサービスの改善を進めております。
前回は少しハンズオン形式でReactのライブラリであるFramer Motionを使ってUIにアニメーションを適用させる方法について紹介させていただきました。

今回はNext.jsでWebサイトを作成している際にウィンドウサイズの変更検知について色々と試行錯誤したので、そのことについて書いてみようと思います。

# 背景

Next.js x TailwindCSSの構成でサイトを作成していたので、レスポンシブ対応はTailwindCSSに定義されているブレイクポイントを使用してスマホなどの小さいデバイスに対応するレイアウトを作成していました。
スタイルシートで定義している箇所はもちろんこの対応のみであらゆるデバイスに対応できると思いますが、今回は前回の記事でもご紹介したFramer Motionを使用してアニメーションも加えていたため、デバイスサイズによってアニメーションのプロパティ、つまりアニメーションの表現方法を変えたいという思いがありました。（前回の記事はこちら）
ただ、CSSはTailwindのブレイクポイントを使用すれば良いですが、Framer Motionのプロパティの値はウィンドウサイズが変わった際にどのように変更させればよいのかということで壁に当たりました。
今回のその解決のために`useWindowResize`と`useMediaQuery`という2つのカスタムフックを作成してみたので、それぞれご紹介していきます。

# `useWindowResize`

最初に作成した`useWindowResize`では、`window.addEventListener('resize')`を使用してウィンドウサイズを取得します。順を追って作成していきましょう。

```ts
import { useState } from "react";

interface WidthAndHeight {
  width: number;
  height: number;
}

const useWindowResize = () => {
  const [widthAndHeight, setWidthAndHeight] = useState<WidthAndHeight>({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  return widthAndHeight;
};

export default useWindowResize;
```

まずはじめに`useState`でカスタムフックから返す`width`と`height`のオブジェクトを定義します。初期値は`window.innerWidth`と`window.innerHeight`にして、ページを開いた際のウィンドウサイズを取得するようにしています。
これでこのフックを利用するとその時のウィンドウサイズを取得できるようになります。ここからウィンドウサイズが変化した際にこの値をアップデートして常に最新のウィンドウサイズを取得できるようにしてみましょう。

```ts
// ↑省略
const useWindowResize = () => {
  const [widthAndHeight, setWidthAndHeight] = useState<WidthAndHeight>({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  const handleResize = () => {
    setWidthAndHeight({
      width: window.innerWidth,
      height: window.innerHeight,
    });
  };

  return widthAndHeight;
};

export default useWindowResize;
```

`handleResize`を追加しました。これはウィンドウサイズが変更したのを検知した際に実行するイベントハンドラになります。最初に定義した`state`に現在の`innerWidth`と`innerHeight`を代入します。

```ts
// ↑省略
const useWindowResize = () => {
  const [widthAndHeight, setWidthAndHeight] = useState<WidthAndHeight>({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  const handleResize = () => {
    setWidthAndHeight({
      width: window.innerWidth,
      height: window.innerHeight,
    });
  };

  useEffect(() => {
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return widthAndHeight;
};

export default useWindowResize;
```

最後に`useEffect`を追加してウィンドウサイズの変更を検知してイベントハンドラを実行するようにします。
`useEffect`は、コンポーネントがレンダリングされた後に副作用を実行するために使われます。ここでは、ウィンドウのリサイズイベントを監視し、対応する処理を行うために使っています。
また、return文でクリーンアップ関数を定義しています。これは、コンポーネントがアンマウントされたときに呼び出され、リサイズイベントの監視を解除します。これによって不要なイベントリスナーが残ってメモリリークを引き起こすことを防ぐことができます。

あとはこの`useWindowResize`をページなどで呼び出して使用すると、現在のウィンドウサイズが取得できます。
実際に触ってみたい方のためにCodesandboxも貼っておきます。

https://codesandbox.io/embed/28ltv7?view=preview&module=%2Fsrc%2FApp.tsx

# `useMediaQuery`

続いて`useMediaQuery`を作成していきましょう。
こちらは`window.matchMedia`を使ってカスタムフックを作成します。
`window.matchMedia`はドキュメントによると、「指定された メディアクエリ文字列のパース結果を表す、新しい MediaQueryList オブジェクトを返します。」となっています。

```ts
import { useState } from "react";

const useMediaQuery = (query: string) => {
  const [matches, setMatches] = useState<boolean>(false);

  return matches;
};

export default useMediaQuery;
```

まずは`useMediaQuery`の大枠を定義していきます。`window.matchMedia`ではメディアクエリ文字列を引数として渡すことになるので、フックの引数（`query`）として受け取るようにします。
最終的に戻り値となるのはbooleanなので、`useState`で保持している`matches`を返してあげます。

```ts
import { useEffect, useState } from "react";

const useMediaQuery = (query: string) => {
  const [matches, setMatches] = useState<boolean>(false);

  useEffect(() => {
    const mediaQuery: MediaQueryList = window.matchMedia(query);
    const handleMediqQueryChange = (event: MediaQueryListEvent) => {
      setMatches(event.matches);
    };

    setMatches(mediaQuery.matches);
  }, []);

  return matches;
};

export default useMediaQuery;
```

`useEffect`内で`window`オブジェクトを使って実際に`window.matchmedia`を呼び出し、その引数としてメディアクエリ文字列を渡してあげます。ここでの戻り値は`MediaQueryList`という型で帰ってくるので、この中の`.matches`にアクセスすることでbooleanの値を取得できので、これをstateの値として保持します。

また、`handleMediaQueryChange`はイベントリスナーとして使用します。

```ts
import { useEffect, useState } from "react";

const useMediaQuery = (query: string) => {
  const [matches, setMatches] = useState<boolean>(false);

  useEffect(() => {
    const mediaQuery: MediaQueryList = window.matchMedia(query);
    const handleMediqQueryChange = (event: MediaQueryListEvent) => {
      setMatches(event.matches);
    };

    mediaQuery.addEventListener("change", handleMediqQueryChange);
    setMatches(mediaQuery.matches);

    return () => {
      mediaQuery.removeEventListener("change", handleMediqQueryChange);
    };
  }, [query]);

  return matches;
};

export default useMediaQuery;
```

最後に`useEffect`の副作用として実行する`addEventListener`とクリーンアップ関数である`removeEventListener`を記述します。

https://codesandbox.io/embed/679n5t?view=preview&module=%2Fsrc%2FApp.tsx

ただ、今回は第一引数であるイベントタイプを`change`としているので、具体的に以下のタイミングで実行されます。

1. ウィンドウサイズの変更など、画面の仕様や設定が変化したタイミング
2. ユーザーが画面の向き(横/縦)を変更したタイミング
3. ユーザーがデバイスの設定を変更し、指定したメディアクエリが一致/不一致になったタイミング

ウィンドウサイズそのものが変化したタイミングで`resize`イベントが発生する一方で、`change`イベントは指定したメディアクエリの一致状態が変化したタイミングで発生するという明確なタイミングがあります。

`useWindowResize`と`useMediaQuery`ではどちらもウィンドウサイズの変化に対応するために使用するカスタムフックですが、`useWindowResize`は単純にウィンドウサイズの変更のみを検知し、`useMediaQuery`は指定したメディアクエリの一致状態の変化を検知するカスタムフックになっているため、状況に応じて使い分けることができるのです。

# 使用する際の注意点

今回はブラウザ上で動くことを想定しているため、`window`オブジェクトを使用しています。サーバーサイドレンダリングやサーバーコンポーネントを使用する際には注意が必要です。
また、スタイリングでのレスポンシブ対応で使用する場合はまず最優先としてCSSのメディアクエリを使用するようにしましょう。（今回はTailwindCSSのメディアクエリ）
スタイリングでどうしようもならない場合にのみ今回のカスタムフックを適宜使用するイメージです！

# まとめ

もともと私は`useWindowResize`を使用していましたが、TailwindCSSのブレイクポイントに合わせてFramer motionのアニメーションを変化させたかったので、この用途ではブレイクポイントを超えたかどうかだけを判断してくれる`useMediaQuery`の方が使い勝手が良さそうでした。

# エンジニア募集中

Gakken LEAP では教育をアップデートしていきたいエンジニアを絶賛大募集しています！！
ぜひお気軽にカジュアル面談へお越しください！！

https://gakken-leap.co.jp/recruit/

# 参考

- [Learn to create Custom React Hooks: useWindowResize](https://medium.com/@ankurr.singhal/react-hooks-tutorial-usewindowsize-137faadb4594)
- [window.matchMedia - mdn web docs](https://developer.mozilla.org/ja/docs/Web/API/Window/matchMedia)
- [【React】useMediaQuery は最終手段にしよう](https://zenn.dev/chot/articles/do-not-use-use-media-query)