DEV Community

Oleksandr Demian
Oleksandr Demian

Posted on

Sync height between elements in React

A simple problem: make sure that the different elements in the app are the same height, as if they were in a table.

Let's start with a sample react app that renders 3 cards with different items (styles are omitted, but at the end they are all flex boxes):

const ItemCard = ({
  title,
  items,
  footerItems,
}: {
  title: string;
  items: string[];
  footerItems: string[];
}) => {
  return (
    <div className="card">
      <h2>{title}</h2>
      <div className="separator" />
      <div className="items">
        {items.map((item) => (
          <p>{item}</p>
        ))}
      </div>
      <div className="separator" />
      <div className="footer">
        {footerItems.map((footerItem) => (
          <p>{footerItem}</p>
        ))}
      </div>
    </div>
  );
};

export const App = () => {
  return (
    <div>
      <ItemCard title="Card one" items={['One', 'Two']} footerItems={['One']} />
      <ItemCard
        title="Card two"
        items={['One', 'Two', 'Three', 'Four']}
        footerItems={['One', 'Two', 'Three']}
      />
      <ItemCard title="Card three" items={['One']} footerItems={['One']} />
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

When you run this app, you will get this result:
Default result

The desired result would be something like this:
React elements have same height

In order to synchronize the height I came with the following idea: a custom hook that stores the references to all different elements that have to be matched in a {[key: string]: value: array of elements} object, and when there is a change in dependencies, the height of elements gets recalcualted in useLayoutEffect:

import { MutableRefObject, useLayoutEffect } from 'react';

type Target = MutableRefObject<HTMLElement | null>;

// Store all elements per key, so it is easy to retrieve them
const store: Record<string, Target[]> = {};

// Triggered when useLayoutEffect is executed on any of the components that use useSyncRefHeight hook
const handleResize = (key: string) => {
  // get all elements with the same key
  const elements = store[key];
  if (elements) {
    let max = 0;
    // find the element with highest clientHeight value
    elements.forEach((element) => {
      if (element.current && element.current.clientHeight > max) {
        max = element.current.clientHeight;
      }
    });
    // update height of all 'joined' elements
    elements.forEach((element) => {
      if (element.current) {
        element.current.style.minHeight = `${max}px`;
      }
    });
  }
};

// Add element to the store when component is mounted and return cleanup function
const add = (key: string, element: Target) => {
  // create store if missing
  if (!store[key]) {
    store[key] = [];
  }

  store[key].push(element);

  // cleanup function
  return () => {
    const index = store[key].indexOf(element);
    if (index > -1) {
      store[key].splice(index, 1);
    }
  };
};

// Receives multiple elements ([key, element] pairs). This way one hook can be used to handle multiple elements
export type UseSyncRefHeightProps = Array<[string, Target]>;
export const useSyncRefHeight = (refs: UseSyncRefHeightProps, deps?: any[]) => {
  useLayoutEffect(() => {
    // store cleanup functions for each entry
    const cleanups: (() => void)[] = [];
    refs.forEach(([key, element]) => {
      // add element ref to store
      cleanups.push(add(key, element));
    });
    return () => {
      // cleanup when component is destroyed
      cleanups.forEach((cleanup) => cleanup());
    };
  }, []);

  useLayoutEffect(() => {
    // when any of the dependencies changes, update all elements heights
    refs.forEach(([key]) => {
      handleResize(key);
    });
  }, deps);
};
Enter fullscreen mode Exit fullscreen mode

By using this hook we can change a bit ItemCard element:

const ItemCard = ({
  title,
  items,
  footerItems,
}: {
  title: string;
  items: string[];
  footerItems: string[];
}) => {
  // create ref to the parent container, to only target its children instead of running query on the entire document
  const itemsRef = useRef(null);
  const footerRef = useRef(null);

  // align elements with class items
  // deps is an empty array, so it will only be aligned when the component is mounted.
  // You can add your dependencies, or remove it to make sure the hook runs at every render
  useSyncRefHeight(
    [
      ['items', itemsRef],
      ['footer', footerRef],
    ],
    // trigger hook when items of footerItems changes, since it may change height
    [items, footerItems],
  );
  return (
    <div className="card">
      <h2>{title}</h2>
      <div className="separator" />
      <div className="items" ref={itemsRef}>
        {items.map((item) => (
          <p>{item}</p>
        ))}
      </div>
      <div className="separator" />
      <div className="footer" ref={footerRef}>
        {footerItems.map((footerItem) => (
          <p>{footerItem}</p>
        ))}
      </div>
    </div>
  );
};
Enter fullscreen mode Exit fullscreen mode

Now, items and footer elements height will be matched across all cards.

Oldest comments (3)

Collapse
 
daveybrown profile image
daveybrown

This is cool. Thank you Oleksandr

...
let max = 0;
// reset height of all elements
elements.forEach((element) => {
  if (element.current) {
    element.current.style.minHeight = '0px';
  }
});
...
Enter fullscreen mode Exit fullscreen mode

If you add a reset like this, and trigger the hook on window width, then it works responsively.

Collapse
 
kryamk profile image
Kryamk

How trigger the hook on window width?

Collapse
 
daveybrown profile image
daveybrown

Use something like: usehooks.com/usewindowsize
And then pass width into useSyncRefHeight as the 2nd param, like [items, footerItems, size.width],