30% offEnding soon

React Textarea: Build, Validate, Autosize, and Test

19 min read

A React textarea starts with the native multiline text control, then adds state only when validation, counters, or other interface behavior needs it.

A reusable React textarea should preserve native form behavior while adding an associated label, synchronous controlled state, accessible validation, a UTF-16-based character counter, autosizing, and behavior-focused tests.

React Textarea in 60 Seconds

A textarea is the browser's native multiline text control. In JSX, it accepts familiar attributes such as name, placeholder, rows, cols, required, disabled, and readOnly.

This is the smallest useful React example:

import { useState } from "react";

export function ShortAnswer() {
  const [answer, setAnswer] = useState("");

  return (
    <div>
      <label htmlFor="short-answer">Explain event bubbling</label>
      <textarea
        id="short-answer"
        name="answer"
        value={answer}
        onChange={(event) => setAnswer(event.target.value)}
        rows={5}
        cols={40}
        placeholder="Write an interview-sized answer"
        style={{ resize: "vertical" }}
      />
    </div>
  );
}

The label is visible and programmatically associated with the control through htmlFor and id. A placeholder remains a hint. It does not replace the label.

Passing a string through value makes the textarea controlled. React state supplies the displayed text, and onChange synchronously copies event.target.value into that state. Delaying this update can make typing behave incorrectly because the rendered value continues to come from the old state.

Controlled typing is a loopBrowsertextareaReactstateonChange sends textvalue renders text
Every controlled edit completes the textarea–state loop.

rows and cols provide an initial size. CSS can permit vertical resizing, prohibit resizing, or apply other layout rules. The final autosizing component later in this article disables manual resizing because it manages the height itself.

The name attribute matters during form submission. Without it, the textarea value does not appear under a corresponding key in FormData.

React does not use text between the opening and closing textarea tags as its initial value. An uncontrolled textarea uses defaultValue instead:

<textarea
  id="notes"
  name="notes"
  defaultValue="Review closures and event delegation."
/>

For focused practice on the underlying resize logic, see useTextareaAutosize.

Controlled vs. Uncontrolled Textareas

A controlled textarea gets its current value from React state. An uncontrolled textarea keeps its current value in the DOM after React supplies any initial value.

ControlledUncontrolledCurrent textDOM controlvalueonChangedefaultValue onceDOM controlCurrent text
Controlled and uncontrolled textareas differ in where current text lives.
ApproachCurrent value lives inRead the value withBest fit
value plus onChangeReact stateThe state variableCounters, live validation, conditional interface behavior
defaultValueThe DOMFormData during submissionSimple forms that do not need each edit in React state
defaultValue plus a refThe DOMref.current?.valueImperative actions such as focusing or selecting text

A controlled textarea needs both sides of the state loop:

const [comment, setComment] = useState("");

<textarea
  value={comment}
  onChange={(event) => setComment(event.target.value)}
/>

The state update that controls value must happen synchronously in onChange. Expensive work triggered by the new value can run later. For example, a search request or draft-saving operation can be debounced without debouncing the state update that keeps typing responsive.

The controlled value must remain a string for the textarea's lifetime. Data that may initially be missing can be normalized before it reaches the element:

<textarea
  value={profile.bio ?? ""}
  onChange={handleBioChange}
/>

A textarea cannot begin uncontrolled and later become controlled, or begin controlled and later lose its string value. Initializing state with "" avoids that switch.

An uncontrolled form needs less state:

export function NotesForm() {
  function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault();

    const data = new FormData(event.currentTarget);
    const notes = data.get("notes");

    // Send or validate notes here.
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="notes">Interview notes</label>
      <textarea
        id="notes"
        name="notes"
        defaultValue="Review array method edge cases."
        rows={6}
      />
      <button type="submit">Save notes</button>
    </form>
  );
}

A ref is not a different state model. It is an imperative way to reach the same DOM control. Use one when an action needs the element itself, such as calling focus(), rather than as a substitute for ordinary controlled rendering.

A value without an onChange handler creates an effectively read-only field unless readOnly is intentional. Caret jumps can also appear when onChange transforms the value unexpectedly or updates it asynchronously. Keeping the immediate update as setValue(event.target.value) makes the data flow easy to inspect.

For forms with several related controls, the patterns in useForm provide a useful next exercise.

