How to Build a Textarea with a Character Counter

Intermediate8 min interview
Skills tested:
Using a controlled textarea with value from state and onChange updating stateComputing the remaining character count as derived state (not stored in useState)Applying visual feedback when the character limit is approaching (color change)Knowing when useRef is the right tool for textarea access vs when controlled state is betterUsing the native maxLength attribute as a hard DOM cap alongside React validation

Advertisement

🧩 Scenario

Character counters appear in comment boxes, bio fields, tweet-style inputs, and any form field with a content limit. The counter must update on every keystroke. This requires the textarea value to be in state. The count itself is always derived from the value length, never stored separately. A candidate who reaches for useRef for this feature misunderstands when refs are the right tool.

Architecture Walkthrough

Controlled Textarea and Derived Count

A character counter needs the textarea value on every keystroke to display the current count. This means the value must be in React state (controlled textarea). Once it is, the remaining count is maxLength - value.length, computed during render. No second useState for the count is needed; no useEffect to sync it. Computing during render guarantees the count is always current.

The <textarea> controlled component works identically to <input>: set value from state and use onChange to update state. The only syntactic difference is that there is no self-closing tag; <textarea> uses value and children together is not valid in React (unlike HTML).

Visual Feedback Thresholds

A good counter provides progressive feedback. The typical pattern is three zones: normal (gray), warning (orange, usually at 80-90% usage), and danger (red, usually at 95%+). These are derived from the same count value with simple comparisons and applied as class names or inline styles.

When useRef Is Appropriate Here

The original question title mentions useRef, which represents a common misconception. useRef is the right tool when you want to read the value once on submit without re-rendering on every keystroke. But a counter that updates in real time is a display of current state; it requires re-renders to stay accurate. Using useRef and reading ref.current.value in render would give you the previous DOM value, not the current one. Use a controlled textarea.

useRef has a legitimate role here: accessing the textarea to call textarea.focus() or textarea.select() programmatically from a parent. That is imperative DOM access, distinct from reading the value for display.


Key Code Explained

// Controlled textarea with derived character counter
interface BioInputProps {
  maxLength?: number;
  onSubmit: (bio: string) => void;
}

function BioInput({ maxLength = 160, onSubmit }: BioInputProps) {
  const [bio, setBio] = useState('');

  // Derived during render — always in sync, no extra state
  const remaining = maxLength - bio.length;
  const usagePercent = (bio.length / maxLength) * 100;

  // Progressive color feedback
  const counterColor =
    usagePercent >= 95
      ? 'text-red-500'
      : usagePercent >= 80
        ? 'text-orange-400'
        : 'text-gray-400';

  const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
    // Enforce limit in state (maxLength on the element enforces it in the DOM too)
    setBio(e.target.value.slice(0, maxLength));
  };

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (bio.trim()) onSubmit(bio.trim());
  };

  return (
    <form onSubmit={handleSubmit}>
      <div className="relative">
        <textarea
          value={bio}
          onChange={handleChange}
          maxLength={maxLength}  // hard DOM cap for paste and keyboard
          rows={4}
          placeholder="Write a short bio..."
          className="w-full resize-none rounded border p-3"
        />

        <div className="flex justify-between text-sm mt-1">
          {/* Words count: another derived value */}
          <span className="text-gray-400">
            {bio.trim() ? bio.trim().split(/\s+/).length : 0} words
          </span>

          {/* Character counter: changes color as limit approaches */}
          <span className={counterColor}>
            {remaining} / {maxLength}
          </span>
        </div>

        {remaining <= 0 && (
          <p className="text-red-500 text-sm mt-1">Character limit reached</p>
        )}
      </div>

      <button
        type="submit"
        disabled={!bio.trim()}
        className="mt-2"
      >
        Save Bio
      </button>
    </form>
  );
}


// When useRef IS appropriate: focus the textarea from a parent button
interface FocusableTextareaProps {
  placeholder?: string;
}

const FocusableTextarea = forwardRef<
  { focus: () => void },
  FocusableTextareaProps
>(({ placeholder }, ref) => {
  const [value, setValue] = useState('');
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  // Expose focus() to parent via ref — imperative DOM access, not value reading
  useImperativeHandle(ref, () => ({
    focus: () => textareaRef.current?.focus(),
  }));

  return (
    <textarea
      ref={textareaRef}
      value={value}
      onChange={(e) => setValue(e.target.value)}
      placeholder={placeholder}
    />
  );
});


