かもメモ

自分の落ちた落とし穴に何度も落ちる人のメモ帳

Next.js × Chakra UI レスポンシブ値を変更できる useBreakpointValue で複数の値を扱いたい時のハマりどころ

Chakra UI の useBreakpointValue() を使って Button コンポーネントの size をレスポンシブで変更できるようにしました。
useBreakpointValue() が保持した値を返すので、ボタンのラベルも合わせて保持できるのでは?と Next.js のアプリで試していて罠にハマったのでメモ。

環境
@chakra-ui/icons: ^1.0.13
@chakra-ui/react: ^1.6.3
next: ^10.0.0

複数の値を useBreakpointValue() で保持する

import { Button, useBreakpointValue } from '@chakra-ui/react';

const ResponsiveButton: VFC = () => {
  const buttonSize = useBreakpointValue(['sm', 'md']);
  const buttonLabel = useBreakpointValue(['MyButton', 'MyResponsiveButton']);

  return (
    <Button type="button" size={buttonSize}>
      {buttonLabel}
    </Button>
  );
}

レスポンシブで変更する値それぞれに useBreakpointValue() を使えば当然複数の値をレスポンシブで変更できます。
ただ値が多くなると全ての値で hooks を呼び出すのはパフォーマンス的にもあまりよろしくないと思います。
今回は buttonSizebuttonLabel を同じブレイクポイントで変更したいので、これをセットにして useBreakpointValue() に渡せば合理的だと考えました。

useBreakpointValue の型は次のように定義されています

// chakra-ui/packages/media-query/src/use-breakpoint-value.ts
export function useBreakpointValue<T = any>(
  values: Record<string, T> | T[],
  defaultBreakpoint?: string,
): T | undefined

cf. chakra-ui/packages/media-query/src/use-breakpoint-value.ts | GitHub

保持される値が T = any なので配列・オブジェクトで保持する事ができそうです。

🙅 Next.js で useBreakpointValue() に保持させるデータを配列で渡すとエラーになった

変数は分割代入で取れるので const [buttonSize, buttonLabel] = [size, label] の形にしようと思いました。

const ResponsiveButton: VFC = () => {
  const [buttonSize, buttonLabel] = useBreakpointValue([
    ['sm', '登録'],
    ['md', '登録する'],
  ]);

  return (
    <Button type="button" size={buttonSize}>
      {buttonLabel}
    </Button>
  );
}

うまくいきそうです。
しかし実際には次のようなエラーが発生してしまいました。
=> Uncaught TypeError: Invalid attempt to destructure non-iterable instance. In order to be iterable, non-array objects must have a [Symbol.iterator]() method.

🙅 Object 形式でも同様

export const ResponsiveButton: VFC = () => {
  const { buttonSize, buttonLabel } = useBreakpointValue([
    { buttonSize: 'sm', buttonLabel: '登録' },
    { buttonSize: 'md', buttonLabel: '登録する' },
  ]);
  // 略 
}

=> TypeError: Cannot read property 'buttonSize' of undefined

Next.js だと初回レンダリング時に useBreakpointValue() が undefined を返すのがエラーの原因

最初複数の値をセットで保存できないのかと思ったのですが、元の文字列の値を保持する状態に戻して console.log で確認すると次のようになっていました

const ResponsiveButton: VFC = () => {
  const buttonSize = useBreakpointValue(['sm', 'md']);
  console.log({ buttonSize });
  // 略
}

👇

1. { buttonSize: undefined }
2. { buttonSize: "sm" }

初回レンダリング時に useBreakpointValue() が返す値が undefined になっていました。なので先のコードでは配列形式のデータを想定して分割代入で変数に値を代入しようとしていたので初回レンダリング時に次のような処理になりエラーが発生してしまったようです

const [buttonSize, buttonLabel] = undefined;
// => Uncaught TypeError: undefined is not iterable

const {buttonSize, buttonLabel} = undefined;
// => TypeError: Cannot destructure property 'buttonSize' of 'undefined' as it is undefined.

useBreakpointValue の実装を見てみる

useBreakpointValue の実装を見てみると、useBreakpoint で breakpoint を取得していて、これが Falsy だと undefined を返すようになっており、breakpoint の取得は useBreakpointuseEffect 内で window.matchMedia を使って取得するようになっていました。