The Interview Prompt and Acceptance Criteria

Build a reusable TypeScript comment textarea for a frontend interview form. The native textarea remains the foundation.

The component must satisfy these acceptance criteria:

  • Render a visible label connected to the textarea.
  • Receive a controlled string value and report edits synchronously.
  • Accept a configurable character limit.
  • Show the remaining character count.
  • Connect help text and validation errors through aria-describedby.
  • Mark invalid input with aria-invalid.
  • Grow when content wraps or gains line breaks, then shrink after deletion.
  • Stop growing at a configurable maximum height and allow internal scrolling beyond it.
  • Forward standard native textarea attributes such as name, required, minLength, disabled, readOnly, and placeholder.
  • Submit the value through normal FormData behavior.
  • Preserve a string value throughout the component's lifetime.

The counter uses JavaScript string length. That length counts UTF-16 code units, which matches how the native maxLength attribute measures a textarea value. It does not always match the number of symbols a person sees. Some emoji occupy more than one UTF-16 code unit.

For a limit of 3, this string reaches the limit:

const answer = "😀a";

answer.length;

The expression evaluates to 3. A counter based on a different definition of a character could disagree with the browser's native maximum-length behavior.

What people see😀aWhat maxlength countsunit 1unit 2unit 3length = 3
Visible symbols and UTF-16 code units are not always one-to-one.

This is a deliberate contract, not a universal recommendation for every product. An interface that promises a limit in user-perceived characters needs a different counting rule and must not assume it matches maxLength. The related Character Counter Textarea exercise explores the counter as its own component problem.

Build the Reusable React Textarea

The following file is the canonical implementation. Later code uses it without replacing or redeclaring the component.

// CommentTextarea.tsx
import {
  useId,
  useLayoutEffect,
  useRef,
  type TextareaHTMLAttributes,
} from "react";

type NativeTextareaProps = Omit<
  TextareaHTMLAttributes<HTMLTextAreaElement>,
  "value" | "defaultValue" | "onChange" | "maxLength"
>;

export interface CommentTextareaProps extends NativeTextareaProps {
  label: string;
  value: string;
  onValueChange: (value: string) => void;
  limit: number;
  helpText?: string;
  error?: string;
  minRows?: number;
  maxHeight?: number;
}

export function CommentTextarea({
  label,
  value,
  onValueChange,
  limit,
  helpText,
  error,
  minRows = 3,
  maxHeight = 240,
  id: providedId,
  disabled,
  readOnly,
  style,
  "aria-describedby": providedDescription,
  "aria-invalid": providedInvalid,
  ...textareaProps
}: CommentTextareaProps) {
  const generatedId = useId();
  const textareaId = providedId ?? `comment-${generatedId}`;
  const helpId = helpText ? `${textareaId}-help` : undefined;
  const errorId = error ? `${textareaId}-error` : undefined;
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  const describedBy = [
    providedDescription,
    helpId,
    errorId,
  ]
    .filter(Boolean)
    .join(" ") || undefined;

  useLayoutEffect(() => {
    const textarea = textareaRef.current;

    if (!textarea) {
      return;
    }

    textarea.style.height = "auto";

    const measuredHeight = textarea.scrollHeight;
    const nextHeight = Math.min(measuredHeight, maxHeight);

    textarea.style.height = `${nextHeight}px`;
    textarea.style.overflowY =
      measuredHeight > maxHeight ? "auto" : "hidden";
  }, [value, maxHeight]);

  const remaining = limit - value.length;

  return (
    <div>
      <label htmlFor={textareaId}>{label}</label>

      {helpText ? <p id={helpId}>{helpText}</p> : null}

      <textarea
        {...textareaProps}
        ref={textareaRef}
        id={textareaId}
        value={value}
        onChange={(event) => onValueChange(event.target.value)}
        maxLength={limit}
        rows={minRows}
        disabled={disabled}
        readOnly={readOnly}
        aria-describedby={describedBy}
        aria-invalid={error ? true : providedInvalid}
        style={{
          ...style,
          boxSizing: "border-box",
          resize: "none",
        }}
      />

      <p aria-live="polite">
        {remaining} {remaining === 1 ? "character" : "characters"} remaining
      </p>

      {error ? (
        <p id={errorId} role="alert">
          {error}
        </p>
      ) : null}
    </div>
  );
}