// Anti-pattern: useRef for counter (DO NOT DO THIS)
function CounterWithRefAntiPattern() {
  const [remaining, setRemaining] = useState(160);
  const textareaRef = useRef<HTMLTextAreaElement>(null);

  const handleChange = () => {
    // BAD: reads the DOM to update state — re-renders twice per keystroke
    // and can read stale values in concurrent mode
    const currentLength = textareaRef.current?.value.length ?? 0;
    setRemaining(160 - currentLength);
  };

  return (
    <div>
      <textarea ref={textareaRef} onChange={handleChange} />
      <p>{remaining} remaining</p>
    </div>
  );
  // BETTER: controlled textarea with derived count
}

The anti-pattern in CounterWithRefAntiPattern reads from the DOM via ref.current.value inside an onChange handler to update state. This works in practice but is backwards: the textarea value is in the DOM, not in React state, so React has no visibility into it. Any feature that needs the value (validation, submit handler, other display elements) must also go through the ref. Switching to a controlled textarea makes the value universally accessible from state.


Tradeoffs

ApproachReal-time counterValue in stateReset supportWhen to use
Controlled + derived countYesYesYesAny textarea that needs a counter
useRef + setState(count)Yes (with extra step)NoVia DOM callAvoid: anti-pattern
Uncontrolled (submit-only)NoNoVia form.reset()Submit-only, no live display

What Interviewers Actually Check

  • Whether you use a controlled textarea to make the value available in real time
  • Whether you derive the remaining count during render without a second useState
  • Whether you apply visual feedback (color change) based on usage thresholds
  • Whether you know why useRef for reading values is the wrong tool here
  • Whether you handle the maxLength in both the DOM attribute and in state

Follow-Up Questions

  1. How would you implement an expandable textarea that grows in height as the user types more content?
  2. How would you add an "emoji picker" button that inserts an emoji at the cursor position inside the textarea?
  3. How does the HTML maxLength attribute interact with controlled onChange — does React still call onChange when the limit is hit?
  4. How would you build a rich text editor (bold, italic, links) that still shows a character count based on the plain-text content?
  5. How would you test that the character counter updates correctly using React Testing Library?

Common Candidate Mistakes

  • Storing remaining as a second useState and syncing with useEffect: derived state anti-pattern with an extra render cycle
  • Using useRef to read the textarea value for the counter, thinking it avoids "unnecessary re-renders" -- but the counter must re-render to update, so controlled state is needed
  • Not enforcing the limit in state (only relying on maxLength), which can allow a limit to be exceeded in uncontrolled paste scenarios before the DOM truncates it
  • Not normalizing the count display to 0 when pasted content is clamped by maxLength before reaching onChange
  • Placing maxLength only on the textarea element without also slicing in onChange, leading to an off-by-one state mismatch during rapid input

Interview Readiness Checklist

Before you leave this question, make sure you can answer:

  • Can you build a controlled textarea with value and onChange?
  • Can you derive the remaining character count from state.length during render without a second useState?
  • Can you apply progressive color changes to the counter based on usage percentage?
  • Can you explain why useRef is the wrong tool for reading the textarea value for display?
  • Can you implement both the DOM-level maxLength and a state-level slice to enforce the limit?

Summary

A textarea with a real-time character counter requires the value to be in React state. Use a controlled textarea: value={bio} and onChange updating state. Once the value is in state, the remaining count is maxLength - bio.length, computed during render. No second useState is needed; no useEffect to sync them. The count is always exactly current because it is a pure function of state.

Visual feedback is also derived: compare the usage percentage to thresholds (80%, 95%) and apply corresponding class names or inline styles to the counter element. These are computed during render from the same state, with no additional state variables.

useRef is the right tool for imperative access to the textarea DOM node: calling focus(), inserting text at the cursor, or scrolling to a position. It is not the right tool for reading the value to display a counter, because the value must be in state to trigger re-renders that update the counter display.

Related Videos
Watch these videos to learn more about this topic
Frequently Asked Questions

Should I use useState or useRef for a character counter?

Use a controlled textarea with useState. The remaining count is then derived during render from state.length — no second useState needed. useRef is for when you want to read the DOM value on submit without triggering re-renders, but then you lose real-time counter updates.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement