Zenn
🤖

Chakra UI v2でuseBreakpointValueが動かなくなる要因と解消方法について

に公開

はじめに

Chakra UIのV2を使用しているのですが、レスポンシブデザインを実装する際に予期せぬ動作に遭遇しました。
この記事では、レスポンシブ対応の時に使用したuseBreakpointValueフックが正しく機能しないケースについて、その原因を探り解決方法を解説していきます。
具体的には、以下のようなコードで発生する問題を見ていきます。

import * as React from 'react'
import { ChakraProvider, extendTheme } from '@chakra-ui/react'
import { BreakPoint } from './components/BreakPoint'
import { BreakpointDebug } from './components/MatchQuery'
const breakpoints = {
  base: '0px',
  sm: '410px',
  md: '768px',
  lg: '1280px',
}
const theme = extendTheme({ breakpoints })
export default function App() {
  return (
    <ChakraProvider theme={theme}>
      <BreakPoint />
    </ChakraProvider>
  )
}

BreakPointコンポーネントの実装は次のようになっています:

import { Box, useBreakpointValue } from "@chakra-ui/react";
export function BreakPoint() {
    const breakpoint = useBreakpointValue({ md: 'none', lg: 'inline' })
    return (
        <div>
            <Box display={breakpoint}>aaa</Box>
        </div>
    );
}

期待していた動作は、768〜1279pxでは何も表示されず、1280px以上で「aaa」がインライン要素として表示されることでした。
しかし実際には、Boxコンポーネントに何もスタイルが設定されない状況が発生します。
この問題の原因と解決方法を順に見ていきます。

前提

議論を始める前に、いくつかの前提知識を確認します。

  • この記事では、extendThemeの基本的な使い方を理解していることを前提としています
  • extendThemeの詳細については、Chakra UIの公式ドキュメントを参照してください
  • extendThemeの重要な特性として、以下の点を理解しておく必要があります:
    • カスタムプロパティは基本的にデフォルトテーマを上書きします
    • カスタムプロパティで指定していないプロパティはデフォルトの値が設定されます

カスタムテーマの上書きはあくまで、一致するプロパティ部分のみ上書きするということは、この後の話に関わっているのでご認識のほどよろしくお願いします。

解消方法

先に、解決方法をお伝えします。
問題を解決するために必要なことは実はドキュメントに記載されていました。

Note: If you're using pixels as breakpoint values make sure to always provide a value for the 2xl breakpoint, which by its default pixels value is "1536px"

ピクセル(px)を使用する場合は、sm, md, lg, xl, 2xlの全てのブレイクポイント定義をextendThemeに渡す必要があります。
したがって、以下のように修正することで期待通りの動作が得られます:

const breakpoints = {
  base: '0px',
  sm: '410px',
  md: '768px',
  lg: '1280px',
  xl: '1440px',
  '2xl': '1536px',
}

一方で、emを使用する場合は全てのプロパティを渡さなくても正しく動作します:

const breakpoints = {
  base: '0em',/** 0px */
  sm: '25.625em',/** 410px */
  md: '48em',/** 768px */
  lg: '80em',/** 1280px */
}

以上で問題は解決しますが、「なぜpxの場合は全てのプロパティを定義する必要があり、emの場合はそうでないのか」という疑問が残ります。
これからその理由を深掘りしていきましょう。
問題の理由としては、Chakra UIが内部的にemを前提として処理しているため、一部のプロパティだけをpxで指定すると、ソートの順序に影響が出て、生成される@mediaクエリの範囲が想定と異なってしまうことがあげられます。

useBreakpointValueからBreakpointを扱っている部分までを辿る

まずは、useBreakpointValueのソースコードの処理部分を見てみます。