useId supplies a stable identifier when the caller does not pass an id. The same identifier connects the label, help text, error message, and textarea.

One field, four connected partsLabelHelp textTextareaErrorhtmlFor + idaria-describedby names both message IDs
Shared IDs turn separate visible text into one accessible field.

The component forwards the remaining native props before setting the props it owns. A caller can therefore provide name, placeholder, required, minLength, autoComplete, disabled, or readOnly, but cannot accidentally replace the controlled value, change handler, or configured limit.

disabled prevents ordinary interaction and excludes the control from successful form submission. readOnly preserves the displayed value and permits the control to remain part of form data, but prevents editing. Those states describe different form requirements, so the component exposes both.

Same editing result, different form resultDisabledCannot editRead-onlyCannot editExcludedIncludedFormData
Disabled and read-only fields differ at the submission boundary.

The form below adds required and minimum-length validation. It is part of the same canonical implementation and uses the exported component as written above.

// CommentForm.tsx
import { useState, type FormEvent } from "react";
import { CommentTextarea } from "./CommentTextarea";

const COMMENT_LIMIT = 280;
const COMMENT_MIN_LENGTH = 10;

interface CommentFormProps {
  onSubmit: (comment: string) => void;
  disabled?: boolean;
}

function validateComment(value: string): string | undefined {
  if (value.trim().length === 0) {
    return "Enter a comment before submitting.";
  }

  if (value.length < COMMENT_MIN_LENGTH) {
    return `Comment must contain at least ${COMMENT_MIN_LENGTH} UTF-16 code units.`;
  }

  return undefined;
}

export function CommentForm({
  onSubmit,
  disabled = false,
}: CommentFormProps) {
  const [comment, setComment] = useState("");
  const [error, setError] = useState<string>();

  function handleChange(nextValue: string) {
    setComment(nextValue);

    if (error) {
      setError(validateComment(nextValue));
    }
  }

  function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();

    const nextError = validateComment(comment);
    setError(nextError);

    if (nextError) {
      return;
    }

    const data = new FormData(event.currentTarget);
    const submittedComment = data.get("comment");

    onSubmit(typeof submittedComment === "string" ? submittedComment : "");
    setComment("");
    setError(undefined);
  }

  return (
    <form onSubmit={handleSubmit} noValidate>
      <CommentTextarea
        label="Code review comment"
        name="comment"
        value={comment}
        onValueChange={handleChange}
        limit={COMMENT_LIMIT}
        minLength={COMMENT_MIN_LENGTH}
        required
        disabled={disabled}
        placeholder="Explain the issue and suggest a fix"
        helpText="Use at least 10 UTF-16 code units."
        error={error}
        minRows={4}
        maxHeight={240}
      />

      <button type="submit" disabled={disabled}>
        Submit comment
      </button>
    </form>
  );
}

The native required, minLength, and maxLength attributes remain on the textarea. The form also supplies explicit messages so the same error is visible and connected to the control. aria-invalid appears only when an error exists, and the error identifier joins the help identifier in aria-describedby.

The state update stays small. Most forms do not need optimization beyond keeping expensive derived work out of the immediate change handler. If validation later includes a network request, the request can be delayed while setComment(nextValue) remains synchronous.

For a larger collection of interview-ready component exercises, UIReady Premium Lifetime provides additional practice material.

Make the Textarea Grow and Shrink

Autosizing depends on one measurement cycle:

  1. Set the height to auto so the previous explicit height no longer holds the element open.
  2. Read scrollHeight, which measures the content height including padding but excluding borders and margins.
  3. Assign the smaller of scrollHeight and the configured maximum height.
  4. Enable vertical overflow when the content is taller than that maximum.

The reset is what permits shrinking. If the code only assigns a larger scrollHeight, deleting lines can leave the old explicit height in place.

One maximum, two outcomescontent ≤ maximumcontent > maximumshort linesecond lineline oneline twoline threemore below…maximum heightdelete → reset to auto → remeasure
Content below the cap sets the box height; content above it creates internal scrolling.

useLayoutEffect runs the measurement after React has placed the new value in the DOM and before the browser presents the updated frame. The effect depends on value, so typing, pasting, clearing, and programmatic value changes all repeat the measurement.

