How to Build a Character Counter in React
Building a character counter in React is a 10-minute exercise — but doing it right (with grapheme counting for emoji, controlled inputs, and TypeScript) is what separates an MVP from a production-quality component. This tutorial covers both. Our live Character Counter is built using exactly these patterns. For the vanilla-JS basics underlying React's hooks, see the JavaScript tutorial.
Basic character counter (controlled input)
The simplest version — a controlled textarea with a live character count.
import { useState } from "react";
export default function CharCounter() {
const [text, setText] = useState("");
return (
<div>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={5}
placeholder="Type here..."
/>
<p>{text.length} characters</p>
</div>
);
}With max length and progress
Add a maximum, a remaining-count, and a visual progress bar that changes color near the limit.
import { useState } from "react";
const MAX = 280; // Twitter-style
export default function TweetCounter() {
const [text, setText] = useState("");
const remaining = MAX - text.length;
const percent = Math.min(100, (text.length / MAX) * 100);
const overLimit = remaining < 0;
const barColor = overLimit
? "bg-red-500"
: remaining < 20
? "bg-amber-500"
: "bg-emerald-500";
return (
<div>
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={4}
className={`w-full ${overLimit ? "border-red-500" : ""}`}
/>
<div className="h-1 bg-slate-100 rounded overflow-hidden mt-2">
<div className={`h-full ${barColor}`} style={{ width: `${percent}%` }} />
</div>
<p className={overLimit ? "text-red-600" : ""}>
{remaining} characters remaining
</p>
</div>
);
}Grapheme-aware counting (handles emoji correctly)
Use Intl.Segmenter for visible character counts that match user expectations.
import { useMemo, useState } from "react";
function graphemeCount(text: string): number {
if (!text) return 0;
if (typeof Intl === "undefined" || !Intl.Segmenter) {
return Array.from(text).length; // fallback
}
const seg = new Intl.Segmenter("en", { granularity: "grapheme" });
let count = 0;
for (const _ of seg.segment(text)) count++;
return count;
}
export default function GraphemeCounter() {
const [text, setText] = useState("");
const visibleCount = useMemo(() => graphemeCount(text), [text]);
return (
<div>
<textarea value={text} onChange={(e) => setText(e.target.value)} />
<p>{visibleCount} visible characters</p>
<p>{text.length} code units</p>
</div>
);
}Reusable hook pattern
Wrap counter logic in a custom hook so multiple inputs can share the same behavior.
import { useState, useCallback } from "react";
interface UseCharCounter {
text: string;
setText: (s: string) => void;
length: number;
remaining: number;
isOver: boolean;
}
export function useCharCounter(maxLength: number, initial = ""): UseCharCounter {
const [text, setText] = useState(initial);
const length = text.length;
const remaining = maxLength - length;
const isOver = remaining < 0;
return { text, setText, length, remaining, isOver };
}
// Usage:
function MyForm() {
const title = useCharCounter(60);
const description = useCharCounter(155);
return (
<>
<input value={title.text} onChange={(e) => title.setText(e.target.value)} />
<span>{title.remaining}</span>
<textarea value={description.text} onChange={(e) => description.setText(e.target.value)} />
<span>{description.remaining}</span>
</>
);
}Common Pitfalls
⚠Don't use defaultValue + onChange together
An input is either controlled (value + onChange) or uncontrolled (defaultValue). Mixing them causes React warnings and unpredictable behavior.
⚠useState for every keystroke is fine
Re-rendering on every keystroke seems wasteful, but React handles thousands of re-renders per second easily. Don't optimize prematurely.
⚠Don't prevent typing past maxLength
Soft-warn instead of hard-blocking. Users hate typing only to find their last word disappeared. Show 'over limit' and disable submit, don't truncate.
⚠Server-side validation still matters
Client-side character limits are UX, not security. Always re-validate on the server before saving.
See a Working Character Counter
Our Character Counter is built using the patterns from this tutorial. Open the dev tools to inspect the live implementation.
📊Open Character Counter