⚛️
React Tutorial

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.

tsx
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.

tsx
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.

tsx
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.

tsx
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

FAQ

useState is fine for a single counter. Use useReducer if you're managing multiple related fields with shared logic.

Tutorials in Other Languages