FORCIA CUBEフォルシアの情報を多面的に発信するブログ

Reactで要素の高さをそろえる

2021.12.04

アドベントカレンダー2021 エンジニア テクノロジー

これは、FORCIA Advent Calendar 2021の4日目の記事です。


はじめまして。2021年度新卒入社エンジニアの高嶋です。 外部に公開される記事を書くのは初めてなので緊張しています。

本稿では表題の通りReactを用いて要素の高さを揃える方法についてご紹介します。私自身が最近業務内でハマった内容なのですが、大変勉強になったと感じたため記事にすることにしました。解決策を出すにあたりアドバイスをくださった先輩方ありがとうございました。

やりたかったことは、React, Reduxを用いたWebアプリのとあるページで内部のデータ数によって高さが可変になるような枠A, Bをそろえて表示することです。 問題の画面を簡易的に表すと以下のようになります。

01takashima_画面イメージ.png

データの数が枠Aと枠Bで同じ時は良いのですが、異なる場合には高さが揃わなくなってしまいます。

要素同士の高さを揃えたいとき、通常はCSSを使用することをまず考えるでしょう。 しかし、今回は高さをそろえたい要素同士を同一のwrapperで包むことができないという事情がありました。

02takashima_wrapperで揃えられない.png

縦方向に構成されたグループに内包されているため、要素高を統一するための横方向のwrapperで該当要素同士を包むことができません。 そのため、ここではReactを利用して要素高の統一を実現することにしました。

案1:useState, useEffect, getElementsByClassNameの合わせ技

案1があるということは案2もあります。初めにお話ししておきますが案1は失敗に終わります。

まず初めに試したのは、getElementsByClassNameメソッドで要素とその要素高を取得し、useStateフックで要素高のうち高い方を記録、それを該当要素のstyleに指定することで高さを揃える方法です。また、枠内に表示するデータ数はユーザーの操作によって可変である為、useEffectフックを用いてその都度要素の最大高の計算し適用します。

// 親コンポーネント内
// ~~~~~~~~~~~~~~~省略~~~~~~~~~~~~~~~~~~~

// 対象要素の取得
const frameElementArr: HTMLElement[] = [].slice.call(
	typeof document !== "undefined" &&
		document.getElementsByClassName("frame-class")
);

// 要素高を記録させるためのstate
const [frameMaxHeight, setFrameMaxHeight] = React.useState<
    number | undefined
>(undefined);

React.useEffect(() => {
    if (
        // 枠内に表示するデータ数を比較
        data[frame1].length !==
        data[frame2].length
    ) {
        // データ数が異なった場合は枠1, 2の要素高の高い方をframeMaxHeightにセット
        setFrameMaxHeight(
            Math.max(...frameElementArr.map(elment => elment.offsetHeight)) 
        );                          
    } else {
        // 大きいエレメントに合わせたまま元に戻らなくなってしまうので、データ数が同じである場合は1度リセットする。
        setFrameMaxHeight(undefined);
    }
}, [data]);

return (
    <frame
        data = {data[frame1]}
        // 要素高の最大値を渡す
        frameMaxHeight = {frameMaxHeight}
    />

    <frame
        data = {data[frame2]}
        frameMaxHeight = {frameMaxHeight}
    />
);
// frameコンポーネント内
// ~~~~~~~~~~省略~~~~~~~~~~~~~~~~~~~
return (
    <div 
       className="frame-class"
       style={
           // frameMaxHeightがundefind(リセット状態)の時は高さ指定なしとする
            frameMaxHeight
                ? { minHeight: `${frameMaxHeight}px` }
                : { minHeight: "initial" }
        }
    >
        dataを表示(省略)
    </div>
)

枠内のデータ数を減らした場合にframeMaxHeightを減らすことができなくなってしまうので、データ数が同一(style指定なしで描画しても高さが揃う)の場合にはuseEffect内でframeMaxHeightundefinedに設定して要素高をリセットしています。frameMaxHeightundefinedの場合には枠要素のminHeight指定を初期値に指定します。

これで一見うまくいったかのように思えたのですが、実は初期描画時は枠A、Bの要素高を揃えることができません。(私の場合、初期描画時はデータ数が同一、ユーザー操作でデータ数が不均一になるような状況だったので初めは気づきませんでした)

この部分に着目してください。親コンポーネントの初回レンダリング時に子コンポーネントはまだレンダリングされていません。そのため、子コンポーネント内の要素の参照が取れず要素高を取得できません。

// 対象要素の取得
const frameElementArr: HTMLElement[] = [].slice.call(
	typeof document !== "undefined" &&
		document.getElementsByClassName("frame-class")
);

ちなみに、再レンダリング時も同様の状況になるのではと考えたのですが、