export function useBreakpointValue<T = any>(
  values: Record<string, T> | T[],
  defaultBreakpoint?: string,
): T | undefined {
  const breakpoint = useBreakpoint(defaultBreakpoint)
  const theme = useTheme()

  if (!breakpoint) return undefined
  // ...

cf. chakra-ui/packages/media-query/src/use-breakpoint-value.ts | GitHub

つまり yarn dev で実行している時の初回レンダリングは useEffect 前の値が一度返されていて、この時初期値の undefined が返されているようです。 Next.js では サーバーサイドの処理も同じコンポーネントで行われるので、クライアントサイドでのみ実行される useEffect 内でないと window オブジェクトが存在しないので matchMedia が使えず breakpoint が取得できないのでこのような仕様になっているのだと思います。

⚠️ defaultBreakpoint オプションが効かない 追記: @chakra-ui/react v1.6.4 より古いの場合

実装には defaultBreakpoint の指定ができるようになっており、この値がデフォルトのブレイクポイントとして使われるっぽいのですが、指定してみたのですが type error になり type error を ignore してもデフォルトの値は返されず undefined が返され上手く動作しませんでした。

import { useBreakpointValue } from '@chakra-ui/react';

export const ResponsiveButton: VFC = () => {
  const { buttonSize, label } = useBreakpointValue(
    {
      sm: { buttonSize: 'sm', buttonLabel: '登録' },
      md: { buttonSize: 'md', buttonLabel: '登録する' },
    },
    // defaultBreakpoint
    'sm', // => Expected 1 arguments, but got 2.
  );
  // … 略
};

実装された PR は merge されているのですが、Chakra UI のドキュメント にも記載がないので Chakra UI @chakra-ui/react: ^1.6.3 ではまだ使えないオプションなのかもです。

追記: @chakra-ui/react@1.6.4 以降なら使えそう

16-06-2021
@chakra-ui/react@1.6.4
Media Query v1.1.0
🚀 useBreakpointValue() now supports receiving a defaultBreakpoint as the second argument to support SSR/SSG.
cf. chakra-ui/CHANGELOG.md | GitHub

2021 6/16 にリリースされた @chakra-ui/react v1.6.4 からは使えるっぽいです。

解決方法: 自前でデフォルト値を設定する

useBreakpointValuedefaultBreakpoint オプションが使えず、初期レンダリング時に undefined が返ってくるので、useBreakpointValue() || defaultValue として自前でデフォルト値を設定してしまえば分割代入を使っていてもエラーになりません。

Array パターン

import { useBreakpointValue } from '@chakra-ui/react';

type ButtonSizeType = string;
type ButtonLabelType = string;
type BreakpointType = [ButtonSizeType, ButtonLabelType];

const ResponsiveButton: VFC = () => {
  const responsiveValues: BreakpointType[] = [
    ['sm', '登録'],
    ['md', '登録する'],
  ];
  const [buttonSize, buttonLabel] =
    useBreakpointValue(responsiveValues) || responsiveValues[0];
}

初回レンダリング時は undefined || responsiveValues[0] となり responsiveValues[0] の値が使われるようになります。

Object パターン

import { useBreakpointValue } from '@chakra-ui/react';
import { BaseBreakpointConfig } from '@chakra-ui/theme-tools';


type ResponsiveValue = {
  buttonSize: string;
  buttonLabel: string;
};

type BreakpointTypes = {
  [key in keyof BaseBreakpointConfig]?: ResponsiveValue;
};

const ResponsiveButton = () => {
  const breakpointValues: BreakpointTypes = {
    sm: { buttonSize: 'sm', buttonLabel: '登録' },
    md: { buttonSize: 'md', buttonLabel: '登録する' },
  };
  const defaultValue = breakpointValues.sm;
  const { buttonSize, buttonLabel } =
    useBreakpointValue(breakpointValues) || defaultValue;

BreakpointTypes の key が Partial になっているので、defaultValue の指定が breakpointValues オブジェクトに存在するキーに限定できてないですが、Next.js の初回ロード時は defaultValue が使われるようにできました。

追記: まとめ

  1. デフォルトだと useBreakpointValue() が初回レンダリング時に undefined を返すので、分割代入で値を受け取ろうとするとエラーになる
  2. Chakra UI v1.6.4 以降なら useBreakpointValue の第二引数 (defaultBreakpoint) オプションで初期値になるブレイクポイントを指定する
  3. Chakra UI が v1.6.4より古い場合は useBreakpointValue() || defaultValue で初期値を自前で指定してしまう
所管

GitHub のコードを見に行くとデフォルト値を指定できる defaultBreakpoint オプションがあり、これを渡せばサクット解決☆(ゝω・)
と思ったのですが、オプションが使えなかったというヲチで || を使うハック的な方法で何とか解決できました。
バグ踏みの達人なので、Chakra UI まだまだハマりそうです…

今回は Next.js の環境だったからこうなったのですが、React でも SSR をする環境だと SSR 時は node.js で実行されるので window オブジェクトがなく同じ問題にハマるだろうな〜と思いメモを書くことにしました。
そういえば、以前のお仕事で JSX 内でメディアクエリを利用してコンポーネントの出し分けをするコンポーネントを作成していて、SSR の際にメディアクエリが取得できず、結局クライアントで再度 React 描画時にコンポーネントの表示変更行われ Layout Shift (CLS) が発生してしまう問題に悩まされたことを思い出しました。 (その記事を描こうと思ったまま書いていなかった…


[参考]

チャクラ宙返りのおもちゃ流石にもう出てこないか…