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.
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.
| Approach | Current value lives in | Read the value with | Best fit |
|---|---|---|---|
value plus onChange | React state | The state variable | Counters, live validation, conditional interface behavior |
defaultValue | The DOM | FormData during submission | Simple forms that do not need each edit in React state |
defaultValue plus a ref | The DOM | ref.current?.value | Imperative 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, andplaceholder. - Submit the value through normal
FormDatabehavior. - 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.
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.
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.
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:
- Set the height to
autoso the previous explicit height no longer holds the element open. - Read
scrollHeight, which measures the content height including padding but excluding borders and margins. - Assign the smaller of
scrollHeightand the configured maximum height. - 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.
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.
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.