ReactでResizeObserverを使い、要素の幅で表示を変える
ResizeObserver を使うと、画面全体の幅ではなく、要素そのものの幅に合わせて React のコンポーネントの表示を変えられます。同じカードを、広い列では横並びに、狭いサイドバーでは縦並びにする、といった切り替えができます。監視の登録と片付けは、ref に渡す関数の中にまとめます。
見た目(CSS)を変えるだけなら、CSS のコンテナークエリでも、コンテナーの大きさに応じてスタイルを当てられます。出す部品そのもの(数字の欄を出すかどうかなど)を幅で変えたいときは、JavaScript で幅を受け取ります。
検証環境は Ubuntu 24.04、Node.js 22.22.0 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2 です。.tsx を tsc で型チェックしてから esbuild で開発用に変換し、Chrome for Testing 153(Chromium)を Playwright 1.64.1 で操作しました。Firefox や Safari、スマホの実機では試していません。
ResizeObserver は、ref に渡す関数の中で observe し、その関数から disconnect する片付けの関数を返します。state には幅の数値ではなく、区切りに当てはめた名前('wide' など)を入れると、名前が変わらないあいだは React が描画し直しを飛ばします。
要素の幅を受け取るフック
まず、要素の幅を state にするだけのフックです。
import { useCallback, useState } from 'react';
// 要素の幅(content box)を state にする
export function useElementWidth<T extends Element>() {
const [width, setWidth] = useState<number | null>(null);
// React 19 では、ref に渡した関数から片付けの関数を返せる
const ref = useCallback((el: T | null) => {
if (!el) return;
const observer = new ResizeObserver(([entry]) => {
setWidth(entry.contentBoxSize[0].inlineSize);
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return [ref, width] as const;
}
useEffect ではなく、ref に渡す関数(ref のコールバック)の中で監視を始めています。公式リファレンスの ref のコールバックの説明にあるとおり、React は DOM の要素が画面に加わったときにこの関数を呼び、要素が外れたときに、返した片付けの関数を呼びます。片付けの関数を返せるのは、React 19 で入った機能です。
幅は contentBoxSize[0].inlineSize で読んでいます。MDN の contentBoxSize のページによると、inlineSize は横書きなら幅です。値が配列なのは、段組みで要素がいくつかに分かれる場合に備えるためです。
幅を区切りの名前にして、表示を切り替える
表示を切り替えるときは、幅をそのまま返すより、区切りに当てはめた名前を返すフックのほうが使いやすくなります。
import { useCallback, useState } from 'react';
// [この幅以上なら, この名前] を、幅の大きい順に並べる。最後は 0 にする
export type Breakpoints<N extends string> = readonly (readonly [minWidth: number, name: N])[];
// 要素の幅を、区切りに当てはめた名前にして返す
export function useWidthClass<T extends Element, N extends string>(breakpoints: Breakpoints<N>) {
const [name, setName] = useState<N | null>(null);
const ref = useCallback(
(el: T | null) => {
if (!el) return;
const observer = new ResizeObserver(([entry]) => {
const width = entry.contentBoxSize[0].inlineSize;
const hit = breakpoints.find(([min]) => width >= min) ?? breakpoints[breakpoints.length - 1];
// 同じ名前なら、React は描画し直さない
setName(hit[1]);
});
observer.observe(el);
return () => observer.disconnect();
},
[breakpoints],
);
return [ref, name] as const;
}
使う側では、外側の div に ref を付けて、その幅で中身を決めます。次のコードは、検証に使ったカードです。幅が 480px 以上なら横並びにして数字の欄も出し、300px 以上なら数字の欄を隠し、それより狭ければ縦並びにします。
import { useWidthClass, type Breakpoints } from './useWidthClass';
// 配列はコンポーネントの外に置き、描画のたびに作り直さない
const CARD_BREAKPOINTS: Breakpoints<'wide' | 'medium' | 'narrow'> = [
[480, 'wide'],
[300, 'medium'],
[0, 'narrow'],
];
export function ProfileCard() {
const [ref, layout] = useWidthClass<HTMLDivElement, 'wide' | 'medium' | 'narrow'>(CARD_BREAKPOINTS);
return (
<div ref={ref}>
{/* 幅が分かるまでは描かない */}
{layout && (
<div className={`card ${layout}`} data-layout={layout}>
<div className="avatar" />
<div>
<strong>ねこ</strong>
<p style={{ margin: 0, fontSize: 14 }}>レイアウト:{layout}</p>
</div>
{layout !== 'medium' && (
<div className="stats">
<span>記事 12</span>
<span>いいね 34</span>
</div>
)}
</div>
)}
</div>
);
}
並べ方は、.card.wide などのクラスに当てた CSS で変えています。grid の列の数を変え、medium では数字の欄を隠します。
.card { display: grid; gap: 12px; padding: 16px; border: 2px solid #00c8e8; border-radius: 10px; background: #eefafd; }
.card.narrow { grid-template-columns: 1fr; text-align: center; }
.card.medium { grid-template-columns: 64px 1fr; align-items: center; }
.card.wide { grid-template-columns: 96px 1fr 160px; align-items: center; }
.card.medium .stats { display: none; }

カードを入れた枠の幅を変えて、レイアウトの名前を読んだ結果です。
枠の幅 480px: {"label":"カードの幅:480px","layout":"wide"}
枠の幅 479px: {"label":"カードの幅:479px","layout":"medium"}
枠の幅 300px: {"label":"カードの幅:300px","layout":"medium"}
枠の幅 299px: {"label":"カードの幅:299px","layout":"narrow"}
区切りの値ちょうど(480px と 300px)では、広いほうの名前になりました。画面(ブラウザのウィンドウ)の大きさは変えていません。枠の幅だけで表示が変わっています。
幅の数値ではなく、名前を state にする
幅の数値を state にすると、幅が変わるたびに描画し直します。幅の数値を state にして、描画のときにレイアウトを決めるコンポーネントと、useWidthClass で名前を state にするコンポーネントを並べ、枠の幅を 305px から 700px まで 5px ずつ 80 回変えて、描画の回数を数えました。枠の幅は、React を通さずに DOM で変えています。
幅を変える前: {"byWidth":3,"byClass":3,"roCallbacks":4}
305px から 700px まで 5px ずつ 80 回変えたあと: {"byWidth":83,"byClass":5,"roCallbacks":164}
幅を state にしたほう(byWidth)は、80 回ぶん描画が増えました。名前を state にしたほう(byClass)は、2 回しか増えていません。この間にレイアウトが変わったのは、幅が 480px に達して medium から wide になった1回だけです。公式リファレンスの set 関数の注意書きにあるとおり、新しい値が今の値と同じ(Object.is で比べる)なら、React は描画し直しを飛ばします。ただし、子の描画を飛ばす前にコンポーネントを呼ぶことはある、とも書かれています。レイアウトは1回しか変わっていないのに2回増えたのは、そのためと考えられます。
区切りの配列は、コンポーネントの外に置いてください。useWidthClass([[400, 'wide'], [0, 'narrow']]) のように中で書くと、描画のたびに新しい配列になり、useCallback が新しい関数を返します。公式リファレンスの ref のコールバックの注意書きにあるとおり、ref に別の関数が渡されると、React は前の関数の片付けを呼んでから、新しい関数を呼びます。検証では、ボタンで3回描画し直したところ、observe が 2 回から 5 回に増え、そのたびに監視を作り直していました。
マウント直後: {"created":2,"observe":2,"disconnect":1,"active":1,"callbacks":2}
+1 を3回押したあと: {"created":5,"observe":5,"disconnect":4,"active":1,"callbacks":5}
この画面は StrictMode を使っていませんが、マウント直後から observe が2回あります。最初の描画で監視を始めたあと、名前が分かって描画し直したときに配列が新しくなり、監視を作り直したためです。
ref は useRef ではなく、関数で渡す
useRef で要素を受け取り、useEffect の中で observe する書き方もあります。ただし、依存配列を空にすると、要素があとから出るときに監視が始まりません。ボタンを押すとパネルが出る画面で、2つの書き方を比べました。
パネルを出したあと: {"obj":"null","cb":"240","ro":{"created":2,"observe":2,"disconnect":1,"active":1,"callbacks":1}}
callback ref の側のパネルを消したあと: {"created":2,"observe":2,"disconnect":2,"active":0,"callbacks":1}
useRef と useEffect の書き方(obj)は、マウントしたときにはまだ要素がなかったため、パネルを出しても幅は null のままでした。ref のコールバックの書き方(cb)は、パネルが出た時点で監視を始め、幅 240 を受け取りました。パネルを消すと disconnect が呼ばれ、監視中の数は 0 に戻りました。
この画面は StrictMode で動かしています。observe と disconnect が1回ずつ多いのは、公式リファレンスの注意書きにある、開発用ビルドの StrictMode で1回だけ余分に行われる、登録と片付けの組です。片付けで disconnect しているので、監視が残ることはありませんでした。
読む幅はどのボックスか
幅 300px、padding 20px、border 2px、box-sizing: border-box の要素を監視して、値を比べました。2つ目の要素には transform: scale(0.5) を付けています。
plain: {"id":"plain","contentRect":256,"contentBoxSize":256,"borderBoxSize":300,"offsetWidth":300,"getBoundingClientRect":300}
scaled: {"id":"scaled","contentRect":256,"contentBoxSize":256,"borderBoxSize":300,"offsetWidth":300,"getBoundingClientRect":150}
contentBoxSize は、padding と border を除いた 256 でした。padding と border まで含めた幅で区切りたいときは borderBoxSize を使います。contentRect も同じ 256 ですが、MDN の contentRect のページでは、以前の実装から残っている古いプロパティとされています。
transform で縮めた要素では、getBoundingClientRect() は見た目どおりの 150 でしたが、ResizeObserver の値は縮める前の 300 と 256 のままでした。ResizeObserver で受け取るのは、transform をかける前の、レイアウトで決まった大きさです。transform で縮めた見た目の大きさではありません。
observe した直後にも、通知が1回来ます。検証では、幅 200px の要素では 200 が、display: none の要素では 0 が届きました。隠れている要素は幅 0 になるので、このフックでは一番狭い名前になります。display: none を外すと、150 の通知が届きました。
自分の中身で幅が変わる要素を見ない
監視する要素の幅が切り替えた中身で決まり、切り替えた結果の幅が区切りの反対側に戻ると、切り替えが止まらなくなります。display: inline-block の要素を監視し、幅が 200px 以上なら幅 120px の短い表示に、200px 未満なら幅 260px の長い表示にする画面を作り、1秒待ちました。コールバックの中でふつうに setState した場合の結果です。
開いた直後: {"renders":5,"callbacks":4}
1秒後: {"renders":65,"callbacks":64,"errors":0}
flushSync で包んで setState した場合の結果です。
開いた直後: {"renders":4,"callbacks":3}
1秒後: {"renders":64,"callbacks":63,"errors":63,"firstError":"ResizeObserver loop completed with undelivered notifications."}
ふつうの setState では、エラーは出ませんでしたが、1秒で 60 回描画し直し、表示が行き来し続けました。コールバックの中で flushSync を使って同期的に描画させると、同じように行き来したうえで、window に「ResizeObserver loop completed with undelivered notifications.」のエラーが 63 回届きました。
MDN の ResizeObserver のページの、通知のエラーの説明によると、ブラウザは、画面に描き出す(ペイントする)前に通知を出します。そこで大きさが変わると、スタイルとレイアウトを計算し直し、それによってさらに通知が出ることがあります。この繰り返しでは、前の回より DOM の深い位置にある要素の通知だけをその場で処理します。前の回より深い位置にない要素の通知は、次のフレームに回し、このエラーを出します。また、このしくみはブラウザが固まるのを防ぐだけで、ループそのものは止めないとも書かれています。エラーが出ないふつうの setState でも、行き来は止まりませんでした。
外側の枠を見て、名前で切り替える
監視するのは、親から幅が決まる外側の要素にします。中身を切り替えても、その要素の幅は変わりません。ref のコールバックの中で observe して disconnect を返し、区切りの配列はコンポーネントの外に置きます。state には名前を入れると、区切りをまたがないあいだは React が描画し直しを飛ばします。





