ReactRegexString ManipulationJSX RenderingSearch

Build Search with Highlighting

Learn how to filter a list of articles by a search query and highlight every occurrence of the match in both titles and body text. A common frontend interview question that tests regex, string splitting, and dynamic JSX rendering.

15 min read7 sections
01

Problem Statement

Build a search interface that filters a list of articles and highlights every occurrence of the search query in both the title and body text.

  • Filter articles whose title OR body contains the search query
  • Highlight all occurrences of the query in both title and body
  • Matching should be case-insensitive
  • If the query is empty, show all articles with no highlights
  • Show the count of matching articles
  • Handle special regex characters in the query safely

Why this question?

Search with highlighting tests string manipulation, regex fundamentals, and the ability to return mixed JSX (plain text + styled spans) from a function. It's deceptively simple — most candidates get filtering right but struggle with the highlighting part, especially escaping special characters.

02

Component Anatomy

The UI has three parts: a search input, a results count, and the article list.

🔍

Search Input

A controlled input that updates the query state on every keystroke. The query drives both filtering and highlighting.

📄

Article Cards

Each card shows a title and body. Both are passed through the highlightText function which wraps matches in <mark> tags.

🔗

Filter Logic

A simple .filter() that checks if the title or body includes the query (case-insensitive). Runs on every render.

Highlight Function

The core piece: splits text around matches using a regex with a capturing group, then maps parts to plain text or highlighted spans.

Visual structuretext
┌─────────────────────────────────────────────┐
│  🔍  Search articles...                     │  ← Controlled input
├─────────────────────────────────────────────┤
Showing 3 of 6 articles                    │  ← Filtered count
├─────────────────────────────────────────────┤
│                                             │
Getting Started with [React]               │  ← [React] = highlighted
│  [React] is a JavaScript library for...     │
│                                             │
Understanding JavaScript Closures
In JavaScript, closures are created...     │
│                                             │
└─────────────────────────────────────────────┘
03

Filtering Logic

Filtering is the easy part. Check if the title or body contains the query, case-insensitively. If the query is empty, show everything.

Filtering articlestypescript
const [query, setQuery] = useState("");

const filtered = ARTICLES.filter((article) => {
  if (!query.trim()) return true; // Empty query → show all

  const q = query.toLowerCase();
  return (
    article.title.toLowerCase().includes(q) ||
    article.body.toLowerCase().includes(q)
  );
});

Why not debounce the filter?

For a small dataset (6 articles), filtering on every keystroke is fine. For larger datasets (1000+ items), you'd debounce the query or move filtering to a Web Worker. Mention this trade-off in interviews.

04

The Highlighting Technique

This is the core of the problem. The trick is using String.split() with a regex that has a capturing group. When you split with a capturing group, the matched parts are included in the result array.

1

Build a regex with a capturing group

Create new RegExp(`(${query})`, "gi"). The parentheses create a capturing group. The "g" flag finds all matches, "i" makes it case-insensitive.

2

Split the text

text.split(regex) returns an array of alternating non-match and match segments. For example: "React is great".split(/(react)/gi) → ["", "React", " is great"].

3

Map parts to JSX

Loop over the parts. If a part matches the query (case-insensitive compare), wrap it in a <mark> tag. Otherwise, return it as plain text.

How split with capturing group workstypescript
const text = "React is a JavaScript library. React rocks.";
const query = "react";

// Without capturing group — matches are LOST:
text.split(/react/gi)
// → ["", " is a JavaScript library. ", " rocks."]

// With capturing group — matches are KEPT:
text.split(/(react)/gi)
// → ["", "React", " is a JavaScript library. ", "React", " rocks."]
//         ^^^^^ match                            ^^^^^ match
highlightText functiontypescript
const highlightText = (text: string, query: string) => {
  if (!query.trim()) return text;

  // Escape special regex characters
  const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

  // Split with capturing group to keep matches
  const parts = text.split(new RegExp(`(${escaped})`, "gi"));

  return parts.map((part, i) =>
    part.toLowerCase() === query.toLowerCase() ? (
      <mark key={i} className="bg-yellow-200 text-gray-900 rounded-sm px-0.5">
        {part}
      </mark>
    ) : (
      part
    )
  );
};

Why compare with toLowerCase()?

The regex splits case-insensitively, so the matched part preserves its original casing (e.g., "React" not "react"). We compare with toLowerCase() to identify which parts are matches regardless of case. This way the highlight shows the original text casing.

05

Escaping Special Characters

If the user types C++ or file.txt or (test), the regex will break because +, ., and () are special regex characters. You must escape them.

Escaping regex special characterstypescript
// These characters have special meaning in regex:
// . * + ? ^ $ { } ( ) | [ ] \

const escapeRegex = (str: string) =>
  str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

// Examples:
escapeRegex("C++")      // → "C\+\+"
escapeRegex("file.txt") // → "file\.txt"
escapeRegex("(test)")   // → "\(test\)"

