Skip to main content
Poke-Nex implements a powerful filtering system that allows users to search, filter by region, filter by types, and sort Pokemon in real-time. The system uses debouncing for optimal performance and Zustand for state management.

Architecture Overview

The filtering system is built around the usePokeFilters hook, which combines multiple filter criteria:
  • Text Search: Search by Pokemon name or ID
  • Region Filter: Filter by generation/region (Kanto, Johto, etc.)
  • Type Filter: Filter by one or multiple Pokemon types
  • Sorting: Sort by name (A-Z, Z-A) or ID (ascending, descending)

usePokeFilters Hook

The core filtering logic is implemented in src/hooks/usePokeFilters.ts:
usePokeFilters.ts
import { REGIONS } from '@/constants'
import { PokemonSummary, PokeType } from '@/types'
import { useMemo } from 'react'
import { useDebounce } from './useDebounce'
import { useTweaksStore } from '@/stores/tweaks.store'

type filterConfig = {
  debounce?: number
}

export const usePokeFilters = (
  pokeList: PokemonSummary[],
  config: filterConfig = { debounce: 0 }
) => {
  const { query, region, types, sort, setQuery, setRegion, setSort, setTypes } =
    useTweaksStore()
  const debSearch = useDebounce(query, config.debounce || 0)

  const list = useMemo(() => {
    const query = debSearch.trim().toLowerCase()
    const currentRegion =
      region !== 'all' ? REGIONS.find((r) => r.name === region) : null

    let result = [...pokeList]

    // 1. Search filter
    if (query !== '') {
      result = result.filter(
        ({ name, id }) =>
          name.toLowerCase().includes(query) || id.toString().includes(query)
      )
    }

    // 2. Region filter
    if (currentRegion) {
      const { start, end } = currentRegion
      result = result.filter((p) => p.id >= start && p.id <= end)
    }

    // 3. Type filter
    if (Array.isArray(types) && types.length > 0) {
      result = result.filter((pokemon) =>
        types.some((t) => pokemon.types && pokemon.types.includes(t))
      )
    }

    // 4. Sorting
    result.sort((a, b) => {
      if (sort === 'id-asc') return a.id - b.id
      if (sort === 'id-desc') return b.id - a.id
      if (sort === 'name-asc') return a.name.localeCompare(b.name)
      if (sort === 'name-desc') return b.name.localeCompare(a.name)
      return 0
    })

    return result
  }, [pokeList, debSearch, types, sort, region])

  const toggleType = (typeName: PokeType['name']) => {
    const isAlreadySelected = types.includes(typeName)
    const newTypes = isAlreadySelected
      ? types.filter((t) => t !== typeName)
      : [...types, typeName]
    setTypes(newTypes)
  }

  return {
    list,
    state: { search: query, region, types, sort },
    setSearch: setQuery,
    setRegion,
    setSort,
    toggleType,
    clearTypes: () => setTypes([]),
  }
}

Debounce Implementation

To prevent excessive re-renders during typing, the search input uses a debounce hook (src/hooks/useDebounce.ts):
useDebounce.ts
import { useEffect, useState } from 'react'

export const useDebounce = <T>(state: T, time: number = 300) => {
  const [debState, setDebState] = useState(state)

  useEffect(() => {
    const timeoutID = setTimeout(() => {
      setDebState(state)
    }, time)

    return () => {
      clearTimeout(timeoutID)
    }
  }, [state, time])

  return debState
}
The debounce delay is configurable. Poke-Nex uses 250ms for search input, which provides a good balance between responsiveness and performance.

Filter State Management

Filter state is managed by Zustand in src/stores/tweaks.store.ts. This ensures filters persist across navigation:
tweaks.store.ts
import { PokeRegion, PokeSort, PokeType } from '@/types'
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

interface TweaksState {
  page: number
  region: PokeRegion['name']
  types: PokeType['name'][]
  sort: PokeSort
  query: string

  setQuery: (query: string) => void
  setRegion: (region: PokeRegion['name']) => void
  setTypes: (types: PokeType['name'][]) => void
  setSort: (sort: PokeSort) => void
  resetTweaks: () => void
}

export const useTweaksStore = create<TweaksState>()(persist(
  (set, get) => ({
    page: 1,
    region: 'all',
    types: [],
    query: '',
    sort: 'id-asc',

    setQuery: (query) => set({ query }),
    setRegion: (region) => {
      const newRegion = region === get().region ? 'all' : region
      set({ region: newRegion, page: 1 })
    },
    setTypes: (types) => set({ types, page: 1 }),
    setSort: (sort) => set({ sort, page: 1 }),
    resetTweaks: () => set({
      page: 1,
      region: 'all',
      types: [],
      query: '',
      sort: 'id-asc',
    }),
  }),
  {
    name: 'pokenex-tweaks',
    storage: createJSONStorage(() => sessionStorage),
    partialize: (state) => ({
      region: state.region,
      types: state.types,
    }),
  }
))

Usage Example

Here’s how the filtering system is used in PokeGallery component:
const {
  list: filteredList,
  state: filterState,
  setSearch,
  setRegion,
  setSort,
  toggleType,
  clearTypes,
} = usePokeFilters(content, { debounce: 250 })

return (
  <FilterBar
    search={filterState.search}
    region={filterState.region}
    selectedTypes={filterState.types}
    sort={filterState.sort}
    onSearch={setSearch}
    onRegionUpdate={setRegion}
    onToggleType={toggleType}
    onClearTypes={clearTypes}
    onSort={setSort}
  />
)

Performance Optimizations

useMemo

The filter logic uses useMemo to prevent unnecessary recalculations. The filtered list only updates when dependencies change.

Debouncing

Search input is debounced by 250ms, reducing the number of filter operations while typing.

SessionStorage

Filter state persists in sessionStorage, maintaining user preferences during the session.

Pagination Reset

Changing filters automatically resets to page 1, ensuring users see relevant results immediately.

Filter Types

Searches both Pokemon names and IDs. The search is case-insensitive and uses substring matching:
result.filter(
  ({ name, id }) =>
    name.toLowerCase().includes(query) || id.toString().includes(query)
)

Region Filter

Regions are defined in src/constants/pokemon.constant.ts with ID ranges:
export const REGIONS: PokeRegion[] = [
  { name: 'kanto', start: 1, end: 151 },
  { name: 'johto', start: 152, end: 251 },
  { name: 'hoenn', start: 252, end: 386 },
  // ... more regions
]

Type Filter

Supports multi-type filtering. A Pokemon matches if it has ANY of the selected types:
result.filter((pokemon) =>
  types.some((t) => pokemon.types && pokemon.types.includes(t))
)
Region filter is toggle-based: clicking the same region again clears the filter.

Key Features

  • Real-time filtering with optimized performance
  • Multiple filter combinations work together seamlessly
  • Persistent state across page navigation (session-based)
  • Type-safe implementation with TypeScript
  • Responsive filtering that works on all device sizes

Build docs developers (and LLMs) love