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で受け取ったtheme
がtoCSSVar
関数に渡されて変換され、その結果がEmotionThemeProvider
に設定されている点です。
useTheme
関数はこの変換後のcomputedTheme
を取得しています。
なお、ThemeProviderへthemeがChakraProviderからどうわたっているかはchakra-provider.tsx→create-provider.tsx→provider.tsx→providers.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.matchMedia
のmatches
プロパティは、画面幅が指定された範囲内にあれば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,
}
}
この関数は次のことを行います。
- 受け取った
breakpoints
オブジェクトをObject.entries
で[key, value]
のペアの配列に変換 -
sortByBreakpointValue
関数でソート - 各ブレイクポイントに対して、
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
は入力値から数値部分のみを取得するため、10em
と15px
のような異なる単位の値が混在すると、単位を無視して数値のみでソートされます(この場合は10
と15
として解釈され、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
のブレイクポイントが適用され、カスタム定義したsm
やmd
が想定通りに機能しなくなってしまいます。
これこそが、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は多くのファイルと複雑な構造を持っているため、問題の切り分けには苦労しましたが、最終的に原因を見つけることができて満足です。
この問題の要点をまとめると:
- Chakra UIは内部的にemを前提として処理している
- pxとemが混在すると、ソート処理の順序に影響が出る
- 結果として、生成されるメディアクエリの範囲が想定と異なってしまう
解決策としては、pxを使用する場合は必ず全てのブレイクポイント(sm, md, lg, xl, 2xl)を定義するか、全てemで統一することが有効です。
この記事が、同様の問題に悩む方々の助けになれば幸いです。
ここまで読んでいただき、ありがとうございました。
Discussion