ReactReact.cloneElementTree TraversalRegexReusable Components

Build a Highlight Search Component

Learn how to build a reusable wrapper component that recursively traverses its React children tree and highlights matching text nodes. Unlike a simple function, this approach works with any nested JSX structure — a technique that tests deep React internals knowledge.

20 min read8 sections
01

Problem Statement

Build a reusable <HighlightSearchText> wrapper component. Instead of calling a function on each string, you wrap arbitrary JSX and the component recursively finds and highlights text nodes.

  • Build a wrapper component that accepts query and children props
  • Recursively traverse the React children tree
  • For string nodes: split around matches and wrap in <mark>
  • For element nodes: clone the element and recurse into its children
  • Case-insensitive matching with regex escaping
  • Support multi-word queries (split by spaces, match any word)
  • Works with any nested JSX structure — not tied to specific data shapes

Why this question?

This goes beyond basic string highlighting. It tests your understanding of React's children API (React.Children.map, React.isValidElement, React.cloneElement), recursive tree traversal, and building truly reusable components. It's a medium-difficulty question that separates candidates who understand React internals.

02

Function vs. Component Approach

There are two ways to highlight text. Understanding the difference is key.

ƒ

Function Approach

Call highlightText(text, query) on each string manually. Simple but requires you to know which strings to highlight. Tightly coupled to your data shape.

🧩

Component Approach

Wrap any JSX in <HighlightSearchText>. It recursively finds text nodes and highlights them. Works with any structure — decoupled and reusable.

Function approach (simple but coupled)typescript
// You must call it on every string manually
<h2>{highlightText(article.title, query)}</h2>
<p>{highlightText(article.body, query)}</p>

// If the structure changes, you update every call site
Component approach (reusable)typescript
// Wrap any JSX — it finds text nodes automatically
<HighlightSearchText query={query}>
  <div>
    <h2>{article.title}</h2>
    <p>{article.body}</p>
    <span>{article.author}</span>  {/* also highlighted! */}
  </div>
</HighlightSearchText>

// Structure can change freely — highlighting still works

When to use which?

The function approach is fine for simple cases with known strings. The component approach shines when you have complex, nested, or dynamic JSX where you don't want to manually wire up every text node. In interviews, building the component version shows deeper React knowledge.

03

React Children Tree Traversal

The core technique is a recursive traverse() function that walks the React element tree and handles each node type differently.

1

String node → highlight it

If typeof node === "string", this is a text node. Split it around matches and wrap matches in <mark> tags. Return the array of JSX parts.

2

React element → clone and recurse

If React.isValidElement(node), it's an element like <div>, <h2>, <span>. Use React.cloneElement to create a copy with its children replaced by traverse(children).

3

Anything else → return as-is

Numbers, booleans, null, undefined — just return them unchanged. They don't contain text to highlight.

The traverse functiontypescript
const traverse = (node: React.ReactNode): React.ReactNode => {
  // Case 1: String → highlight text
  if (typeof node === "string") {
    return highlightText(node);
  }

  // Case 2: React element → clone and recurse into children
  if (React.isValidElement(node)) {
    const element = node as React.ReactElement<{
      children?: React.ReactNode;
    }>;
    return React.cloneElement(element, {
      children: React.Children.map(
        element.props.children,
        (child) => traverse(child)
      ),
    });
  }

  // Case 3: Everything else (number, null, etc.)
  return node;
};
React APIPurpose
React.isValidElement(node)Checks if a node is a React element (not a string, number, etc.)
React.cloneElement(el, props)Creates a copy of an element with new/merged props (including children)
React.Children.map(children, fn)Safely iterates over children (handles arrays, fragments, single nodes)

Why React.Children.map instead of Array.map?

React.Children.map handles edge cases that Array.map doesn't: children can be a single element (not an array), null, or a fragment. React.Children.map normalizes all of these. Always use it when traversing unknown children.

04

The Highlight Logic

The highlightText function is the same split-with-capturing-group technique, but now it lives inside the component and uses regex.test() to check matches.

highlightText inside the componenttypescript
const highlightText = (text: string) => {
  const parts = text.split(regex); // regex built from query

  return parts.map((part, index) =>
    regex.test(part) ? (
      <mark key={index} className="bg-yellow-200 font-semibold">
        {part}
      </mark>
    ) : (
      <span key={index}>{part}</span>
    )
  );
};

Watch out: regex.test() with the g flag

When using regex.test() with the g flag, the regex maintains a lastIndex that advances after each match. This can cause alternating true/false results. To avoid this, either reset regex.lastIndex = 0 before each test, or use a case-insensitive toLowerCase() comparison instead of test().

05

Multi-Word Query Support

Instead of matching the entire query as one string, split it into individual words and match any of them. This gives a more useful search experience.

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

// Split query into words, escape each, join with |
const words = query
  .split(/\s+/)        // "react grid" → ["react", "grid"]
  .filter(Boolean)     // remove empty strings
  .map(escapeRegex);   // escape special chars

// Build regex that matches ANY word
const regex = new RegExp(`(${words.join("|")})`, "gi");
// → /(react|grid)/gi

// "CSS Grid Layout" with query "react grid":
// "CSS ".split(regex) → ["CSS "]         (no match)
// "Grid".split(regex) → ["", "Grid", ""] (match!)
// "React".split(regex) → ["", "React", ""] (match!)
QueryRegex BuiltMatches
"react"/(react)/gi"React", "react"
"react grid"/(react|grid)/gi"React", "Grid", "grid"
"C++ node"/(C\+\+|node)/gi"C++", "Node", "node"
06

