A ReactJS textarea uses
defaultValuefor an uncontrolled initial value or a stringvaluewith a synchronousonChangeupdate for a controlled value.
How a Textarea Works in React
A textarea accepts familiar HTML attributes in JSX, including name, rows, cols, placeholder, required, minLength, and maxLength. React uses className and htmlFor where HTML would use class and for, but the textarea itself remains the browser's multiline form control.
A visible label should identify the field. Match the label's htmlFor value to the textarea's id:
<label htmlFor="interview-notes">Interview notes</label>
<textarea
id="interview-notes"
name="notes"
rows={6}
cols={40}
placeholder="Write your notes"
required
minLength={10}
maxLength={280}
/>
The placeholder is a hint, not a replacement for the label. Matching htmlFor and id gives the textarea an accessible name, and clicking the label focuses the control.
React handles initial textarea content differently from literal HTML. Do not place the initial text between the opening and closing tags:
<textarea>Initial notes</textarea>
React does not support children as the way to supply initial textarea content. Use defaultValue when the DOM should own later edits:
<textarea
id="interview-notes"
name="notes"
defaultValue="Initial notes"
/>
Use value when React state should own the current content:
const [notes, setNotes] = useState("");
<textarea
id="interview-notes"
name="notes"
value={notes}
onChange={(event) => setNotes(event.target.value)}
/>
The change handler updates state to event.target.value immediately. React then renders that string back into the textarea. The broader React textarea guide covers related validation, autosizing, and testing patterns.
Controlled vs. Uncontrolled Textareas
A controlled textarea receives its current string through value. React state owns the source of truth, so every edit calls onChange and updates that state.
An uncontrolled textarea receives an optional starting string through defaultValue. After the initial render, the DOM owns the current text. Code usually reads it when the form is submitted.
A reusable controlled component does not have to own the state itself. It can accept value and onChange as props, leaving ownership with its parent.
| Approach | Current value belongs to | How code reads changes | Suitable use |
|---|---|---|---|
value plus onChange | React state | On every edit | Counters, live validation, previews, and conditional UI |
defaultValue plus FormData | The DOM | During submission | Forms that do not need the current text while typing |
Component value plus onChange props | The parent component | Through the component contract | Shared fields and integration with form state |
An uncontrolled form can read the textarea by name:
function NotesForm() {
function handleSubmit(event) {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const notes = formData.get("notes");
console.log(notes);
}
return (
<form onSubmit={handleSubmit}>
<label htmlFor="notes">Interview notes</label>
<textarea
id="notes"
name="notes"
defaultValue="Review array methods"
/>
<button type="submit">Save</button>
</form>
);
}
The name="notes" attribute makes notes the key used in FormData. Without a name, the textarea does not appear in that submitted form data.
A reusable controlled field keeps the same ownership rule:
import { useId } from "react";
function NotesTextarea({
label,
name,
id,
value,
onChange,
...textareaProps
}) {
const generatedId = useId();
const textareaId = id ?? `notes-${generatedId}`;
return (
<>
<label htmlFor={textareaId}>{label}</label>
<textarea
{...textareaProps}
id={textareaId}
name={name}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
</>
);
}
Choose controlled state when another part of the interface needs the current string. Choose an uncontrolled field when code only needs the result at submission. Choose a controlled component API when the parent or a form library should own the value.
Do not mix the approaches. A textarea cannot be controlled and uncontrolled at the same time, and it should not switch modes during its lifetime.
Build an Interview-Ready Textarea
The exercise is a feedback form with these requirements:
- A visible label identifies the textarea.
- The textarea permits at most 280 UTF-16 code units.
- A live message reports the remaining amount.
- Empty feedback and feedback shorter than 10 code units produce distinct errors.
- Successful submission passes the text to a callback and clears the field.
- Reset restores the latest accepted draft.
- A remotely loaded draft can fill an untouched field but cannot replace an edit already made.
JavaScript string length and the HTML maxlength calculation both use UTF-16 code units. Some user-perceived characters occupy more than one code unit. This exercise therefore describes its limit as 280 code units, not as a universal 280-character limit.
The component has three state invariants. initialDraft is an optional string, and an overlong draft is truncated to 280 code units. text is always a string. remaining is derived from text.length instead of stored separately. Once dirty becomes true, a later draft does not replace the current edit.
This is the canonical implementation used by the tests later in the article:
import { useEffect, useId, useRef, useState } from "react";
const MAX_LENGTH = 280;
const MIN_LENGTH = 10;
function normalizeDraft(value) {
return (value ?? "").slice(0, MAX_LENGTH);
}
export function FeedbackTextarea({
initialDraft,
onSubmit,
id,
name = "feedback",
rows = 6,
cols = 40,
}) {
const generatedId = useId();
const textareaId = id ?? `feedback-${generatedId}`;
const remainingId = `${textareaId}-remaining`;
const errorId = `${textareaId}-error`;
const [text, setText] = useState(() => normalizeDraft(initialDraft));
const [dirty, setDirty] = useState(false);
const [error, setError] = useState("");
const previousDraft = useRef(initialDraft);
const acceptedDraft = useRef(normalizeDraft(initialDraft));
useEffect(() => {
if (Object.is(previousDraft.current, initialDraft)) {
return;
}
previousDraft.current = initialDraft;
if (!dirty) {
const nextDraft = normalizeDraft(initialDraft);
acceptedDraft.current = nextDraft;
setText(nextDraft);
setError("");
}
}, [initialDraft, dirty]);
const remaining = MAX_LENGTH - text.length;
function validate(value) {
if (value.length === 0) {
return "Feedback is required.";
}
if (value.length < MIN_LENGTH) {
return `Feedback must contain at least ${MIN_LENGTH} code units.`;
}
if (value.length > MAX_LENGTH) {
return `Feedback must contain at most ${MAX_LENGTH} code units.`;
}
return "";
}
function handleChange(event) {
setText(event.target.value.slice(0, MAX_LENGTH));
setDirty(true);
if (error) {
setError("");
}
}
function handleSubmit(event) {
event.preventDefault();
const nextError = validate(text);
setError(nextError);
if (nextError) {
return;
}
onSubmit(text);
setText("");
setDirty(false);
setError("");
}
function handleReset() {
setText(acceptedDraft.current);
setDirty(false);
setError("");
}
const describedBy = error
? `${remainingId} ${errorId}`
: remainingId;
return (
<form onSubmit={handleSubmit} noValidate>
<label htmlFor={textareaId}>Interview feedback</label>
<textarea
id={textareaId}
name={name}
rows={rows}
cols={cols}
placeholder="Describe what went well and what needs work"
value={text}
onChange={handleChange}
required
minLength={MIN_LENGTH}
maxLength={MAX_LENGTH}
aria-describedby={describedBy}
aria-invalid={Boolean(error)}
/>
<p id={remainingId} role="status" aria-live="polite">
{remaining} code units remaining
</p>
{error ? (
<p id={errorId} role="alert">
{error}
</p>
) : null}
<button type="submit">Submit feedback</button>
<button type="button" onClick={handleReset}>
Reset
</button>
</form>
);
}
The form uses noValidate because this exercise displays its own validation messages. The textarea still has required, minLength, and maxLength, so its constraints remain visible in the component contract.
minLength alone does not make an empty textarea invalid. That is why the implementation includes required and checks the empty case separately. minLength also does not stop the user from deleting text below the minimum. Validation reports that problem when the form is submitted.
maxLength prevents additional input after the limit in normal browser editing. The derived count uses the same text.length unit. A standalone character counter textarea exercise is useful when the counter itself is the interview task.
Handle Async Values and Common Bugs
A controlled textarea becomes effectively read-only when it has a value but its onChange handler does not update the state behind that value:
const [feedback] = useState("Initial text");
<textarea value={feedback} />
Typing changes the DOM briefly, but React renders the unchanged state value again. The fix is to store the setter and synchronously update the state to event.target.value.
Controlled values must remain strings. API data often begins as undefined while a request is pending. Normalize that missing value:
<textarea
value={feedback ?? ""}
onChange={(event) => setFeedback(event.target.value)}
/>
Passing undefined first and a string later switches the textarea from uncontrolled to controlled. React warns about that mode change. Initializing state with "", or using the nullish fallback above, keeps the mode consistent.
Caret jumps often appear when an onChange handler writes a transformed or stale value instead of the current event.target.value. For example, forcing a formatting transformation on every keystroke can change both the text and the selection position. Keep the immediate edit update faithful to the browser value. Apply heavier formatting on blur, on submission, or through a design that also manages selection.
Async drafts introduce a separate race:
- The component renders with no draft.
- The request starts.
- The user types feedback.
- The request finishes and supplies saved text.
Blindly copying every new draft into state at step four destroys the user's edit. The canonical component treats dirty as a product rule: an untouched field accepts a changed initialDraft, while a dirty field keeps its local text.
This is not a requirement imposed by React. Another product might show a conflict message or let the user choose between drafts. The important interview step is to name the rule and implement it consistently.
Test the Textarea With Jest
React Testing Library can find the textarea by its role and accessible name. getByRole("textbox", { name: /interview feedback/i }) checks the same label relationship that a user relies on. getByLabelText is also appropriate for form fields.
The following Jest tests target the canonical FeedbackTextarea component. They use userEvent.type for typing and userEvent.clear for clearing the editable field. They assume @testing-library/react, @testing-library/user-event, @testing-library/jest-dom, and jest-environment-jsdom are installed.
/** @jest-environment jsdom */
import "@testing-library/jest-dom";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FeedbackTextarea } from "./FeedbackTextarea";
test("starts empty, updates the count, and can be cleared", async () => {
const user = userEvent.setup();
render(<FeedbackTextarea onSubmit={jest.fn()} />);
const textarea = screen.getByRole("textbox", {
name: /interview feedback/i,
});
expect(textarea).toHaveValue("");
expect(screen.getByRole("status")).toHaveTextContent(
"280 code units remaining"
);
await user.type(textarea, "hello");
expect(textarea).toHaveValue("hello");
expect(screen.getByRole("status")).toHaveTextContent(
"275 code units remaining"
);
await user.clear(textarea);
expect(textarea).toHaveValue("");
expect(screen.getByRole("status")).toHaveTextContent(
"280 code units remaining"
);
});
test("enforces the 280-code-unit input limit", async () => {
const user = userEvent.setup();
render(<FeedbackTextarea onSubmit={jest.fn()} />);
const textarea = screen.getByRole("textbox", {
name: /interview feedback/i,
});
await user.type(textarea, "a".repeat(281));
expect(textarea).toHaveValue("a".repeat(280));
expect(textarea).toHaveAttribute("maxLength", "280");
expect(screen.getByRole("status")).toHaveTextContent(
"0 code units remaining"
);
});
test("truncates an overlong initial draft before submission", async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(
<FeedbackTextarea
initialDraft={"a".repeat(281)}
onSubmit={handleSubmit}
/>
);
const textarea = screen.getByRole("textbox", {
name: /interview feedback/i,
});
expect(textarea).toHaveValue("a".repeat(280));
expect(screen.getByRole("status")).toHaveTextContent(
"0 code units remaining"
);
await user.click(screen.getByRole("button", { name: /submit feedback/i }));
expect(handleSubmit).toHaveBeenCalledWith("a".repeat(280));
});
test("reports validation errors and submits valid feedback", async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
render(<FeedbackTextarea onSubmit={handleSubmit} />);
const textarea = screen.getByRole("textbox", {
name: /interview feedback/i,
});
const submit = screen.getByRole("button", {
name: /submit feedback/i,
});
await user.click(submit);
expect(screen.getByRole("alert")).toHaveTextContent(
"Feedback is required."
);
expect(textarea).toHaveAttribute("aria-invalid", "true");
expect(handleSubmit).not.toHaveBeenCalled();
await user.type(textarea, "short");
await user.click(submit);
expect(screen.getByRole("alert")).toHaveTextContent(
"Feedback must contain at least 10 code units."
);
await user.clear(textarea);
await user.type(textarea, "Clear explanation");
await user.click(submit);
expect(handleSubmit).toHaveBeenCalledWith("Clear explanation");
expect(textarea).toHaveValue("");
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent(
"280 code units remaining"
);
});
test("reset restores the current draft", async () => {
const user = userEvent.setup();
render(
<FeedbackTextarea
initialDraft="Saved draft"
onSubmit={jest.fn()}
/>
);
const textarea = screen.getByRole("textbox", {
name: /interview feedback/i,
});
await user.type(textarea, " with an edit");
await user.click(screen.getByRole("button", { name: /reset/i }));
expect(textarea).toHaveValue("Saved draft");
});
test("loads a draft into a pristine field", () => {
const { rerender } = render(
<FeedbackTextarea
initialDraft={undefined}
onSubmit={jest.fn()}
/>
);
rerender(
<FeedbackTextarea
initialDraft="Loaded draft"
onSubmit={jest.fn()}
/>
);
expect(
screen.getByRole("textbox", { name: /interview feedback/i })
).toHaveValue("Loaded draft");
});
test("preserves an edit when an async draft arrives late", async () => {
const user = userEvent.setup();
const handleSubmit = jest.fn();
const { rerender } = render(
<FeedbackTextarea
initialDraft={undefined}
onSubmit={handleSubmit}
/>
);
const textarea = screen.getByRole("textbox", {
name: /interview feedback/i,
});
await user.type(textarea, "My local edit");
rerender(
<FeedbackTextarea
initialDraft="Late remote draft"
onSubmit={handleSubmit}
/>
);
expect(textarea).toHaveValue("My local edit");
});
These tests cover the observable contract: the accessible field name, initial state, typing, clearing, the code-unit limit, validation, submission, reset, pristine async loading, and preservation of dirty text. They do not inspect internal state or call component functions directly.
Interview Follow-Ups and Tradeoffs
Validation can live in the field, the parent form, or a shared validation layer. This exercise keeps it in the component because the errors and constraints belong to one named field. A larger form may centralize validation so related fields can be checked together.
Uncontrolled fields can reduce state updates while someone types because the DOM owns the current text. That benefit matters only when the interface does not need the value for a counter, preview, conditional control, or live validation. Measure an actual performance problem before giving up the simpler data flow required by the interface.
A visible label provides the textarea's accessible name and gives the user a larger focus target. Placeholder text can disappear during typing, so it should remain a hint rather than the only identification.
A reusable component should accept a string value and an onChange callback when its parent or form library owns the data. It can also pass through field attributes such as name, required, and aria-describedby. That contract keeps value ownership outside the visual field.
Autosizing is a different concern from value management. It involves responding to content size and DOM measurements, so it can be added as a separate hook or wrapper after the controlled textarea works. The useTextareaAutosize question isolates that behavior, while the React interview questions guide places the exercise among broader interview rounds.
For additional timed workspaces and tested solutions, UIReady Premium provides more practice beyond this textarea exercise.