Détail du package

@jungle-commerce/typesense-react

jungle-commerce1.8kMIT2.0.3

React hooks and components for building search interfaces with Typesense

typesense, react, ui, typesense-react

readme

@jungle-commerce/typesense-react

A powerful, headless React library for building search experiences with Typesense. This package provides comprehensive search state management, advanced filtering, multi-collection search, and automatic schema discovery - all without UI dependencies.

Features

  • 🚀 React Hooks - Simple hooks like useSearch and useAdvancedFacets for instant search
  • 🔧 TypeScript First - Full type safety and IntelliSense support
  • Performance Optimized - Request debouncing, caching, and smart re-rendering
  • 🎨 UI Agnostic - Works with any React UI library (Material-UI, Ant Design, etc.)
  • 🔍 Advanced Search - Filtering, faceting, sorting, and geo-search out of the box
  • 📱 Multi-Collection - Search across multiple collections simultaneously
  • 🤖 AI Ready - Claude MCP integration for AI-powered search assistance
  • 🧠 Schema Discovery - Automatically configure search based on your data

Documentation

📚 Full Documentation - Comprehensive guides, API reference, and examples

Quick Links

Additional Documentation

Installation

npm install @jungle-commerce/typesense-react typesense

Quick Start Examples

Connecting to Typesense

import { TypesenseConfig } from '@jungle-commerce/typesense-react';

const typesenseConfig: TypesenseConfig = {
  nodes: [{
    host: 'localhost',
    port: 8108,
    protocol: 'http'
  }],
  apiKey: 'your-search-api-key',
  connectionTimeoutSeconds: 2,
  cacheSearchResultsForSeconds: 60 // 1 minute cache
};

Retrieving a Schema

import { TypesenseSearchClient } from '@jungle-commerce/typesense-react';

const client = new TypesenseSearchClient(typesenseConfig);
const schema = await client.retrieveSchema('products');
console.log(schema);

Static/Fixed Configuration Examples

Simple Query

import React from 'react';
import { SearchProvider, useSearch } from '@jungle-commerce/typesense-react';

function App() {
  return (
    <SearchProvider 
      config={typesenseConfig} 
      collection="products"
      searchOnMount={true}
    >
      <SearchInterface />
    </SearchProvider>
  );
}

function SearchInterface() {
  const { state, actions } = useSearch();

  return (
    <div>
      <input
        value={state.query}
        onChange={(e) => actions.setQuery(e.target.value)}
        placeholder="Search products..."
      />

      {state.loading && <p>Searching...</p>}

      {state.results?.hits.map(hit => (
        <div key={hit.document.id}>
          <h3>{hit.document.name}</h3>
          <p>{hit.document.description}</p>
        </div>
      ))}
    </div>
  );
}

Filtered Query

import { SearchProvider, useSearch, useAdvancedFacets } from '@jungle-commerce/typesense-react';

function App() {
  const facetConfig = [
    {
      field: 'category',
      label: 'Category',
      type: 'checkbox',
      disjunctive: true // Allow multiple selections with OR logic
    },
    {
      field: 'price',
      label: 'Price Range',
      type: 'numeric',
      numericDisplay: 'range'
    }
  ];

  return (
    <SearchProvider 
      config={typesenseConfig} 
      collection="products"
      facets={facetConfig}
      searchOnMount={true}
    >
      <FilteredSearch />
    </SearchProvider>
  );
}

function FilteredSearch() {
  const { state } = useSearch();
  const facets = useAdvancedFacets();

  return (
    <div>
      {/* Category Filter */}
      <div>
        <h4>Categories</h4>
        {state.results?.facet_counts?.find(f => f.field_name === 'category')?.counts.map(item => (
          <label key={item.value}>
            <input
              type="checkbox"
              checked={facets.disjunctiveFacets.category?.includes(item.value)}
              onChange={() => facets.actions.toggleFacetValue('category', item.value)}
            />
            {item.value} ({item.count})
          </label>
        ))}
      </div>

      {/* Price Range Filter */}
      <div>
        <h4>Price Range</h4>
        <input
          type="range"
          min="0"
          max="1000"
          value={facets.numericFilters.price?.min || 0}
          onChange={(e) => facets.actions.setNumericFilter('price', 
            Number(e.target.value), 
            facets.numericFilters.price?.max || 1000
          )}
        />
      </div>

      {/* Results */}
      <div>
        {state.results?.hits.map(hit => (
          <div key={hit.document.id}>
            <h3>{hit.document.name}</h3>
            <p>Category: {hit.document.category}</p>
            <p>Price: ${hit.document.price}</p>
          </div>
        ))}
      </div>
    </div>
  );
}