// Now safe to use in RegExp:
new RegExp(`(${escapeRegex("C++")})`, "gi")
// → /(C++)/gi  ✓
User InputWithout EscapeWith Escape
C++❌ Invalid regex error✓ Matches literal "C++"
file.txt❌ Matches "fileatxt" too (. = any char)✓ Matches only "file.txt"
(test)❌ Creates a capture group instead✓ Matches literal "(test)"

This is an interview differentiator

Most candidates forget to escape special characters. Bringing it up proactively shows attention to edge cases and production-readiness. Interviewers notice this.

06

Full Implementation

Here's the complete search with highlighting. Study how filtering, escaping, splitting, and JSX mapping compose together.

highlight-search-text/page.tsxtypescript
"use client";

import { useState } from "react";

interface Article {
  id: number;
  title: string;
  body: string;
}

const ARTICLES: Article[] = [
  {
    id: 1,
    title: "Getting Started with React",
    body: "React is a JavaScript library for building user interfaces...",
  },
  // ... more articles
];

export default function HighlightSearchTextPage() {
  const [query, setQuery] = useState("");

  // 1. Filter articles by query
  const filtered = ARTICLES.filter((article) => {
    if (!query.trim()) return true;
    const q = query.toLowerCase();
    return (
      article.title.toLowerCase().includes(q) ||
      article.body.toLowerCase().includes(q)
    );
  });

  // 2. Highlight matches in text
  const highlightText = (text: string, search: string) => {
    if (!search.trim()) return text;

    // Escape special regex characters
    const escaped = search.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");

    // Split with capturing group — keeps matches in the array
    const parts = text.split(new RegExp(`(${escaped})`, "gi"));

    return parts.map((part, i) =>
      part.toLowerCase() === search.toLowerCase() ? (
        <mark
          key={i}
          className="bg-yellow-200 text-gray-900 rounded-sm px-0.5"
        >
          {part}
        </mark>
      ) : (
        part
      )
    );
  };

  return (
    <div>
      {/* Search input */}
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search articles..."
      />

      {/* Results count */}
      <p>Showing {filtered.length} of {ARTICLES.length} articles</p>

      {/* Article list */}
      {filtered.map((article) => (
        <div key={article.id}>
          <h2>{highlightText(article.title, query)}</h2>
          <p>{highlightText(article.body, query)}</p>
        </div>
      ))}
    </div>
  );
}
✓ Done

Case-insensitive matching

Both filtering and highlighting use case-insensitive comparison. The original casing is preserved in the rendered output.

✓ Done

Regex escaping

Special regex characters in the query are escaped before building the RegExp, preventing crashes on inputs like 'C++' or '(test)'.

→ Could add

Debounced search

For large datasets, debounce the query update by 200-300ms to avoid filtering and re-rendering on every keystroke.

→ Could add

Fuzzy matching

Instead of exact substring matching, use a fuzzy search algorithm (like Fuse.js) to match misspellings and partial words.

07

Common Interview Follow-up Questions

After building search with highlighting, interviewers explore edge cases and enhancements:

Q:Why use split with a capturing group instead of replaceAll?

A: replaceAll returns a string, but we need JSX — a mix of plain text and <mark> elements. split() with a capturing group gives us an array of parts that we can map to JSX nodes. You can't embed React elements inside a string.

Q:What if the dataset has 10,000 articles?

A: Debounce the search input (300ms). Move filtering to a Web Worker to avoid blocking the main thread. Paginate or virtualize the results list. Consider a server-side search with an index (like Elasticsearch) for very large datasets.

Q:How would you highlight multiple search terms?

A: Split the query by spaces into individual terms. Build a regex that matches any of them: new RegExp(`(${terms.join('|')})`, 'gi'). The same split-and-map technique works — just check if each part matches any of the terms.

Q:How would you handle overlapping matches?

A: With simple substring matching, overlaps don't occur because split() processes left to right. For regex patterns with alternation (a|ab), the regex engine picks the first match. If you need longest-match-first, sort terms by length descending before joining with |.

Q:What about performance of creating RegExp on every render?

A: For small datasets it's negligible. For optimization, memoize the regex with useMemo keyed on the query. You could also memoize the filtered + highlighted results. But premature optimization here is usually not worth it — profile first.

Q:How would you add keyboard shortcuts (e.g., Ctrl+F to focus search)?

A: Add a useEffect with a keydown listener on the document. Check for e.ctrlKey && e.key === 'f', call e.preventDefault() to override browser find, and focus the search input using a ref. Clean up the listener on unmount.

Q:What about accessibility for highlighted text?

A: Use the <mark> HTML element — it has semantic meaning ('highlighted text'). Screen readers announce it. Add aria-label on the results count to announce 'Showing X of Y results'. Use role='status' with aria-live='polite' so screen readers announce count changes.

Ready to build it yourself?

We've set up the articles and UI shell. Implement the filtering and highlighting logic from scratch.

Built for developers, by developers. Happy coding! 🚀