Building a Text Replace Tool: Regex and String Processing#

Recently, I needed to implement a batch replace feature supporting both plain text and regex modes. Seemed simple, but I ran into some interesting gotchas. Here’s what I learned.

Core Requirements#

A text replace tool typically needs:

  1. Plain text replace: Direct string search and replace
  2. Regex replace: Pattern matching with regular expressions
  3. Case sensitivity: Respect or ignore case
  4. Replace all vs. first: Replace all matches or just the first one

The Plain Text Trap#

The simplest approach is String.prototype.replace():

const result = input.replace(searchText, replaceText)

But this only replaces the first match. To replace all, many reach for regex:

const regex = new RegExp(searchText, 'g')
const result = input.replace(regex, replaceText)

Here’s the trap: If searchText contains regex special characters (., *, ?, $, etc.), it breaks or matches incorrectly.

The Fix: Escape Special Characters#

function escapeRegExp(string) {
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

const escaped = escapeRegExp(searchText)
const regex = new RegExp(escaped, 'gi')
const result = input.replace(regex, replaceText)

\\$& means “prepend a backslash to the entire matched string.”

Implementing Case Sensitivity#

For case-insensitive matching, don’t just use toLowerCase()—you’ll lose the original formatting.

Approach 1: Regex i Flag#

const flags = caseSensitive ? 'g' : 'gi'
const regex = new RegExp(escaped, flags)
const result = input.replace(regex, replaceText)

The regex engine preserves the original case automatically.

Approach 2: Without Regex#

If you want to avoid regex for plain text:

if (caseSensitive) {
  result = input.split(searchText).join(replaceText)
} else {
  // Still need regex for case-insensitive
  const regex = new RegExp(escaped, 'gi')
  result = input.replace(regex, replaceText)
}

Regex Mode#

When the user chooses regex mode, construct the RegExp directly:

const flags = caseSensitive ? 'g' : 'gi'
const regex = new RegExp(searchText, flags)
const result = input.replace(regex, replaceText)

Handle errors gracefully:

try {
  const regex = new RegExp(searchText, flags)
  // execute replace
} catch (error) {
  // Invalid regex, notify user
  console.error('Invalid regex:', error.message)
}

Common errors: unclosed brackets, invalid quantifiers, invalid escape sequences.

Replacing Only the First Match#

replace() defaults to first-match-only (for non-regex or regex without g flag). But with the g flag, it replaces all.

Solution:

// No g flag
const flags = caseSensitive ? '' : 'i'
const regex = new RegExp(searchText, flags)
const result = input.replace(regex, replaceText)

Or more explicit control:

if (replaceAll) {
  const flags = caseSensitive ? 'g' : 'gi'
  const regex = new RegExp(searchText, flags)
  result = input.replace(regex, replaceText)
} else {
  const flags = caseSensitive ? '' : 'i'
  const regex = new RegExp(searchText, flags)
  result = input.replace(regex, replaceText)
}

Counting Matches#

Showing match count is helpful for users:

function countMatches(input, searchText, useRegex, caseSensitive) {
  if (!searchText) return 0

  if (useRegex) {
    const flags = caseSensitive ? 'g' : 'gi'
    const regex = new RegExp(searchText, flags)
    return (input.match(regex) || []).length
  } else {
    const escaped = escapeRegExp(searchText)
    const flags = caseSensitive ? 'g' : 'gi'
    const regex = new RegExp(escaped, flags)
    return (input.match(regex) || []).length
  }
}

Note: String.prototype.match() returns null, so default to an empty array.

Complete Implementation#

interface ReplaceOptions {
  input: string
  searchText: string
  replaceText: string
  useRegex: boolean
  caseSensitive: boolean
  replaceAll: boolean
}

function replaceText(options: ReplaceOptions): { result: string; count: number } {
  const { input, searchText, replaceText, useRegex, caseSensitive, replaceAll } = options

  if (!input || !searchText) {
    return { result: input, count: 0 }
  }

  try {
    const gFlag = replaceAll ? 'g' : ''
    const iFlag = caseSensitive ? '' : 'i'
    const flags = gFlag + iFlag

    const pattern = useRegex ? searchText : escapeRegExp(searchText)
    const regex = new RegExp(pattern, flags)

    const count = (input.match(new RegExp(pattern, 'g' + iFlag)) || []).length
    const result = input.replace(regex, replaceText)

    return { result, count }
  } catch (error) {
    return { result: input, count: 0 }
  }
}

function escapeRegExp(string: string): string {
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}

Performance Optimization#

For large text, recalculating on every keystroke is slow. Use useMemo to cache results:

const result = useMemo(() => {
  return replaceText({ input, searchText, replaceText, useRegex, caseSensitive, replaceAll })
}, [input, searchText, replaceText, useRegex, caseSensitive, replaceAll])

Or debounce:

const debouncedReplace = useMemo(
  () => debounce((value: string) => {
    // execute replace logic
  }, 300),
  []
)

Edge Cases#

Watch out for:

  1. Empty search text: Return original text
  2. Empty replacement: Equivalent to deleting matches
  3. Capture group references: $1, $2 backreferences in regex
  4. Special replacement patterns: $& (match), $\`` (before match), $’` (after match)
  5. Cross-platform newlines: \r\n vs \n

The Result#

Based on these ideas, I built: Text Replace Tool

Features:

  • Plain text and regex modes
  • Case sensitivity toggle
  • Replace all or first match
  • Real-time match count
  • Friendly error messages

The implementation isn’t complex, but getting the details right takes effort. Hope this helps.


Related: Regex Tester | String Escape Tool