The component sets boxSizing: "border-box" because its assigned height then describes the element's border box consistently. scrollHeight includes padding, so border and padding rules still deserve attention when integrating the component into an existing design system.

Once the measured content exceeds maxHeight, the textarea stops growing and overflowY becomes auto. Content remains available through the textarea's own scrolling. The component does not claim a fixed number of visible rows at that boundary because row height depends on the applied typography and spacing.

DOM test environments may not calculate browser layout. Tests should control the scrollHeight measurement and verify the component's decisions instead of asserting real pixel layout that the runner never produced.

Browser layout versus test layoutReal browsermeasures layoutTest runnerno renderedpixel layoutMock scrollHeightVerify:heightoverflow
Autosize tests replace absent layout with a controlled scrollHeight value.

A project that does not want to maintain this measurement code can use react-textarea-autosize, a React textarea replacement with minRows and maxRows props. The native implementation above remains useful in an interview because it exposes the measurement and shrinking logic directly. Another useful comparison is useResizeObserver, which responds to observed element-size changes rather than replacing this content-height measurement.

Test the Behaviors Interviewers Care About

These tests use React Testing Library and user-event. They cover the behavior visible to a user or form consumer: accessible discovery, multiline typing, controlled updates, UTF-16 limits, errors, clearing, submission, disabled state, read-only state, and autosize boundaries.

// CommentForm.test.tsx
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CommentForm } from "./CommentForm";
import { CommentTextarea } from "./CommentTextarea";

afterEach(() => {
  vi.restoreAllMocks();
});

describe("CommentTextarea", () => {
  it("is discovered by its accessible label and accepts line breaks", async () => {
    const user = userEvent.setup();

    function ControlledExample() {
      const [value, setValue] = useState("");

      return (
        <CommentTextarea
          label="Interview answer"
          name="answer"
          value={value}
          onValueChange={setValue}
          limit={40}
        />
      );
    }

    render(<ControlledExample />);

    const textarea = screen.getByRole("textbox", {
      name: "Interview answer",
    });

    await user.type(textarea, "First line{Enter}Second line");

    expect(textarea).toHaveValue("First line\nSecond line");
    expect(
      screen.getByText("18 characters remaining"),
    ).toBeInTheDocument();
  });

  it("uses the same UTF-16 code-unit limit as maxLength", async () => {
    const user = userEvent.setup();

    function LimitedExample() {
      const [value, setValue] = useState("");

      return (
        <CommentTextarea
          label="Limited answer"
          value={value}
          onValueChange={setValue}
          limit={3}
        />
      );
    }

    render(<LimitedExample />);

    const textarea = screen.getByRole("textbox", {
      name: "Limited answer",
    });

    await user.type(textarea, "😀ab");

    expect(textarea).toHaveValue("😀a");
    expect(textarea).toHaveAttribute("maxlength", "3");
    expect(
      screen.getByText("0 characters remaining"),
    ).toBeInTheDocument();
  });

  it("grows, shrinks, and enables overflow at the maximum height", () => {
    let measuredHeight = 160;

    vi.spyOn(
      HTMLTextAreaElement.prototype,
      "scrollHeight",
      "get",
    ).mockImplementation(() => measuredHeight);

    const { rerender } = render(
      <CommentTextarea
        label="Resizable answer"
        value={"Line one\nLine two"}
        onValueChange={() => {}}
        limit={100}
        maxHeight={120}
      />,
    );

    const textarea = screen.getByRole("textbox", {
      name: "Resizable answer",
    });

    expect(textarea).toHaveStyle({
      height: "120px",
      overflowY: "auto",
    });

    measuredHeight = 48;

    rerender(
      <CommentTextarea
        label="Resizable answer"
        value="Short"
        onValueChange={() => {}}
        limit={100}
        maxHeight={120}
      />,
    );

    expect(textarea).toHaveStyle({
      height: "48px",
      overflowY: "hidden",
    });
  });

  it("prevents editing when disabled or read-only", async () => {
    const user = userEvent.setup();
    const onDisabledChange = vi.fn();
    const onReadOnlyChange = vi.fn();

    render(
      <>
        <CommentTextarea
          label="Disabled comment"
          value="Fixed"
          onValueChange={onDisabledChange}
          limit={20}
          disabled
        />
        <CommentTextarea
          label="Read-only comment"
          value="Review complete"
          onValueChange={onReadOnlyChange}
          limit={20}
          readOnly
        />
      </>,
    );

    const disabled = screen.getByRole("textbox", {
      name: "Disabled comment",
    });
    const readOnly = screen.getByRole("textbox", {
      name: "Read-only comment",
    });

    expect(disabled).toBeDisabled();
    expect(readOnly).toHaveAttribute("readonly");

    await user.type(readOnly, " changed");

    expect(readOnly).toHaveValue("Review complete");
    expect(onDisabledChange).not.toHaveBeenCalled();
    expect(onReadOnlyChange).not.toHaveBeenCalled();
  });
});

describe("CommentForm", () => {
  it("shows an associated required error", async () => {
    const user = userEvent.setup();

    render(<CommentForm onSubmit={vi.fn()} />);

    await user.click(
      screen.getByRole("button", { name: "Submit comment" }),
    );

    const textarea = screen.getByRole("textbox", {
      name: "Code review comment",
    });
    const error = screen.getByRole("alert");

    expect(error).toHaveTextContent(
      "Enter a comment before submitting.",
    );
    expect(textarea).toHaveAttribute("aria-invalid", "true");
    expect(textarea).toHaveAccessibleDescription(
      expect.stringContaining("Enter a comment before submitting."),
    );
  });

  it("updates a length error and clears it after valid input", async () => {
    const user = userEvent.setup();

    render(<CommentForm onSubmit={vi.fn()} />);

    const textarea = screen.getByRole("textbox", {
      name: "Code review comment",
    });

    await user.type(textarea, "Short");
    await user.click(
      screen.getByRole("button", { name: "Submit comment" }),
    );

    expect(screen.getByRole("alert")).toHaveTextContent(
      "Comment must contain at least 10 UTF-16 code units.",
    );

    await user.clear(textarea);
    await user.type(textarea, "A useful fix");

    expect(screen.queryByRole("alert")).not.toBeInTheDocument();
  });

  it("submits through FormData and clears the controlled value", async () => {
    const user = userEvent.setup();
    const handleSubmit = vi.fn();

    render(<CommentForm onSubmit={handleSubmit} />);

    const textarea = screen.getByRole("textbox", {
      name: "Code review comment",
    });

    await user.type(textarea, "Extract this helper");
    await user.click(
      screen.getByRole("button", { name: "Submit comment" }),
    );

    expect(handleSubmit).toHaveBeenCalledWith("Extract this helper");
    expect(textarea).toHaveValue("");
  });

  it("disables the textarea and submit button together", () => {
    render(<CommentForm onSubmit={vi.fn()} disabled />);

    expect(
      screen.getByRole("textbox", {
        name: "Code review comment",
      }),
    ).toBeDisabled();

    expect(
      screen.getByRole("button", { name: "Submit comment" }),
    ).toBeDisabled();
  });
});

userEvent.type sends edits through the same interaction path as ordinary typing, and {Enter} inserts a newline in the textarea. The wrapper in the first test proves that onValueChange updates controlled state rather than checking only whether a callback fired.

The autosize test controls scrollHeight. It verifies the boundary decisions the component owns: capping height, enabling overflow, and shrinking after the controlled value changes. It does not pretend that the test runner calculated the pixels a browser would render.

The form tests query the textarea by its accessible name, which catches broken htmlFor and id wiring. They also inspect the accessible description, not only the presence of error text elsewhere on the page. That distinction catches a validation message that looks correct but is not programmatically associated with the field.

Frequently asked questions

How do you create a textarea in React?
Render the native textarea element with a visible label connected through htmlFor and id. Use value with onChange for a controlled textarea, or defaultValue when the DOM should retain the current value.
How do you make a React textarea grow with its content?
Set its height to auto, read scrollHeight, and then assign that measured height. Repeating this process after each value change lets the textarea shrink when text is deleted as well as grow when lines are added.
What is the difference between value and defaultValue on a React textarea?
The value prop makes React state the source of the current text, so onChange must update that state synchronously. defaultValue supplies only the initial text and leaves subsequent edits in the DOM.
How should a React textarea character limit count emoji?
Use JavaScript string length when the counter must match the native maxlength attribute. Both count UTF-16 code units, so some emoji count as more than one even when they appear as a single character.