Steve Hollasch Steve Hollasch - 7 months ago 10
Javascript Question

How can I get the width of an auto-sized DOM element?

I have a complex web page using React components, and am trying to convert the page from a static layout to a more responsive, resizable layout. However, I keep running into limitations with React, and am wondering if there's a standard pattern for handling these issues. In my specific case, I have a component that renders as a div with display:table-cell and width:auto.

Unfortunately, I cannot query the width of my component, because you can't compute the size of an element unless it's actually placed in the DOM (which has the full context with which to deduce the actual rendered width). Besides using this for things like relative mouse positioning, I also need this to properly set width attributes on SVG elements within the component.

In addition, when the window resizes, how do I communicate size changes from one component to another during setup? We're doing all of our 3rd-party SVG rendering in shouldComponentUpdate, but you cannot set state or properties on yourself or other child components within that method.

Is there a standard way of dealing with this problem using React?

Answer

When I need to respond to the sizes of components with React, I typically call the parent's setState with the sizes of the components whenever:

  • its componentDidMount gets called
  • The window resizes (with some throttling)

I then use those sizes from this.state in the parent's render method. This means there is an awkward initial phase where the sizes are undefined. In that case I usually just use some small default sizes. It would even be possible to set the components' visibility: 'hidden' until the parent re-renders with their sizes available.

If I have animated transitions that affect the layout, I typically fire fake window resize events during or at the end of the transition so that my parent component can re-perform the layout. It may also be necessary to only turn on CSS transitions after the initial layout has been performed, so that nothing jumps around on mount.

