@da-chia/react-search-kit
v1.2.0
Published
A headless, type-safe search library for React
Maintainers
Readme
React Search Kit
A powerful, type-safe, headless search library for React applications. Built with TypeScript and designed for flexibility and performance.
✨ Features
- 🎯 Headless-first: Pure logic, no UI opinions
- 🔍 Smart Search: Built-in debouncing and request cancellation
- 💾 Caching: Configurable cache with TTL support
- 📜 History Management: Persistent search history with localStorage
- 💡 Suggestions: Smart suggestions from history and custom APIs
- 🔌 Adapter Pattern: Pluggable storage and cache implementations
- 📦 TypeScript: Fully typed API with excellent IntelliSense
- 🎨 Optional UI: Lightweight, unstyled components
- ⚡ Performance: Optimized for large datasets
- 🧪 Production-Ready: Comprehensive error handling
📦 Installation
npm install @your-scope/react-search-kit
# or
yarn add @your-scope/react-search-kit
# or
pnpm add @your-scope/react-search-kit🚀 Quick Start
import { useSearch } from '@your-scope/react-search-kit';
interface Product {
id: string;
name: string;
price: number;
}
function ProductSearch() {
const { query, setQuery, data, isLoading, isEmpty } = useSearch<Product>({
queryFn: async (query) => {
const response = await fetch(`/api/products?q=${query}`);
return response.json();
},
debounceMs: 300,
});
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search products..."
/>
{isLoading && <div>Loading...</div>}
{isEmpty && <div>No results</div>}
{data.map((product) => (
<div key={product.id}>
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
))}
</div>
);
}📖 Core Concepts
The useSearch Hook
The heart of the library. It manages all search state and provides a clean API:
const search = useSearch<DataType, ParamsType>({
queryFn: (query, params, signal) => Promise<SearchResult<DataType>>,
// ... options
});Search States
The hook follows a finite state machine with these states:
idle- Initial state, no search performedloading- Search in progresssuccess- Search completed with resultsempty- Search completed with no resultserror- Search failed
Query Function
Your queryFn is the bridge between the library and your API:
async function searchAPI(query: string, params?: any, signal?: AbortSignal) {
const response = await fetch(`/api/search?q=${query}`, { signal });
const data = await response.json();
return {
data: data.results, // Array of results
meta: { total: data.total } // Optional metadata
};
}🎨 Using Optional UI Components
import { useSearch } from '@your-scope/react-search-kit';
import {
SearchInput,
SearchResults,
SearchHistory
} from '@your-scope/react-search-kit/ui';
function MySearch() {
const search = useSearch({ queryFn: searchAPI });
return (
<>
<SearchInput
value={search.query}
onChange={search.setQuery}
loading={search.isLoading}
clearable
/>
<SearchResults
state={search.state}
data={search.data}
error={search.error}
renderItem={(item) => <div>{item.title}</div>}
/>
<SearchHistory
history={search.history}
onSelect={(item) => search.search(item.query)}
onDelete={search.deleteHistoryItem}
onClear={search.clearHistory}
/>
</>
);
}⚙️ Configuration
Full Options Reference
useSearch<TData, TParams>({
// Required: Your search function
queryFn: SearchQueryFn<TData, TParams>,
// Debounce delay in milliseconds
debounceMs: 300,
// Additional parameters to pass to queryFn
params: { category: 'books' },
// Minimum query length to trigger search
minQueryLength: 2,
// Initial search query
initialQuery: '',
// Cache configuration
cache: {
adapter: new MemoryCacheAdapter(100),
ttl: 5 * 60 * 1000, // 5 minutes
key: 'my-search-cache',
},
// History configuration
history: {
enabled: true,
maxItems: 50,
storage: new LocalStorageAdapter(),
key: 'search-history',
},
// Suggestions configuration
suggestions: {
enabled: true,
minQueryLength: 2,
maxSuggestions: 5,
fetchFn: async (query) => {
const res = await fetch(`/api/suggestions?q=${query}`);
return res.json();
},
},
// Lifecycle callbacks
onSuccess: (data, query) => console.log('Success!'),
onError: (error, query) => console.error('Error!'),
onEmpty: (query) => console.log('No results'),
});🔌 Adapters
Built-in Storage Adapters
import {
LocalStorageAdapter, // Persistent browser storage
SessionStorageAdapter, // Session-only storage
MemoryStorageAdapter, // In-memory only (SSR-safe)
NoopStorageAdapter, // Disables storage
} from '@your-scope/react-search-kit';Built-in Cache Adapters
import {
MemoryCacheAdapter, // In-memory with LRU eviction
LocalStorageCacheAdapter, // Persistent cache
NoopCacheAdapter, // Disables caching
} from '@your-scope/react-search-kit';Custom Adapters
Implement the StorageAdapter or CacheAdapter interface:
import { StorageAdapter } from '@your-scope/react-search-kit';
class CustomStorageAdapter implements StorageAdapter {
async getItem(key: string): Promise<string | null> {
// Your implementation
}
async setItem(key: string, value: string): Promise<void> {
// Your implementation
}
async removeItem(key: string): Promise<void> {
// Your implementation
}
async clear(): Promise<void> {
// Your implementation
}
}🎯 Advanced Usage
Search with Parameters
const search = useSearch<Product, { category: string }>({
queryFn: async (query, params) => {
const url = `/api/products?q=${query}&category=${params?.category}`;
const res = await fetch(url);
return res.json();
},
params: { category: 'electronics' },
});
// Update params dynamically
// Note: You'll need to create a new search instance with new paramsManual Search Control
const search = useSearch({ queryFn: searchAPI, debounceMs: 0 });
// Immediate search (bypasses debounce)
search.search('laptop');
// Cancel ongoing request
search.cancel();
// Reset to initial state
search.reset();Working with Suggestions
const search = useSearch({
queryFn: searchAPI,
suggestions: {
enabled: true,
fetchFn: async (query) => {
// Fetch from your suggestions API
return [
{ id: '1', text: 'Suggestion 1', source: 'api' },
{ id: '2', text: 'Suggestion 2', source: 'api' },
];
},
},
});
// Get suggestions for current query
search.getSuggestions(search.query);
// Suggestions are available at:
search.suggestions; // SearchSuggestion[]Cache Management
const search = useSearch({
queryFn: searchAPI,
cache: {
adapter: new MemoryCacheAdapter(100),
ttl: 10 * 60 * 1000, // 10 minutes
},
});
// Clear cache programmatically
search.clearCache();🏗️ Architecture
Separation of Concerns
┌─────────────────────────────────────┐
│ Your React App │
│ (uses useSearch hook & optionally │
│ UI components) │
└─────────────────┬───────────────────┘
│
┌─────────────────▼───────────────────┐
│ React Layer (Hooks) │
│ • useSearch │
│ • SearchProvider (optional) │
└─────────────────┬───────────────────┘
│
┌─────────────────▼───────────────────┐
│ Core Logic │
│ • State management │
│ • Debouncing │
│ • Request cancellation │
│ • History management │
└─────────────────┬───────────────────┘
│
┌─────────────────▼───────────────────┐
│ Adapter Layer │
│ • Storage adapters │
│ • Cache adapters │
│ • Pluggable implementations │
└─────────────────────────────────────┘Design Patterns Used
- Adapter Pattern: Pluggable storage and cache
- State Machine: Clear state transitions
- Dependency Injection: User-provided query functions
- Render Props: Flexible UI components
- Provider Pattern: Optional global configuration
🎨 Styling
The library is completely unstyled by design. You have full control:
// Option 1: Inline styles
<SearchInput
value={query}
onChange={setQuery}
style={{ padding: '12px', fontSize: '16px' }}
/>
// Option 2: CSS classes
<SearchInput
value={query}
onChange={setQuery}
className="my-search-input"
/>
// Option 3: CSS-in-JS
const StyledInput = styled(SearchInput)`
padding: 12px;
border-radius: 8px;
`;
// Option 4: Build your own component using the hook
function MySearchInput() {
const search = useSearch({ queryFn });
return <input {...yourFullControl} />;
}📊 TypeScript Support
Fully typed with generics:
interface Product {
id: string;
name: string;
price: number;
}
interface SearchParams {
category: string;
minPrice?: number;
}
const search = useSearch<Product, SearchParams>({
queryFn: async (query, params) => {
// params is typed as SearchParams
// Return type is enforced as SearchResult<Product>
return {
data: [] as Product[],
meta: { total: 0 },
};
},
params: {
category: 'electronics',
// TypeScript will catch invalid params
},
});
// search.data is typed as Product[]
search.data.forEach((product: Product) => {
console.log(product.name);
});🧪 Testing
The library is designed to be testable:
import { renderHook, act } from '@testing-library/react';
import { useSearch } from '@your-scope/react-search-kit';
test('search returns results', async () => {
const mockQueryFn = jest.fn().mockResolvedValue({
data: [{ id: '1', name: 'Test' }],
});
const { result } = renderHook(() =>
useSearch({ queryFn: mockQueryFn })
);
act(() => {
result.current.setQuery('test');
});
// Wait for debounce and async operation
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toHaveLength(1);
});🔍 Examples
See the /examples directory for complete examples:
- basic-example.tsx - Simple search implementation
- advanced-example.tsx - Full-featured with all options
- custom-adapter-example.tsx - Custom storage/cache adapters
🤝 Contributing
Contributions are welcome! This library follows these principles:
- Headless-first: No UI opinions in core
- Type-safe: Full TypeScript support
- Flexible: Adapter patterns for extensibility
- Production-ready: Error handling and edge cases
📄 License
MIT
🙏 Inspiration
This library draws inspiration from:
- react-query - Async state management patterns
- downshift - Headless component design
- headless UI - Unstyled, accessible components
Built with ❤️ for the React community