export function useBreakpointValue<T = any>(
  values: Partial<Record<string, T>> | Array<T | null>,
  arg?: UseBreakpointOptions | string,
): T | undefined {
  const opts = isObject(arg) ? arg : { fallback: arg ?? "base" }
  const breakpoint = useBreakpoint(opts)
  const theme = useTheme()
  if (!breakpoint) return
  const breakpoints: string[] = Array.from(theme.__breakpoints?.keys || [])
  const obj = Array.isArray(values)
    ? Object.fromEntries<any>(
        Object.entries(arrayToObjectNotation(values, breakpoints)).map(
          ([key, value]) => [key, value],
        ),
      )
    : values
  return getClosestValue(obj, breakpoint, breakpoints)
}

ここでは、useBreakpoint関数とuseTheme関数が呼び出されています。
useBreakpoint関数の方が関係ありそうですが、今回着目するのは主にuseThemeの部分です。
useThemeの実装の一部は以下のようになっています。

import { ThemeContext } from "@emotion/react"
export function useTheme<T extends object = Dict>() {
  const theme = useContext(
    ThemeContext as unknown as React.Context<T | undefined>,
  )
  if (!theme) {
    throw Error(
      "useTheme: `theme` is undefined. Seems you forgot to wrap your app in `<ChakraProvider />` or `<ThemeProvider />`",
    )
  }
  return theme as WithCSSVar<T>
}

この関数は、EmotionのThemeContextから値を取得しているのが分かります。
このThemeContextの値はThemeProviderコンポーネントによって提供されます。

export function ThemeProvider(props: ThemeProviderProps): JSX.Element {
  const { cssVarsRoot, theme, children } = props
  const computedTheme = useMemo(() => toCSSVar(theme), [theme])
  return (
    <EmotionThemeProvider theme={computedTheme}>
      <CSSVars root={cssVarsRoot} />
      {children}
    </EmotionThemeProvider>
  )
}

見ていただきたいのは、propsで受け取ったthemetoCSSVar関数に渡されて変換され、その結果がEmotionThemeProviderに設定されている点です。
useTheme関数はこの変換後のcomputedThemeを取得しています。
なお、ThemeProviderへthemeがChakraProviderからどうわたっているかはchakra-provider.tsxcreate-provider.tsxprovider.tsxproviders.tsxと追ってもらえれば分かりますので、良ければご参照ください。
話を戻すと、computedThemeに使用されたtoCSSVar関数の一部を見ると、ブレイクポイント処理に関わる部分がありますので、コードの一部を記載します。

import { analyzeBreakpoints } from "@chakra-ui/utils"
export function toCSSVar<T extends Record<string, any>>(rawTheme: T) {
  Object.assign(theme, {
    __breakpoints: analyzeBreakpoints(theme.breakpoints),
  })
  return theme as WithCSSVar<T>
}

ここでanalyzeBreakpoints関数が呼び出され、その結果が__breakpointsプロパティに設定されています。
この関数が今回の問題と関わりが深い部分ですので、後の章で取り扱います。

(余談)useBreakpointValueが一致するBreakpointを取得する処理について

本題から少し脱線しますが、ブレイクポイントの判定処理についても簡単に触れます。
Breakpointを判定し、一致する値を取得する処理は先程スルーしたuseBreakpoint関数がになっています。
なので、実装を見ていくため、useBreakpoint関数の主な部分をコメント付きで抽出します。

export function useBreakpoint(arg?: string | UseBreakpointOptions) {
  /** 先程解説したBreakpointsの値を持つthemeを取得 */
  const theme = useTheme()
	/** 
	*	Breakpoints部分の値を抽出しBreakpoint名(base,smなど)と 
	* 「@media screen and (min-width: 320px) and (max-width: 480px)」といった形で記載されている文字列を持つminMaxQuery
	* これら二つの値を抽出している
	* ※内部の処理は後述の章にて解説
	*/
  const breakpoints = theme.__breakpoints!.details.map(
    ({ minMaxQuery, breakpoint }) => ({
      breakpoint,
      query: minMaxQuery.replace("@media screen and ", ""),
    }),
  )
	/** 
	* useMediaQuery関数にqueryを渡している
	* useMediaQueryは画面幅がqueryに記載されているmin-width~max-widthの範囲内であれば
	* trueを返すhook関数である
	*/
  const values = useMediaQuery(
    breakpoints.map((bp) => bp.query),
  )
  /** useMediaQuery関数でtrueとなっている番号を取得し、一致するBreakpointの値を返す */
  const index = values.findIndex((value) => value == true)
  return breakpoints[index]?.breakpoint ?? opts.fallback
}

この関数はuseMediaQueryを使用して、現在の画面幅がどのブレイクポイント範囲に該当するかを判断しています。
useMediaQuery関数内ではwindow.matchMediaを使用して、メディアクエリと現在の画面幅が一致するかをチェックしています。

setValue(
  queries.map((query) => ({
    media: query,
    matches: win.matchMedia(query).matches,
  }))
);

window.matchMediamatchesプロパティは、画面幅が指定された範囲内にあればtrueを返します。
この仕組みにより、現在の画面幅に該当するブレイクポイントを特定できるようになっています。

analyzeBreakpoints関数の分析

ここからはanalyzeBreakpoints関数について詳しく見ていきます。
先程の余談で、useBreakpointValue周りのBreakpointの判定は__breakpointsプロパティ内のdetails部分の値を使うことが分かったので、当該部分を中心にanalyzeBreakpoints関数のコードを抽出します。

export function analyzeBreakpoints(breakpoints: Record<string, any>) {
  if (!breakpoints) return null
  breakpoints.base = breakpoints.base ?? "0px"
  const queries = Object.entries(breakpoints)
    .sort(sortByBreakpointValue)
    .map(([breakpoint, minW], index, entry) => {
      let [, maxW] = entry[index + 1] ?? []
      maxW = parseFloat(maxW) > 0 ? subtract(maxW) : undefined
      return {
        _minW: subtract(minW),
        breakpoint,
        minW,
        maxW,
        maxWQuery: toMediaQueryString(null, maxW),
        minWQuery: toMediaQueryString(minW),
        minMaxQuery: toMediaQueryString(minW, maxW),
      }
    })
  return {
    details: queries,
  }
}

この関数は次のことを行います。

  1. 受け取ったbreakpointsオブジェクトをObject.entries[key, value]のペアの配列に変換
  2. sortByBreakpointValue関数でソート
  3. 各ブレイクポイントに対して、toMediaQueryString関数でメディアクエリの文字列を生成

Object.entriesは分割するだけなので、今回関わっていそうなのはsort(sortByBreakpointValue)とmaxW取得部分とtoMediaQueryString関数ぽそうです。
ただ、toMediaQueryString関数はソースコードを見てもらえば大体雰囲気はつかめるので解説は省略します。
そのため、ソートとmaxWの取得方法を見ていきます。
まずソート部分からです。
sortByBreakpointValue関数は以下のように定義されています。

const sortByBreakpointValue = (a: any[], b: any[]) =>
  parseInt(a[1], 10) > parseInt(b[1], 10) ? 1 : -1

これはparseIntを使用して、ブレイクポイントの値を10進数として解釈し、小さい順にソートしています。
ここが注目すべき点です。
parseIntは入力値から数値部分のみを取得するため、10em15pxのような異なる単位の値が混在すると、単位を無視して数値のみでソートされます(この場合は1015として解釈され、15pxが大きい値とみなされます)。
また、maxWの取得部分も確認します。

let [, maxW] = entry[index + 1] ?? []
maxW = parseFloat(maxW) > 0 ? subtract(maxW) : undefined

ここでは、ソートされた配列の次の要素を取得し、それをその区間の上限値として使用しています。
この処理は、配列がすでに正しくソートされていることを前提としています。
そのため、ソートの順序に問題があると、範囲設定も想定と異なる結果になってしまいます。

関数が受け取るbreakpointsとソートについての問題

ここからが問題の要点です。
analyzeBreakpoints関数は、デフォルトテーマとカスタムテーマを組み合わせたブレイクポイント設定を受け取ります。
Chakra UIのデフォルトブレイクポイント設定はこちらの定義なっています。

const breakpoints = {
  base: "0em",
  sm: "30em",
  md: "48em",
  lg: "62em",
  xl: "80em",
  "2xl": "96em",
}

そして、今回のカスタムテーマは以下のように設定しています。

const breakpoints = {
  base: '0px',
  sm: '410px',
  md: '768px',
  lg: '1280px',

extendThemeは上書きの仕組みを使うため、最終的なブレイクポイント設定は以下のようになります。

const breakpoints = {
  base: '0px',
  sm: '410px',
  md: '768px',
  lg: '1280px',
  xl: "80em",
  "2xl": "96em",
}

これをObject.entries(breakpoints).sort(sortByBreakpointValue)でソートすると:

[
  ["base", "0px"],
  ["xl", "80em"],
  ["2xl", "96em"],
  ["sm", "410px"],
  ["md", "768px"],
  ["lg", "1280px"]
]

このような順序になります。
上記結果になることを実際に動かして試せるanalyzeBreakpoints関数を模したplaygroundも用意しているので、実際に試してもらえると幸いです。
先程の配列の結果は、後続の処理に影響を及ぼします。
単位が混在しているため、ソートの際に使用されたparseIntによる数値解釈で"80em""410px"よりも先に配置されています。
このソート結果から生成されるメディアクエリとBreakpointは次のようになります。

[
  {
    "breakpoint": "base",
    "minMaxQuery": "@media screen and (min-width: 0px) and (max-width: 79.98em)"
  },
  {
    "breakpoint": "xl",
    "minMaxQuery": "@media screen and (min-width: 80em) and (max-width: 95.98em)"
  },
  {
    "breakpoint": "2xl",
    "minMaxQuery": "@media screen and (min-width: 96em) and (max-width: 409.98px)"
  },
  {
    "breakpoint": "sm",
    "minMaxQuery": "@media screen and (min-width: 410px) and (max-width: 767.98px)"
  },
  {
    "breakpoint": "md",
    "minMaxQuery": "@media screen and (min-width: 768px) and (max-width: 1279.98px)"
  },
  {
    "breakpoint": "lg",
    "minMaxQuery": "@media screen and (min-width: 1280px)"
  }
]

baseの次にxlが配置され、baseのMediaクエリのMaxWidthが約80em(一般的には1280px相当)になってしまいます。
つまり、1280pxまでの画面幅では常にbaseのブレイクポイントが適用され、カスタム定義したsmmdが想定通りに機能しなくなってしまいます。
これこそが、pxを部分的にカスタムテーマとして与えると上手く動かなくなる要因です。
これは内部の動作を見ないと絶対に分からない挙動だなと感じました。
もうちょっと型定義で制限したい気持ちもありますが、上手い方法は思い浮かびませんでした。
とはいえ、要因の詳細まで分かったので一旦は満足できとします。

(余談)useBreakpointValueの設定が適切か確認する方法

useBreakpointValueが想定通りのBreakpointの設定になっているかを確認するには、useThemeフックを使って以下のようなコードを実装すると便利です。

import { Box, useBreakpointValue, useTheme } from "@chakra-ui/react";
export function BreakPoint() {
    const theme = useTheme();
    console.log("Theme breakpoints:", theme.__breakpoints);
    return (
			/** 任意のコンポーネント */
    );
}

これにより、ブレイクポイント関連の設定値を確認でき、問題の切り分けに役立ちます。

おわりに

今回はChakra UI V2で提供されているuseBreakpointValueが想定通りに動作しない問題の原因と解決方法を詳しく見てきました。
Chakra UIは多くのファイルと複雑な構造を持っているため、問題の切り分けには苦労しましたが、最終的に原因を見つけることができて満足です。
この問題の要点をまとめると:

  1. Chakra UIは内部的にemを前提として処理している
  2. pxとemが混在すると、ソート処理の順序に影響が出る
  3. 結果として、生成されるメディアクエリの範囲が想定と異なってしまう

解決策としては、pxを使用する場合は必ず全てのブレイクポイント(sm, md, lg, xl, 2xl)を定義するか、全てemで統一することが有効です。
この記事が、同様の問題に悩む方々の助けになれば幸いです。
ここまで読んでいただき、ありがとうございました。

Discussion

ログインするとコメントできます