Putting It All Together

Here's how the pieces compose: the component receives query and children, builds a regex, defines traverse and highlightText, then returns the traversed tree.

1

Early return if no query

If query is empty or falsy, return children unchanged. No work needed.

2

Build the regex from query words

Split query by whitespace, escape each word, join with |, wrap in a capturing group with gi flags.

3

Define highlightText for strings

Split the string with the regex. Map parts: matches get <mark>, non-matches get <span>.

4

Define traverse for the tree

String → highlightText. React element → cloneElement with traversed children. Else → return as-is.

5

Return the traversed children

Wrap traverse(children) in a fragment and return it.

07

Full Implementation

Here's the complete <HighlightSearchText> component. Study how the recursive traversal, regex building, and highlighting compose together.

HighlightSearchText componenttypescript
import React from "react";

type HighlightSearchTextProps = {
  query?: string;
  children: React.ReactNode;
};

export function HighlightSearchText({
  query,
  children,
}: HighlightSearchTextProps) {
  // 1. Early return — no query, no work
  if (!query || !children) return <>{children}</>;

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

  const words = query
    .split(/\s+/)
    .filter(Boolean)
    .map(escapeRegex);

  const regex = new RegExp(`(${words.join("|")})`, "gi");

  // 3. Highlight a single text string
  const highlightText = (text: string) => {
    const parts = text.split(regex);
    return parts.map((part, index) =>
      regex.test(part) ? (
        <mark
          key={index}
          className="font-semibold bg-yellow-200"
        >
          {part}
        </mark>
      ) : (
        <span key={index}>{part}</span>
      )
    );
  };

  // 4. Recursively traverse the React tree
  const traverse = (node: React.ReactNode): React.ReactNode => {
    if (typeof node === "string") {
      return highlightText(node);
    }

    if (React.isValidElement(node)) {
      const element = node as React.ReactElement<{
        children?: React.ReactNode;
      }>;
      return React.cloneElement(element, {
        children: React.Children.map(
          element.props.children,
          (child) => traverse(child)
        ),
      });
    }

    return node;
  };

  // 5. Return traversed tree
  return <>{traverse(children)}</>;
}
Usage in the pagetypescript
{filteredList.map((article) => (
  <HighlightSearchText key={article.id} query={searchText}>
    <div className="border rounded-lg p-5">
      <h2 className="text-lg font-semibold">{article.title}</h2>
      <p className="text-sm">{article.body}</p>
    </div>
  </HighlightSearchText>
))}
✓ Done

Recursive tree traversal

Handles any depth of nested JSX. Strings are highlighted, elements are cloned with traversed children, everything else passes through.

✓ Done

Multi-word query support

Query is split by whitespace and joined with | in the regex. Each word is matched independently, giving a more useful search.

✓ 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

Memoization

Wrap the component in React.memo and memoize the regex/traverse with useMemo to avoid re-traversing when unrelated props change.

→ Could add

Context-based highlighting

Instead of passing query as a prop, use React Context so any deeply nested component can access the highlight query without prop drilling.

08

Common Interview Follow-up Questions

After building the component, interviewers explore React internals and edge cases:

Q:What happens if children contain a component (not just HTML elements)?

A: React.cloneElement works on any React element, including custom components. The traverse function will clone the component and recurse into its children prop. However, if the component renders text internally (not via children), the traversal won't reach it. For that, you'd need a Context-based approach.

Q:Why React.cloneElement instead of just modifying props?

A: React elements are immutable — you can't modify their props directly. cloneElement creates a new element with the same type and merged props. It's the only way to 'modify' an existing element's children without re-rendering the parent.

Q:How would you handle the regex.test() lastIndex bug?

A: When using regex.test() with the g flag, the regex remembers its lastIndex and can return alternating true/false. Fix it by resetting regex.lastIndex = 0 before each test, or use a simpler check like part.toLowerCase().includes(word.toLowerCase()) instead of test().

Q:How would you optimize this for large trees?

A: Memoize the component with React.memo so it only re-renders when query or children change. Use useMemo for the regex and traversed result. For very large trees, consider debouncing the query to avoid traversing on every keystroke.

Q:Could you use a Context instead of a prop for the query?

A: Yes — create a HighlightContext with the query value. The HighlightSearchText component reads from context instead of props. This lets you highlight text in deeply nested components without passing query through every level. Useful for large apps.

Q:What about highlighting in input values or contentEditable?

A: This component only works with React's virtual DOM (children). For input values, you'd need a different approach: overlay a transparent div with highlighted text on top of the input, or use contentEditable with dangerouslySetInnerHTML (not recommended). Libraries like react-highlight-words handle this.

Q:How does this compare to using dangerouslySetInnerHTML?

A: dangerouslySetInnerHTML replaces text with HTML strings containing <mark> tags. It's simpler but has XSS risks if the query isn't sanitized, and it destroys React's virtual DOM for that subtree. The cloneElement approach is safer and keeps React in control of the DOM.

Ready to build it yourself?

We've set up the articles and search UI. Build the recursive HighlightSearchText wrapper component from scratch.

Built for developers, by developers. Happy coding! 🚀