Query with Sorting

function SortedSearch() {
  const { state, actions } = useSearch();

  return (
    <div>
      <select 
        value={state.sortBy} 
        onChange={(e) => actions.setSortBy(e.target.value)}
      >
        <option value="">Relevance</option>
        <option value="price:asc">Price: Low to High</option>
        <option value="price:desc">Price: High to Low</option>
        <option value="created_at:desc">Newest First</option>
      </select>

      {/* Results sorted according to selection */}
      {state.results?.hits.map(hit => (
        <div key={hit.document.id}>{hit.document.name}</div>
      ))}
    </div>
  );
}

Query with Faceting

function FacetedSearch() {
  const facetConfig = [
    { field: 'brand', label: 'Brand', type: 'checkbox', disjunctive: true },
    { field: 'color', label: 'Color', type: 'select' },
    { field: 'in_stock', label: 'Availability', type: 'checkbox' }
  ];

  return (
    <SearchProvider
      config={typesenseConfig}
      collection="products"
      facets={facetConfig}
      searchOnMount={true}
      enableDisjunctiveFacetQueries={true} // Enable OR logic for disjunctive facets
    >
      <FacetInterface />
    </SearchProvider>
  );
}

function FacetInterface() {
  const { state } = useSearch();
  const facets = useAdvancedFacets();

  return (
    <div>
      {state.facets.map(facetConfig => {
        const facetResults = state.results?.facet_counts?.find(
          f => f.field_name === facetConfig.field
        );

        if (facetConfig.type === 'select') {
          return (
            <select
              key={facetConfig.field}
              value={facets.selectiveFilters[facetConfig.field] || ''}
              onChange={(e) => facets.actions.setSelectiveFilter(
                facetConfig.field, 
                e.target.value
              )}
            >
              <option value="">All {facetConfig.label}</option>
              {facetResults?.counts.map(item => (
                <option key={item.value} value={item.value}>
                  {item.value} ({item.count})
                </option>
              ))}
            </select>
          );
        }

        return (
          <div key={facetConfig.field}>
            <h4>{facetConfig.label}</h4>
            {facetResults?.counts.map(item => (
              <label key={item.value}>
                <input
                  type="checkbox"
                  checked={facets.disjunctiveFacets[facetConfig.field]?.includes(item.value)}
                  onChange={() => facets.actions.toggleFacetValue(facetConfig.field, item.value)}
                />
                {item.value} ({item.count})
              </label>
            ))}
          </div>
        );
      })}
    </div>
  );
}

Query Utilizing All Functionality

function AdvancedSearch() {
  const facetConfig = [
    { field: 'category', label: 'Category', type: 'checkbox', disjunctive: true },
    { field: 'price', label: 'Price', type: 'numeric', numericDisplay: 'range' },
    { field: 'rating', label: 'Rating', type: 'numeric', numericDisplay: 'checkbox' },
    { field: 'release_date', label: 'Release Date', type: 'date' },
    { field: 'status', label: 'Status', type: 'select' }
  ];

  return (
    <SearchProvider
      config={typesenseConfig}
      collection="products"
      facets={facetConfig}
      searchOnMount={true}
      accumulateFacets={true} // Remember facet values across searches
      initialState={{
        perPage: 24,
        sortBy: 'popularity:desc',
        additionalFilters: 'in_stock:true', // Native Typesense filter
        multiSortBy: [ // Multi-field sorting
          { field: 'popularity', order: 'desc' },
          { field: 'price', order: 'asc' }
        ]
      }}
    >
      <FullFeaturedSearch />
    </SearchProvider>
  );
}

function FullFeaturedSearch() {
  const { state, actions } = useSearch();
  const facets = useAdvancedFacets();

  // Calculate derived values
  const totalResults = state.results?.found || 0;
  const totalPages = Math.ceil(totalResults / state.perPage);
  const hasNextPage = state.page < totalPages;
  const hasPreviousPage = state.page > 1;

  return (
    <div>
      {/* Search Input */}
      <input
        value={state.query}
        onChange={(e) => actions.setQuery(e.target.value)}
        placeholder="Search..."
      />

      {/* Additional Filters */}
      <button onClick={() => actions.setAdditionalFilters('featured:true && discount:>0')}>
        Show Featured & Discounted
      </button>

      {/* Multi-field Sort */}
      <button onClick={() => actions.setMultiSortBy([
        { field: 'rating', order: 'desc' },
        { field: 'price', order: 'asc' },
        { field: 'name', order: 'asc' }
      ])}>
        Sort by Rating, then Price, then Name
      </button>

      {/* All Facets */}
      <div>
        <h3>Filters ({facets.activeFilterCount} active)</h3>

        {/* Render all configured facets */}
        {/* ... facet rendering code ... */}

        <button onClick={() => facets.actions.clearAllFilters()}>
          Clear All Filters
        </button>
      </div>

      {/* Results */}
      <div>
        <p>Found {totalResults} results (Page {state.page} of {totalPages})</p>

        {state.results?.hits.map(hit => (
          <div key={hit.document.id}>
            {/* Highlighted fields */}
            <h3 dangerouslySetInnerHTML={{ 
              __html: hit.highlight?.name?.snippet || hit.document.name 
            }} />
          </div>
        ))}

        {/* Pagination */}
        <button 
          onClick={() => actions.setPage(state.page - 1)}
          disabled={!hasPreviousPage}
        >
          Previous
        </button>
        <button 
          onClick={() => actions.setPage(state.page + 1)}
          disabled={!hasNextPage}
        >
          Next
        </button>
      </div>
    </div>
  );
}

Schema Discovery Examples

Discovery with Specific Field Names

import { useSchemaDiscovery, SearchProvider } from '@jungle-commerce/typesense-react';

function SchemaBasedSearch() {
  const { schema, facetConfigs, searchableFields, sortableFields } = useSchemaDiscovery({
    // Exclude certain fields from faceting
    excludeFields: ['internal_id', 'created_by'],
    maxFacets: 10,
    includeNumericFacets: true,
    includeDateFacets: true,
    onSchemaLoad: (schema) => {
      console.log('Schema loaded:', schema);
    }
  });

  if (!schema) return <div>Loading schema...</div>;

  return (
    <SearchProvider
      config={typesenseConfig}
      collection="products"
      facets={facetConfigs} // Auto-generated facet config
      initialSearchParams={{
        query_by: searchableFields.join(','), // Auto-detected searchable fields
        sort_by: sortableFields[0]?.field // First sortable field
      }}
    >
      <SearchInterface />
    </SearchProvider>
  );
}

Discovery with Pattern Matching

function PatternBasedDiscovery() {
  const { schema, facetConfigs } = useSchemaDiscovery({
    // Exclude fields by pattern
    patterns: {
      excludePatterns: [
        { pattern: 'internal', matchType: 'contains' },
        { pattern: '_id', matchType: 'endsWith' }
      ]
    },
    // Other configuration
    maxFacets: 8,
    includeNumericFacets: true,
    // Override specific field types
    facetOverrides: {
      price: { type: 'numeric', numericDisplay: 'range' },
      status: { type: 'select' },
      created_at: { type: 'date' }
    }
  });

  return (
    <SearchProvider
      config={typesenseConfig}
      collection="products"
      facets={facetConfigs}
    >
      <SearchInterface />
    </SearchProvider>
  );
}

Dynamic Queries with Unknown Fields

Simple Dynamic Query

function DynamicSearch() {
  const { schema, facetConfigs, searchableFields } = useSchemaDiscovery();

  if (!schema) return <div>Discovering schema...</div>;

  return (
    <SearchProvider
      config={typesenseConfig}
      collection="products"
      facets={facetConfigs}
      initialSearchParams={{
        query_by: searchableFields.join(',') || '*'
      }}
      searchOnMount={true}
    >
      <GenericSearchInterface />
    </SearchProvider>
  );
}

function GenericSearchInterface() {
  const { state, actions } = useSearch();

  return (
    <div>
      <input
        value={state.query}
        onChange={(e) => actions.setQuery(e.target.value)}
        placeholder={`Search in ${state.schema?.fields.length} fields...`}
      />

      {state.results?.hits.map(hit => (
        <div key={hit.document.id}>
          {/* Dynamically render all fields */}
          {Object.entries(hit.document).map(([key, value]) => (
            <p key={key}><strong>{key}:</strong> {JSON.stringify(value)}</p>
          ))}
        </div>
      ))}
    </div>
  );
}

Dynamic Filtered Query

function DynamicFilteredSearch() {
  const { schema, facetConfigs } = useSchemaDiscovery({
    // Auto-detect facetable fields
    maxFacets: 15,
    includeNumericFacets: true,
    includeDateFacets: true
  });

  return (
    <SearchProvider
      config={typesenseConfig}
      collection="products"
      facets={facetConfigs}
    >
      <DynamicFilters />
    </SearchProvider>
  );
}

function DynamicFilters() {
  const { state } = useSearch();
  const facets = useAdvancedFacets();

  return (
    <div>
      {state.facets.map(facetConfig => {
        const facetResult = state.results?.facet_counts?.find(
          f => f.field_name === facetConfig.field
        );

        // Render appropriate UI based on facet type
        switch (facetConfig.type) {
          case 'numeric':
            return <NumericFacet key={facetConfig.field} config={facetConfig} />;
          case 'date':
            return <DateFacet key={facetConfig.field} config={facetConfig} />;
          case 'select':
            return <SelectFacet key={facetConfig.field} config={facetConfig} />;
          default:
            return <CheckboxFacet key={facetConfig.field} config={facetConfig} />;
        }
      })}
    </div>
  );
}

Dynamic Sorting

function DynamicSortedSearch() {
  const { schema, sortableFields } = useSchemaDiscovery({
    collection: 'products',
    enabled: true
  });

  return (
    <SearchProvider
      config={typesenseConfig}
      collection="products"
      initialState={{
        sortBy: sortableFields[0]?.field ? `${sortableFields[0].field}:desc` : ''
      }}
    >
      <DynamicSortInterface sortableFields={sortableFields} />
    </SearchProvider>
  );
}

function DynamicSortInterface({ sortableFields }) {
  const { state, actions } = useSearch();

  return (
    <div>
      <select value={state.sortBy} onChange={(e) => actions.setSortBy(e.target.value)}>
        <option value="">Relevance</option>
        {sortableFields.map(field => (
          <>
            <option key={`${field.field}-asc`} value={`${field.field}:asc`}>
              {field.label} (Low to High)
            </option>
            <option key={`${field.field}-desc`} value={`${field.field}:desc`}>
              {field.label} (High to Low)
            </option>
          </>
        ))}
      </select>
    </div>
  );
}

Dynamic Faceting

function DynamicFacetedSearch() {
  const { schema, facetConfigs } = useSchemaDiscovery({
    // Maximum facets to generate
    maxFacets: 10,
    includeNumericFacets: true,
    includeDateFacets: true,
    // Custom type overrides
    facetOverrides: {
      status: { type: 'select' },
      type: { type: 'select' },
      created_at: { type: 'date' },
      updated_at: { type: 'date' }
    }
  });

  return (
    <SearchProvider
      config={typesenseConfig}
      collection="products"
      facets={facetConfigs}
      accumulateFacets={true}
    >
      <AutoFacetInterface />
    </SearchProvider>
  );
}

Dynamic Query with All Features

function FullDynamicSearch() {
  const { schema, facetConfigs, searchableFields, sortableFields } = useSchemaDiscovery({
    maxFacets: 20,
    includeNumericFacets: true,
    includeDateFacets: true
  });

  // Derive field groups from schema
  const fieldGroups = React.useMemo(() => {
    if (!schema?.fields) return { numericFields: [], booleanFields: [] };

    return {
      numericFields: schema.fields
        .filter(f => ['int32', 'int64', 'float'].includes(f.type))
        .map(f => f.name),
      booleanFields: schema.fields
        .filter(f => f.type === 'bool')
        .map(f => f.name)
    };
  }, [schema]);

  return (
    <SearchProvider
      config={typesenseConfig}
      collection="products"
      facets={facetConfigs}
      searchOnMount={true}
      accumulateFacets={true}
      initialSearchParams={{
        query_by: searchableFields.join(','),
        sort_by: sortableFields[0]?.field
      }}
      initialState={{
        // Dynamic multi-sort based on field types
        multiSortBy: [
          // First by any boolean "featured" field
          ...fieldGroups.booleanFields
            .filter(f => f.includes('featured'))
            .map(f => ({ field: f, order: 'desc' as const })),
          // Then by any numeric "score" or "rating" field
          ...fieldGroups.numericFields
            .filter(f => f.includes('score') || f.includes('rating'))
            .map(f => ({ field: f, order: 'desc' as const })),
          // Finally by name
          { field: 'name', order: 'asc' as const }
        ]
      }}
    >
      <DynamicSearchInterface />
    </SearchProvider>
  );
}

Autocomplete Implementation

Simple Autocomplete

function Autocomplete() {
  const [suggestions, setSuggestions] = useState([]);
  const [query, setQuery] = useState('');

  return (
    <SearchProvider
      config={typesenseConfig}
      collection="products"
      searchOnMount={false}
    >
      <AutocompleteInput 
        value={query}
        onChange={setQuery}
        suggestions={suggestions}
        onSuggestionsChange={setSuggestions}
      />
    </SearchProvider>
  );
}

function AutocompleteInput({ value, onChange, suggestions, onSuggestionsChange }) {
  const { state, actions } = useSearch();

  React.useEffect(() => {
    if (state.results && !state.loading) {
      // Extract suggestions from results
      const newSuggestions = state.results.hits.slice(0, 5).map(hit => ({
        id: hit.document.id,
        text: hit.document.name,
        highlight: hit.highlight?.name?.snippet
      }));
      onSuggestionsChange(newSuggestions);
    }
  }, [state.results, state.loading, onSuggestionsChange]);

  React.useEffect(() => {
    if (value.length >= 2) {
      actions.setQuery(value);
    } else {
      onSuggestionsChange([]);
    }
  }, [value, actions, onSuggestionsChange]);

  return (
    <div className="autocomplete">
      <input
        type="text"
        value={value}
        onChange={(e) => onChange(e.target.value)}
        placeholder="Start typing..."
      />

      {suggestions.length > 0 && (
        <div className="suggestions">
          {suggestions.map(suggestion => (
            <div 
              key={suggestion.id}
              onClick={() => onChange(suggestion.text)}
              dangerouslySetInnerHTML={{ 
                __html: suggestion.highlight || suggestion.text 
              }}
            />
          ))}
        </div>
      )}
    </div>
  );
}

Search-as-you-type Implementation

function SearchAsYouType() {
  return (
    <SearchProvider
      config={typesenseConfig}
      collection="products"
      searchOnMount={false}
      initialSearchParams={{
        query_by: 'name,description',
        prefix: true, // Enable prefix search
        num_typos: 2, // Allow typos
        min_len_1typo: 4, // Minimum length for 1 typo
        min_len_2typo: 7  // Minimum length for 2 typos
      }}
    >
      <InstantSearch />
    </SearchProvider>
  );
}

function InstantSearch() {
  const { state, actions } = useSearch();

  return (
    <div>
      <input
        value={state.query}
        onChange={(e) => actions.setQuery(e.target.value)}
        placeholder="Search instantly..."
        autoFocus
      />

      {state.query && (
        <div className="instant-results">
          {state.loading && <div className="typing-indicator">Searching...</div>}

          {!state.loading && state.results?.found === 0 && (
            <div>No results for "{state.query}"</div>
          )}

          {state.results?.hits.map(hit => (
            <div key={hit.document.id} className="instant-result">
              <strong dangerouslySetInnerHTML={{ 
                __html: hit.highlight?.name?.snippet || hit.document.name 
              }} />
              <small dangerouslySetInnerHTML={{ 
                __html: hit.highlight?.description?.snippet || hit.document.description 
              }} />
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Multi-Collection Search

Basic Multi-Collection Search

import { MultiCollectionProvider, useMultiCollectionContext } from '@jungle-commerce/typesense-react';

function MultiSearch() {
  const collections = [
    {
      collection: 'products',
      queryBy: 'name,description,brand',
      weight: 2.0, // Products are twice as important
      maxResults: 20
    },
    {
      collection: 'categories',
      queryBy: 'name,description',
      weight: 1.0,
      maxResults: 5
    },
    {
      collection: 'brands',
      queryBy: 'name',
      weight: 1.0,
      maxResults: 5
    }
  ];

  return (
    <MultiCollectionProvider
      config={typesenseConfig}
      defaultCollections={collections}
    >
      <UnifiedSearch />
    </MultiCollectionProvider>
  );
}

function UnifiedSearch() {
  const { state, search, setQuery } = useMultiCollectionContext();

  const handleSearch = (query: string) => {
    setQuery(query);
    search({
      query,
      mergeStrategy: 'relevance', // Merge by relevance score
      normalizeScores: true,      // Normalize scores across collections
      resultMode: 'interleaved'   // Mix results together
    });
  };

  return (
    <div>
      <input
        value={state.query}
        onChange={(e) => handleSearch(e.target.value)}
        placeholder="Search everything..."
      />

      {state.results?.hits.map(hit => (
        <div key={`${hit._collection}-${hit.document.id}`}>
          <span className="collection-badge">{hit._collection}</span>
          <h3>{hit.document.name || hit.document.title}</h3>
          <p>Score: {hit._normalizedScore.toFixed(2)}</p>
        </div>
      ))}
    </div>
  );
}

Multi-Collection with Schema Discovery

function DynamicMultiSearch() {
  const [collections, setCollections] = useState([]);

  // Discover collections dynamically
  useEffect(() => {
    async function discoverCollections() {
      const client = new TypesenseSearchClient(typesenseConfig);
      const allCollections = await client.collections().retrieve();

      // Configure each collection based on its schema
      const configured = await Promise.all(
        allCollections.map(async (col) => {
          const schema = await client.collections(col.name).retrieve();
          const searchableFields = schema.fields
            .filter(f => f.type === 'string' || f.type === 'string[]')
            .map(f => f.name);

          return {
            collection: col.name,
            queryBy: searchableFields.join(','),
            weight: col.name === 'products' ? 2.0 : 1.0,
            maxResults: 10
          };
        })
      );

      setCollections(configured);
    }

    discoverCollections();
  }, []);

  if (collections.length === 0) return <div>Loading collections...</div>;

  return (
    <MultiCollectionProvider
      config={typesenseConfig}
      defaultCollections={collections}
    >
      <DynamicUnifiedSearch />
    </MultiCollectionProvider>
  );
}

Multi-Collection Autocomplete

function MultiCollectionAutocomplete() {
  const collections = [
    {
      collection: 'products',
      queryBy: 'name,sku',
      maxResults: 3,
      namespace: 'product'
    },
    {
      collection: 'categories', 
      queryBy: 'name,path',
      maxResults: 2,
      namespace: 'category'
    },
    {
      collection: 'brands',
      queryBy: 'name',
      maxResults: 2,
      namespace: 'brand'
    }
  ];

  return (
    <MultiCollectionProvider
      config={typesenseConfig}
      defaultCollections={collections}
      searchOptions={{
        debounceMs: 150,
        searchOnMount: false
      }}
    >
      <MultiAutocomplete />
    </MultiCollectionProvider>
  );
}

function MultiAutocomplete() {
  const { state, search, setQuery } = useMultiCollectionContext();
  const [isOpen, setIsOpen] = useState(false);

  const handleInputChange = (value: string) => {
    setQuery(value);
    if (value.length >= 2) {
      search({
        query: value,
        resultMode: 'perCollection', // Group by collection
        perCollectionLimit: 5
      });
      setIsOpen(true);
    } else {
      setIsOpen(false);
    }
  };

  return (
    <div className="multi-autocomplete">
      <input
        value={state.query}
        onChange={(e) => handleInputChange(e.target.value)}
        placeholder="Search products, categories, brands..."
      />

      {isOpen && state.results?.hitsByCollection && (
        <div className="suggestions-panel">
          {Object.entries(state.results.hitsByCollection).map(([collection, hits]) => (
            <div key={collection} className="collection-group">
              <h4>{collection}</h4>
              {hits.map(hit => (
                <div 
                  key={hit.document.id}
                  className="suggestion"
                  onClick={() => {
                    // Handle selection based on namespace
                    console.log(`Selected ${hit._namespace}: ${hit.document.name}`);
                    setIsOpen(false);
                  }}
                >
                  {hit.highlight?.name?.snippet || hit.document.name}
                </div>
              ))}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Multi-Collection Search-as-you-type

function MultiCollectionInstantSearch() {
  const collections = [
    {
      collection: 'products',
      queryBy: 'name,description',
      weight: 2.0,
      includeFields: 'id,name,price,image',
      maxResults: 10
    },
    {
      collection: 'help_articles',
      queryBy: 'title,content',
      weight: 1.0,
      includeFields: 'id,title,excerpt',
      maxResults: 5
    }
  ];

  return (
    <MultiCollectionProvider
      config={typesenseConfig}
      defaultCollections={collections}
      searchOptions={{
        debounceMs: 100,
        minQueryLength: 2
      }}
    >
      <InstantMultiSearch />
    </MultiCollectionProvider>
  );
}

function InstantMultiSearch() {
  const { state, search, setQuery, clearResults } = useMultiCollectionContext();

  useEffect(() => {
    if (state.query.length >= 2) {
      search({
        query: state.query,
        enableHighlighting: true,
        highlightConfig: {
          startTag: '<mark>',
          endTag: '</mark>'
        },
        // Use round-robin to ensure variety
        mergeStrategy: 'roundRobin',
        globalMaxResults: 20
      });
    } else {
      clearResults();
    }
  }, [state.query]);

  return (
    <div className="instant-multi-search">
      <input
        value={state.query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Start typing to search..."
        className="search-input"
      />

      <div className="results-container">
        {state.loading && <div className="loading">Searching...</div>}

        {state.results?.hits.map(hit => (
          <div 
            key={`${hit._collection}-${hit.document.id}`}
            className={`result-item ${hit._collection}`}
          >
            <div className="result-header">
              <span className="collection-label">{hit._collection}</span>
              <span className="relevance-score">
                {(hit._normalizedScore * 100).toFixed(0)}% match
              </span>
            </div>

            {hit._collection === 'products' ? (
              <ProductResult hit={hit} />
            ) : (
              <ArticleResult hit={hit} />
            )}
          </div>
        ))}

        {state.results && state.results.totalFound === 0 && (
          <div className="no-results">
            No results found for "{state.query}"
          </div>
        )}
      </div>
    </div>
  );
}

function ProductResult({ hit }) {
  return (
    <div className="product-result">
      {hit.document.image && (
        <img src={hit.document.image} alt={hit.document.name} />
      )}
      <div>
        <h3 dangerouslySetInnerHTML={{ 
          __html: hit.highlight?.name?.snippet || hit.document.name 
        }} />
        <p className="price">${hit.document.price}</p>
      </div>
    </div>
  );
}

function ArticleResult({ hit }) {
  return (
    <div className="article-result">
      <h3 dangerouslySetInnerHTML={{ 
        __html: hit.highlight?.title?.snippet || hit.document.title 
      }} />
      <p dangerouslySetInnerHTML={{ 
        __html: hit.highlight?.excerpt?.snippet || hit.document.excerpt 
      }} />
    </div>
  );
}

Previous Documentation

The following sections contain additional advanced features and implementation details:

Advanced Features

Advanced Faceting

Configure different facet types based on your data:

const facetConfig = [
  {
    field: 'category',
    label: 'Category',
    type: 'checkbox',      // Multi-select checkboxes
    disjunctive: true,     // OR logic between selections
    maxValues: 10,         // Maximum values to show
    searchable: true,      // Enable search within facet
    sortBy: 'count'        // Sort by count or value
  },
  {
    field: 'price',
    label: 'Price',
    type: 'numeric',       // Numeric range filter
    numericDisplay: 'range', // 'checkbox' | 'range' | 'both'
    rangeStep: 10          // Step size for range slider
  },
  {
    field: 'created_at',
    label: 'Date Added',
    type: 'date',          // Date range picker
    dateFormat: 'YYYY-MM-DD'
  },
  {
    field: 'status',
    label: 'Status',
    type: 'select'         // Single-select dropdown
  },
  {
    field: 'tags',
    label: 'Tags',
    type: 'custom',        // Custom filter type
    renderLabel: (value) => value.toUpperCase()
  }
];

UI State Management

The useFacetState hook manages UI state for facets:

// Global facet UI state
const facetUI = useFacetState();

// Search within facet values
facetUI.setFacetSearch('category', 'elec');
const filteredValues = values.filter(v => 
  v.value.toLowerCase().includes(facetUI.getFacetSearch('category'))
);

// Manage facet expansion
facetUI.toggleFacetExpanded('category');
const isExpanded = facetUI.isFacetExpanded('category');

// Track scroll position
facetUI.setFacetScrollTop('category', 100);
const scrollTop = facetUI.getFacetScrollTop('category');

Advanced Filtering and Sorting

The package now supports native Typesense filter_by strings and multi-field sorting:

// Add raw Typesense filters
actions.setAdditionalFilters('in_stock:true && (category:electronics || category:computers)');

// Multi-field sorting
actions.setMultiSortBy([
  { field: 'price', order: 'desc' },
  { field: 'rating', order: 'desc' },
  { field: 'name', order: 'asc' }
]);

// Or use in initial state
<SearchProvider
  initialState={{
    additionalFilters: 'featured:true && discount:>0',
    multiSortBy: [
      { field: 'popularity', order: 'desc' },
      { field: 'price', order: 'asc' }
    ]
  }}
>

Performance Optimization

<SearchProvider
  config={config}
  collection="products"
  performanceMode={true}                    // Disable expensive features
  enableDisjunctiveFacetQueries={false}     // Disable parallel facet queries
  accumulateFacets={false}                  // Disable facet accumulation
>

Utility Functions

Build complex filter strings programmatically:

import { 
  buildDisjunctiveFilter,
  buildNumericFilter,
  buildDateFilter,
  combineFilters,
  buildMultiSortString,
  parseSortString
} from '@jungle-commerce/typesense-react';

// Build filters
const categoryFilter = buildDisjunctiveFilter('category', ['Electronics', 'Books']);
const priceFilter = buildNumericFilter('price', 10, 100);
const combined = combineFilters([categoryFilter, priceFilter]);

// Build sort strings
const sortString = buildMultiSortString([
  { field: 'price', order: 'desc' },
  { field: 'name', order: 'asc' }
]); // Returns: "price:desc,name:asc"

API Reference

For detailed API documentation, see the API Reference section in the original documentation above.

Testing

Quick Start

# Run unit tests
pnpm test

# Run integration tests (requires Docker)
pnpm test:integration

# Run all tests with coverage
pnpm test:all:coverage

Test Setup

  1. Install dependencies: pnpm install
  2. For integration tests: Ensure Docker is installed and running
  3. Run tests: Use the commands above

For detailed testing documentation, see our Testing Guide.

Testing Your Implementation

When using typesense-react in your application:

import { SearchProvider } from '@jungle-commerce/typesense-react';
import { render } from '@testing-library/react';

const renderWithSearch = (ui: React.ReactElement) => {
  return render(
    <SearchProvider config={mockConfig} collection="test">
      {ui}
    </SearchProvider>
  );
};

// Test your components
it('should search when typing', async () => {
  renderWithSearch(<YourSearchComponent />);
  // ... test implementation
});

Claude Integration

This package includes comprehensive Claude MCP (Model Context Protocol) integration documentation:

Testing

# Run unit tests
pnpm test

# Run integration tests (requires Docker)
pnpm test:integration

# Run all tests with coverage
pnpm test:all:coverage

For detailed testing documentation, see our Testing Guide.

Contributing

We welcome contributions! Please see our Contributing Guide.

Support

License

MIT © Jungle Commerce

changelog

Changelog

All notable changes to this project will be documented in this file.

The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.

[2.0.3] - 2025-01-03

Fixed

  • Fixed disjunctive facet counts not updating when additional filters change. Implemented a ref-based solution to avoid React closure issues where the performSearch callback was using stale state values. Now disjunctive facet queries correctly include all current filters (including date filters and additional filters) when calculating facet counts.

[2.0.2] - 2025-01-03

Fixed

  • Initial attempt to fix disjunctive facet counts not updating when additional filters change (incomplete fix - use 2.0.3 instead)

[1.2.1] - 2025-01-01

Added

  • Complete test suite with unit and integration tests
  • Comprehensive documentation for testing infrastructure
  • Docker Compose setup for testing environment
  • GitHub Actions CI/CD pipeline
  • Support for multi-collection search with MultiCollectionProvider
  • Advanced faceting capabilities with useAdvancedFacets hook
  • Date filtering support with useDateFilter hook
  • Numeric range faceting with useNumericFacetRange hook
  • Schema discovery and validation utilities
  • Accumulated facets functionality
  • Additional filters management
  • Error handling improvements

Changed

  • Upgraded React peer dependency to support React 18+
  • Improved TypeScript types and exports
  • Enhanced search performance with optimized reducers
  • Better error messages and validation

Fixed

  • Test suite compatibility issues
  • Build configuration for proper ESM/CJS dual package support
  • Type definitions export paths

[1.2.0] - 2024-12-15

Added

  • NPM publish settings
  • Initial package configuration

Changed

  • Package name refactored to @jungle-commerce/typesense-react
  • Updated README with better examples

[1.1.0] - 2024-12-01

Added

  • Basic search functionality with SearchProvider
  • Core hooks: useSearch, useFacetState, useFacetMode
  • TypesenseClient wrapper
  • Basic examples (basic-search, advanced-filtering)

[1.0.0] - 2024-11-15

Added

  • Initial release
  • Core search provider implementation
  • Basic TypeScript support
  • Vite build configuration