The most complex cases require the following rendering steps:

  1. mount components with visibility: hidden and no transitions, because sizes are unknown

  2. get measured sizes from DOM, re-render with computed layout (if any children compute their children's positions, they must be laid out again as well)

  3. re-render with visibility: visible and any desired transitions

For example, here is a full sidebar layout component I wrote. One can do sidebars with pure CSS, but only if the sidebar is fixed width or doesn't push the content over. In this component, the sidebar width could be adjusted by the user AND push the content over when the screen is wide enough. I have canvases in the content I'm using with this component, so they respond to fake window resize events fired by this component when it finishes transitioning. This also illustrates how to avoid erroneous transitions when the component is first mounted (though the mounted and laidOut state).

SidebarView.jsx:

import React, {PropTypes, Component} from 'react';
import classNames from 'classnames';
import _ from 'lodash';

import {leftSide, rightSide} from '../utils/orient';
import callOnTransitionEnd from '../transition/callOnTransitionEnd';
import fireFakeResizeEvent from '../utils/fireFakeResizeEvent';

import './SidebarView.sass';

export default class SidebarView extends Component {
  static propTypes = {
    sidebar:              PropTypes.node,
    sidebarOpen:          PropTypes.bool,
    content:              PropTypes.node,
    onOpenSidebarClick:   PropTypes.func,
    onCloseSidebarClick:  PropTypes.func,
    sidebarSide:          PropTypes.oneOf([leftSide, rightSide]),
    sidebarWidth:         PropTypes.number,
  }
  static defaultProps = {
    sidebarOpen: true,
    sidebarSide: leftSide,
    sidebarWidth: 200,
    onOpenSidebarClick: function() {},
    onCloseSidebarClick: function() {},
  }
  constructor(props) {
    super(props);
    this.state = {rootWidth: 0};
  }
  onSidebarToggleBtnClick = () => {
    let {sidebarOpen, onOpenSidebarClick, onCloseSidebarClick} = this.props;
    sidebarOpen ? onCloseSidebarClick() : onOpenSidebarClick();
    callOnTransitionEnd(this.refs.sidebar, fireFakeResizeEvent, 1000);
  }
  resize = _.throttle(() => {
    if (this.refs.root && this.refs.sidebarToggleBtn) {
      this.setState({
        rootWidth:              this.refs.root.offsetWidth,
        sidebarToggleBtnWidth:  this.refs.sidebarToggleBtn.offsetWidth,
      });
    }
  }, 30)
  componentWillMount() {
    this.setState({
      mounted: false,
      laidOut: false,
    });
  }
  componentDidMount() {
    this.resize();
    window.addEventListener('resize', this.resize);
  }
  componentWillUnmount() {
    window.removeEventListener('resize', this.resize);
  }
  componentDidUpdate() {
    let {mounted, laidOut} = this.state;
    if (!laidOut) {
      if (!mounted) {
        setTimeout(() => this.setState({mounted: true}), 0);
      }
      else {
        setTimeout(() => this.setState({laidOut: true}), 0);
      }
    }
  }
  render() {
    let {className, sidebar, sidebarOpen, sidebarSide, sidebarWidth, 
        content, ...props} = this.props;
    let {mounted, laidOut, rootWidth = 0, sidebarToggleBtnWidth = 0} = this.state;

    sidebarWidth = Math.min(sidebarWidth, rootWidth - sidebarToggleBtnWidth);

    let contentPosition = rootWidth > sidebarWidth * 2 && sidebarOpen ? sidebarWidth : 0;

    className = classNames(className, 'mf-sidebar-view', `mf-sidebar-${sidebarSide.name}`, {'laid-out': laidOut});

    const sidebarStyle = {
      width: sidebarWidth,
      [sidebarSide.name]: sidebarOpen ? 0 : -sidebarWidth,
    };

    const contentStyle = {
      ['margin' + sidebarSide.capName]: contentPosition,
    };

    var glyphTransform = sidebarOpen ? 'rotateY(0)' : 'rotateY(180deg)';

    const glyphStyle = {
      WebkitTransform: glyphTransform,
      MozTransform: glyphTransform,
      msTransform: glyphTransform,
      OTransform: glyphTransform,
      transform: glyphTransform,
    };

    return <div ref="root" className={className} {...props}>
      {mounted && <div className="content" style={contentStyle}>{content}</div>}
      <div ref="sidebar" className="mf-sidebar" style={sidebarStyle}>
        <div className="mf-sidebar-content">
          {sidebar}
        </div>
        <button ref="sidebarToggleBtn" className="btn btn-default mf-sidebar-toggle-btn" 
          type="button" onClick={this.onSidebarToggleBtnClick}>
          <i className="glyphicon glyphicon-chevron-left" style={glyphStyle}/>
        </button>
      </div>
    </div>;
  }
}

SidebarView.sass:

@import "../sass/css3-mixins"

$transition-speed: 0.2s
$sidebar-toggle-btn-height: 50px

.mf-sidebar-view
  position: relative

  > .content
    overflow: auto

  > .mf-sidebar
    position: absolute
    top: 0
    bottom: 0
    background-color: white
    z-index: 800
    @include box-shadow(0 0 10px rgba(0, 0, 0, 0.5))

    > .mf-sidebar-content
      position: absolute
      top: 0
      left: 0
      right: 0
      bottom: 0
      overflow-y: auto

    > .mf-sidebar-toggle-btn
      position: absolute
      height: $sidebar-toggle-btn-height
      width: 15px
      top: 50%
      padding: 0
      z-index: 801
      @include transform(translateY(-50%))

      .glyphicon
        @include transition(transform ease-out $transition-speed)

  &.absolute
    > .content
      position: absolute
      top: 0
      left: 0
      right: 0
      bottom: 0

  &:not(.laid-out)
    > *
      visibility: hidden

  &.laid-out
    &.mf-sidebar-left
      > .content
        @include transition(margin-left ease-out $transition-speed)
      > .mf-sidebar
        @include transition(left ease-out $transition-speed)

    &.mf-sidebar-right
      > .content
        @include transition(margin-right ease-out $transition-speed)
      > .mf-sidebar-toggle-btn
        @include transition(right ease-out $transition-speed)

  &.mf-sidebar-left
    > .content
      right: 0

    > .mf-sidebar > .mf-sidebar-toggle-btn
      left: 100%
      border-left: none
      border-top-left-radius: 0
      border-bottom-left-radius: 0

  &.mf-sidebar-right
    > .content
      left: 0

    > .mf-sidebar > .mf-sidebar-toggle-btn
      right: 100%
      border-right: none
      border-top-right-radius: 0
      border-bottom-right-radius: 0

In my opinion, there should be a standard way to perform custom layouts with JS built into DOM itself. CSS can never cover all possible cases. A standard React system to abstract away all the tricky details of precise custom layouts would be nice, but I think it would still have limitations compared to something built into the DOM (for instance, components would have to get mounted and laid out before the JS layout code could tweak their sizes). Also, at least the last time I checked, detecting that an individual element resized (even if the window size remains the same) is possible but hacky.

I've also written React components to size multiline text to fit within a div's height/width, but even that remains pretty awkward and inelegant.

EDIT: Here is the new way I do this:

I created a wrapper component that handles getting values from the DOM. You tell it which props to get from the DOM and provide a render function taking those props as a child.

/* @flow */

import React, {Component} from 'react';
import shallowEqual from 'fbjs/lib/shallowEqual';
import _ from 'lodash';

type Props = {
  domProps?: string[],
  computedStyleProps?: string[],
  children: (state: {computedStyle?: Object, [domProp: string]: any}) => ?React.Element,
  component: string
};

type DefaultProps = {
  component: string
};

type State = Object;

export default class Responsive extends Component<DefaultProps,Props,State> {
  static defaultProps = {
    component: 'div'
  };
  state: State = {
    remeasure: this.remeasure
  }; 
  mounted: boolean = false;
  root: ?Object;
  componentWillMount() {
    this.mounted = true;
  }
  componentDidMount() {
    this.remeasure();
    window.addEventListener('resize', this.remeasure);
  }
  componentWillReceiveProps(nextProps: Props) {
    if (!shallowEqual(this.props.domProps, nextProps.domProps) || 
        !shallowEqual(this.props.computedStyleProps, nextProps.computedStyleProps)) {
      this.remeasure();
    }
  }
  componentWillUnmount() {
    this.mounted = false;
    window.removeEventListener('resize', this.remeasure);
  }
  remeasure: Function = _.throttle(() => {
    const {root} = this;
    if (this.mounted && root) {
      let {domProps, computedStyleProps} = this.props;
      let nextState = {};
      if (domProps) {
        domProps.forEach(prop => nextState[prop] = root[prop]);
      }
      if (computedStyleProps) {
        nextState.computedStyle = {};
        let computedStyle = getComputedStyle(root);
        computedStyleProps.forEach(prop => nextState.computedStyle[prop] = computedStyle[prop]);
      }
      this.setState(nextState);
    } 
  }, 500);
  render(): ?React.Element {
    let {props: {children}, state} = this;
    let Comp: any = this.props.component;
    return <Comp ref={c => this.root= c} children={children(state)}/>;
  }
}

With this, responding to width changes is very simple:

function renderColumns(numColumns: number): React.Element {
  ...
}
const responsiveView = <Responsive domProps={['offsetWidth']}>
  {({offsetWidth}) => {
    let numColumns = offsetWidth ? Math.max(1, Math.floor(offsetWidth / 200));
    return offsetWidth ? renderColumns(numColumns) : null;
  }}
</Responsive>;