Possible to have a dynamically height adjusted textarea without constant reflows?

I think thirtydot's recommendation may be the best. The Material UI textarea he linked has a pretty clever solution.

They create a hidden absolutely positioned textarea that mimics the style and width of the actual textarea. Then they insert the text you type into that textarea and retrieve the height of it. Because it is absolutely positioned there is no reflow calculation. They then use that height for the height of the actual textarea.

I don't fully understand all of what their code is doing, but I've hacked together a minimal repurposement for my needs, and it seems to work well enough. Here are some snippets:

.shadow-textarea {
  visibility: hidden;
  position: absolute;
  overflow: hidden;
  height: 0;
  top: 0;
  left: 0
}
<textarea ref={this.chatTextBoxRef} style={{ height: this.state.heightInPx + "px" }}
          onChange={this.handleMessageChange} value={this.props.value}>
</textarea>

<textarea ref={this.shadowTextBoxRef} className="shadow-textarea" />
componentDidUpdate() {
  this.autoSize();
}

componentDidMount() {
  this.autoSize();
}
autoSize = () => {
  let computedStyle = window.getComputedStyle(this.chatTextBoxRef.current); // this is fine apparently..?

  this.shadowTextBoxRef.current.style.width = computedStyle.width; // apparently width retrievals are fine
  this.shadowTextBoxRef.current.value = this.chatTextBoxRef.current.value || 'x';

  let innerHeight = this.shadowTextBoxRef.current.scrollHeight; // avoiding reflow because we are retrieving the height from the absolutely positioned shadow clone

  if (this.state.heightInPx !== innerHeight) { // avoids infinite recursive loop
    this.setState({ heightInPx: innerHeight });
  }
}

A bit hacky but it seems to work well enough. If anyone can decently improve this or clean it up with a more elegant approach I'll accept their answer instead. But this seems to be the best approach considering Material UI uses it and it is the only one I've tried so far that eliminates the expensive reflow calculations that cause lag in a sufficiently complex application.

Chrome is only reporting reflow occurring once when the height changes, as opposed to on every keypress. So there is still a single 30ms lag when the textarea grows or shrinks, but this is much better than on every key stroke or text change. The lag is 99% gone with this approach.


NOTE: Ryan Peschel's answer is better.

Original Post: I have heavily modified apachuilo's code to achieve the desired result. It adjusts the height based on the scrollHeight of the textarea. When the text in the box is changed, it sets the box's number of rows to the value of minRows and measures the scrollHeight. Then, it calculates the number of rows of text and changes the textarea's rows attribute to match the number of rows. The box does not "flash" while calculating.

render() is only called once, and only the rows attribute is changed.

It took about 500ms to add a character when I put in 1000000 (a million) lines of at least 1 character each. Tested it in Chrome 77.

CodeSandbox: https://codesandbox.io/s/great-cherry-x1zrz

import React, { Component } from "react";

class TextBox extends Component {
  textLineHeight = 19;
  minRows = 3;

  style = {
    minHeight: this.textLineHeight * this.minRows + "px",
    resize: "none",
    lineHeight: this.textLineHeight + "px",
    overflow: "hidden"
  };

  update = e => {
    e.target.rows = 0;
    e.target.rows = ~~(e.target.scrollHeight / this.textLineHeight);
  };

  render() {
    return (
      <textarea rows={this.minRows} onChange={this.update} style={this.style} />
    );
  }
}

export default TextBox;

While it's not possible to eliminate all reflows — the browser has to calculate the height at some point — it is possible to reduce them significantly.

Per Paul Irish (a Chrome developer), elem.scrollHeight is among the property accesses & methods that cause a reflow. However, there is a significant note:

Reflow only has a cost if the document has changed and invalidated the style or layout. Typically, this is because the DOM was changed (classes modified, nodes added/removed, even adding a psuedo-class like :focus).

This is where, for plain text, a textarea is actually superior to a <div contenteditable>. For a div, typing changes the innerHTML, which is actually a Text node. As such, modifying the text in any way also modifies the DOM, causing a reflow. In the case of a textarea, typing only changes its value property — nothing touches the DOM, all that's required is repainting, which is (comparatively) very cheap. This allows the rendering engine to cache the value as indicated by the above quote.

Because of the browser's cacheing of scrollHeight, you can use the "classic" advice — fetch that value and immediately set it to the actual height.

function resizeTextarea(textarea) {
    textarea.style.height = 'auto';
    textarea.style.height = `${textarea.style.scrollHeight}px`;
}

Use that method any time the value changes, which will ensure the textarea remains at a height that does not scroll. Don't worry about the consecutive setting of the property, as the browser executes these together (similar to requestAnimationFrame).

This is true in all WebKit-based browsers, which are currently Chrome and Opera, and soon to be Edge as well. I presume Firefox and Safari have similar implementations.