How to Build a Textarea with a Character Counter
Advertisement
🧩 Scenario
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
| Approach | Real-time counter | Value in state | Reset support | When to use |
|---|---|---|---|---|
| Controlled + derived count | Yes | Yes | Yes | Any textarea that needs a counter |
| useRef + setState(count) | Yes (with extra step) | No | Via DOM call | Avoid: anti-pattern |
| Uncontrolled (submit-only) | No | No | Via 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
- How would you implement an expandable textarea that grows in height as the user types more content?
- How would you add an "emoji picker" button that inserts an emoji at the cursor position inside the textarea?
- How does the HTML
maxLengthattribute interact with controlledonChange— does React still callonChangewhen the limit is hit? - How would you build a rich text editor (bold, italic, links) that still shows a character count based on the plain-text content?
- How would you test that the character counter updates correctly using React Testing Library?
Common Candidate Mistakes
- Storing
remainingas a seconduseStateand syncing withuseEffect: derived state anti-pattern with an extra render cycle - Using
useRefto 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
maxLengthbefore reachingonChange - Placing
maxLengthonly on the textarea element without also slicing inonChange, 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
valueandonChange? - Can you derive the remaining character count from
state.lengthduring render without a seconduseState? - Can you apply progressive color changes to the counter based on usage percentage?
- Can you explain why
useRefis the wrong tool for reading the textarea value for display? - Can you implement both the DOM-level
maxLengthand 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.
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