30% offEnding soon

ReactJS Textarea: A Practical Interview Guide

18 min read

A ReactJS textarea uses defaultValue for an uncontrolled initial value or a string value with a synchronous onChange update 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.

One shared value creates the linkVisible labelhtmlFor =interview-notesTextareaid =interview-notesmatchesClicking the label moves focus here
The label and textarea are linked by the same identifier.

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.

ApproachCurrent value belongs toHow code reads changesSuitable use
value plus onChangeReact stateOn every editCounters, live validation, previews, and conditional UI
defaultValue plus FormDataThe DOMDuring submissionForms that do not need the current text while typing
Component value plus onChange propsThe parent componentThrough the component contractShared 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.

ControlledUncontrolledReact stateTextareavalueeditEvery keystrokecompletes the loopTextareaDOM owns textFormDatasubmitCode reads onceat submission
Controlled and uncontrolled textareas have different owners and data paths.

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.

What the user seesA🙂BWhat text.length countsAhighlowB3 visible characters · 4 code units
One visible symbol can consume two UTF-16 code units.

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.

Submit clickedIs length zero?Required errorStop submissionyesIs length below 10?noMinimum errorStop submissionyesCall onSubmitthen clear textno
Submission follows one validation branch based on the current length.

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.

One source of truthCurrent textReact statetext.lengthcalculateRemaining280 minustext.lengthWhen text changes, the whole chainrecalculates from the same value
Keep the text as state and calculate the remaining count from it.

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:

  1. The component renders with no draft.
  2. The request starts.
  3. The user types feedback.
  4. 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.

When the remote draft arrivesRemote draftfinishes loadingUser may havetyped alreadyIs the fielddirty?Load remotedraft into fieldnoKeep the localedit unchangedyes
The dirty flag protects local typing from a late remote draft.

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.

Test the public contractUser actionstypeclear · submitFeedbackcomponentInternalstateObservevaluestatusalertcallbackState stays behind the component boundary
Behavioral tests interact with the component through the same surface as a user.

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.

Frequently asked questions

How do you create a textarea in ReactJS?
Render the native textarea element in JSX and associate it with a visible label. Use defaultValue for an uncontrolled initial value, or use a string value with an onChange handler for a controlled textarea.
Why is my React textarea read-only?
A textarea becomes controlled when it receives a string through value. Its onChange handler must synchronously update the state used by value, or React will keep restoring the old text.
Should a React textarea be controlled or uncontrolled?
Use controlled state when the interface depends on the current text, such as a remaining count or live validation. Use an uncontrolled textarea when the value is only needed during submission, and expose value plus onChange when building a reusable controlled component.
How do you set an initial textarea value from an API?
Store the loaded string in state or pass it through a draft prop that updates the state. Decide what happens if the response arrives after typing starts; one safe product rule is to ignore late drafts once the field is dirty.
Does maxlength count visible characters in a textarea?
The maxlength attribute measures UTF-16 code units. Some symbols use more than one code unit, so a limit of 280 does not always mean 280 user-perceived characters.