HTML DOM 内の HTMLCollection は生きて (live) います。それらは元になった document が変更された時点で自動的に更新されます。 https://developer.mozilla.org/ja/docs/Web/API/HTMLCollection

より、親コンポーネントを再レンダリングする際にはすでに前のレンダリングにより枠A, B自体はDOMに存在しておりHTMLCollectionの取得に成功、子コンポーネントの再レンダリングが走り枠A, Bが再描画されたとしても参照は自動的に更新されるため要素高が取得可能だったのだと考えています。初期描画の場合にはgetElementsByClassName()の戻り値がundefindだったため、子コンポーネントがレンダリングされても参照が更新されなかったのではないでしょうか。

案2:useState, useEffect, useRefの利用

案1の段階で原因すら分からず困っていたところで、useRefフックを使用した本方法について教えていただきました。useRefフックについて、要素の操作に利用できるフックだというなんとなくの知識はあったのですが、正直DOMオブジェクトのメソッドを利用する場合との違いが分かっていませんでした。refについて理解する上でも大変参考になったため共有します。

コードとしては以下のようになります。

 // 親コンポーネント内
 // ~~~~~~~~~~~~~~~省略~~~~~~~~~~~~~~~~~~~
 
 
- // 対象要素の取得
- const frameElementArr: HTMLElement[] = [].slice.call(
- typeof document !== "undefined" &&
- document.getElementsByClassName("frame-class")
- );
+ // useRefで参照を持つ
+ const frame1Ref = React.useRef<HTMLDivElement>(null);
+ const frame2Ref = React.useRef<HTMLDivElement>(null);

 // 要素高を記録させるためのstate
 const [frameMaxHeight, setFrameMaxHeight] = React.useState<
     number | undefined
 >(undefined);
 
 React.useEffect(() => {
      if (
          // 枠内に表示するデータ数を比較
         data[frame1].length !==
         data[frame2].length
     ) {
         // データ数が異なった場合は枠1, 2の要素高の高い方をframeMaxHeightにセット
-         setFrameMaxHeight(
-             Math.max(...frameElementArr.map(elment => elment.offsetHeight)) 
-         );                          
+         // frame1Ref.current, frame2Ref.currentが参照可能になったら比較を行う
+         if (frame1Ref.current && frame2Ref.current) {
+             setFrameMaxHeight(
+                 Math.max(frame1Ref.current.offsetHeight, frame2Ref.current.offsetHeight) 
+             );  
+         }                          
     } else {
         // 大きいエレメントに合わせたまま元に戻らなくなってしまうので、データ数が同じである場合は1度リセットする。
         setFrameMaxHeight(undefined);
     }
- }, [data]);
+ }, [
+     data, 
+     // refから得られる要素高を依存配列に入れておく
+     frame1Ref?.current?.offsetHeight, 
+     frame2Ref?.current?.offsetHeight
+ ]);
 
 return (
     <frame
         data = {data[frame1]}
         // 要素高の最大値を渡す
         frameMaxHeight = {frameMaxHeight}
+        frameRef = {frame1Ref}
     />
 
     <frame
         data = {data[frame2]}
         frameMaxHeight = {frameMaxHeight}
+        frameRef = {frame2Ref}
     />
 );
 // frameコンポーネント内
 // ~~~~~~~~~~省略~~~~~~~~~~~~~~~~~~~
 return (
     <div
         className="frame-class"
+        ref={frameRef}
         style={
             // frameMaxHeightがundefind(リセット状態)の時は高さ指定なしとする
             frameMaxHeight
                 ? { minHeight: `${frameMaxHeight}px` }
                 : { minHeight: "initial" }
        }
     >
         // dataを表示(省略)
     </div>
 );

getElementsByClassNameメソッドの代わりにuseRefフックを利用して要素を参照します。前者の場合、DOM構築済みの要素から参照を取ってきますが、後者は生成したrefを渡してレンダリングが行われるため、要素がレンダリングされた後に参照することが可能になります。 また、初期描画においては要素高が数段に分けて変化していく様だったので、useEffectの依存変数にrefから参照した要素高も含めています。

今回初めてuseRefフックを利用したのですが、レンダリング状態に左右されず要素に参照を通すことができReact内で要素を操作する際に非常に便利だと感じました。

この記事を書いた人

高嶋ありさ

2021年度新卒入社エンジニア
プログラミング経験ほぼ無しからWebエンジニアになりました。
最近現れたミライナタワーのクリスマスツリーが可愛らしくて好きです。

フォルシアではフォルシアに興味をお持ちいただけた方に、社員との面談のご案内をしています。
採用応募の方、まずはカジュアルにお話をしてみたいという方は、お気軽に下記よりご連絡ください。


採用お問い合わせフォーム 募集要項

※ 弊社社員に対する営業行為などはお断りしております。ご希望に沿えない場合がございますので予めご了承ください。