Using Fuse.js with React
Published April 2026Fuse.js pairs naturally with React. Both are client-side and dependency-free in philosophy. This guide walks through building a practical search-as-you-type component, then layers on debouncing, match highlighting, and techniques for handling large datasets.
Setup
Install both packages:
npm install fuse.js
All examples below assume React 18+ with hooks.
Basic Search Component
Start with the simplest useful thing: a text input that filters a list using Fuse.js.
import { useMemo, useState } from 'react'
import Fuse from 'fuse.js'
const books = [
{ title: 'The Great Gatsby', author: 'F. Scott Fitzgerald' },
{ title: 'To Kill a Mockingbird', author: 'Harper Lee' },
{ title: 'One Hundred Years of Solitude', author: 'Gabriel Garcia Marquez' },
{ title: 'The Catcher in the Rye', author: 'J.D. Salinger' },
{ title: 'Brave New World', author: 'Aldous Huxley' },
]
function BookSearch() {
const [query, setQuery] = useState('')
// Create the Fuse instance once — the index is built at construction time
const fuse = useMemo(() => {
return new Fuse(books, {
keys: ['title', 'author'], // fields to search
threshold: 0.4, // 0 = exact, 1 = match anything
})
}, [])
const results = query ? fuse.search(query) : []
// Show search results when there's a query, full list otherwise
const displayItems = results.length > 0
? results.map(({ item }) => item)
: books
return (
<div>
<input
type="text"
placeholder="Search books..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<ul>
{displayItems.map((book) => (
<li key={book.title}>
{book.title} — {book.author}
</li>
))}
</ul>
</div>
)
}
Key points:
useMemoensures the Fuse instance is created once, not on every render. The index is built at construction time, so recreating it is wasteful.- Fallback to full list when the query is empty. This is a UI choice -- when there's no query, show everything instead of search results.
threshold: 0.4is stricter than the default0.6, which tends to feel too loose for search-as-you-type UIs.
Debouncing the Search
For small lists, searching on every keystroke is fine. For larger lists or if you're doing additional work per search (like analytics), debounce the input:
import { useEffect, useMemo, useState } from 'react'
import Fuse from 'fuse.js'
// Delays updating the value until the user stops typing
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(timer) // reset timer on each keystroke
}, [value, delay])
return debounced
}
function BookSearch({ books }) {
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 200) // wait 200ms after last keystroke
// Rebuild the Fuse instance when the book list changes
const fuse = useMemo(() => {
return new Fuse(books, {
keys: ['title', 'author'],
threshold: 0.4,
})
}, [books])
// Search only fires after debounce settles
const results = debouncedQuery ? fuse.search(debouncedQuery) : []
const displayItems = results.length > 0
? results.map(({ item }) => item)
: books
return (
<div>
<input
type="text"
placeholder="Search books..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<ul>
{displayItems.map((book) => (
<li key={book.title}>
{book.title} — {book.author}
</li>
))}
</ul>
</div>
)
}
200ms is a good default. Users won't notice the delay, but it eliminates redundant searches during fast typing.
Highlighting Matches
Fuse.js can return the exact character ranges that matched. Enable includeMatches and use the indices to wrap matched characters in a <mark> tag:
import { useMemo, useState } from 'react'
import Fuse from 'fuse.js'
// Splits text into plain strings and <mark> elements based on match regions
function highlightMatches(text, regions = []) {
if (!regions.length) return text
const chunks = []
let lastIndex = 0
// Fuse.js returns sorted, non-overlapping [start, end] pairs
for (const [start, end] of regions) {
// Add any unmatched text before this region
if (start > lastIndex) {
chunks.push(text.slice(lastIndex, start))
}
// Wrap the matched range in a <mark> tag
chunks.push(<mark key={start}>{text.slice(start, end + 1)}</mark>)
lastIndex = end + 1
}
// Add any remaining text after the last match
if (lastIndex < text.length) {
chunks.push(text.slice(lastIndex))
}
return chunks
}
function BookSearch({ books }) {
const [query, setQuery] = useState('')
const fuse = useMemo(() => {
return new Fuse(books, {
keys: ['title', 'author'],
includeMatches: true, // return character-level match positions
threshold: 0.4,
})
}, [books])
const results = query ? fuse.search(query) : []
return (
<div>
<input
type="text"
placeholder="Search books..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<ul>
{results.map(({ item, matches }) => {
// Find match data for each field we want to highlight
const titleMatch = matches?.find((m) => m.key === 'title')
const authorMatch = matches?.find((m) => m.key === 'author')
return (
<li key={item.title}>
{highlightMatches(item.title, titleMatch?.indices)} —{' '}
{highlightMatches(item.author, authorMatch?.indices)}
</li>
)
})}
</ul>
</div>
)
}
The matches array contains one entry per matched key, each with an indices array of [start, end] pairs. The end index is inclusive, so use end + 1 when slicing.
Style the highlights with CSS:
mark {
background-color: #fef08a;
padding: 0;
}
Handling Large Lists
Fuse.js indexes data in memory, so the search itself is fast. The bottleneck with large lists is usually React rendering thousands of DOM nodes. Two strategies help:
1. Limit Displayed Results
The simplest approach. Fuse.js results are already sorted by relevance, so taking the top N gives users the best matches:
const results = query ? fuse.search(query, { limit: 20 }) : []
Fuse.js still scans all indexed records, but limit reduces the number of results returned and the sorting overhead. For the biggest gains, combine it with the strategies below.
2. Memoize the Fuse Index
If your data changes frequently and the list is large, you can pre-build and cache the index:
import { useMemo } from 'react'
import Fuse from 'fuse.js'
function useSearch(items, keys, query) {
const fuse = useMemo(() => {
// Pre-build the index so Fuse doesn't rebuild it on every new instance
const index = Fuse.createIndex(keys, items)
// pass pre-built index
return new Fuse(items, { keys, threshold: 0.4 }, index)
}, [items, keys])
return query ? fuse.search(query) : []
}
Fuse.createIndex() builds the index separately, which is useful if you need to serialize it or reuse it across multiple Fuse instances.
3. Virtualized Rendering
For datasets where you want to display many results, pair Fuse.js with a virtualization library like react-window:
npm install react-window
import { FixedSizeList } from 'react-window'
function SearchResults({ results }) {
// Only the visible rows are rendered — the rest are virtualized
const Row = ({ index, style }) => (
<div style={style}>
{results[index].item.title}
</div>
)
return (
<FixedSizeList
height={400}
itemCount={results.length}
itemSize={40}
width="100%"
>
{Row}
</FixedSizeList>
)
}
This renders only the visible rows, keeping DOM size constant regardless of result count.
Putting It All Together
Here's a complete search component combining everything above:
import { useEffect, useMemo, useState } from 'react'
import Fuse from 'fuse.js'
function useDebounce(value, delay) {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debounced
}
function highlightMatches(text, regions = []) {
if (!regions.length) return text
const chunks = []
let lastIndex = 0
for (const [start, end] of regions) {
if (start > lastIndex) chunks.push(text.slice(lastIndex, start))
chunks.push(<mark key={start}>{text.slice(start, end + 1)}</mark>)
lastIndex = end + 1
}
if (lastIndex < text.length) chunks.push(text.slice(lastIndex))
return chunks
}
function FuzzySearch({ items, keys, itemKey, placeholder = 'Search...' }) {
const [query, setQuery] = useState('')
const debouncedQuery = useDebounce(query, 200)
// Recreate Fuse only when items or keys change
const fuse = useMemo(
() => new Fuse(items, { keys, includeMatches: true, threshold: 0.4 }),
[items, keys]
)
// Cap results to keep rendering fast
const results = debouncedQuery
? fuse.search(debouncedQuery, { limit: 50 })
: []
return (
<div>
<input
type="text"
placeholder={placeholder}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<ul>
{results.length > 0
? results.map(({ item, matches }) => (
<li key={item[itemKey]}>
{keys.map((key) => {
const match = matches?.find((m) => m.key === key)
return (
<span key={key}>
{highlightMatches(item[key], match?.indices)}{' '}
</span>
)
})}
</li>
))
: items.slice(0, 50).map((item) => (
<li key={item[itemKey]}>
{keys.map((key) => (
<span key={key}>{item[key]} </span>
))}
</li>
))}
</ul>
</div>
)
}
Usage:
<FuzzySearch
items={products}
keys={['name', 'description']}
itemKey="id"
placeholder="Search products..."
/>
Tips
- Set
thresholdlower for search-as-you-type. The default0.6returns too many loose matches when users are typing partial words. Start with0.3-0.4and adjust. - Use
includeScoreduring development to see how tight your matches are. Remove it in production if you don't need it. - Don't recreate the Fuse instance on every render. Always wrap it in
useMemowith appropriate dependencies. - For nested keys, use dot notation:
keys: ['address.city']. - Weight your keys to prioritize certain fields:
keys: [{ name: 'title', weight: 2 }, { name: 'description', weight: 1 }].
See Getting Started for installation options (CDN, ES modules, CommonJS) and Fuzzy Search to understand how scoring and thresholds work.
