typescript 97 lines · 3 tabs

Memoized Async Search With a Cached Selector Hook in React

Shared by codesnips Jul 2026
3 tabs
import { useMemo } from "react";

export type SortDir = "asc" | "desc";
export interface SortConfig<T> {
  key: keyof T;
  dir: SortDir;
}

export function useFilteredSort<T extends Record<string, unknown>>(
  items: T[],
  query: string,
  sort: SortConfig<T>,
  searchKeys: (keyof T)[]
): T[] {
  const comparator = useMemo(() => {
    const factor = sort.dir === "asc" ? 1 : -1;
    return (a: T, b: T) => {
      const av = a[sort.key];
      const bv = b[sort.key];
      if (av === bv) return 0;
      return (av > bv ? 1 : -1) * factor;
    };
  }, [sort.key, sort.dir]);

  return useMemo(() => {
    const needle = query.trim().toLowerCase();
    if (!needle) return items;

    const filtered = items.filter((row) =>
      searchKeys.some((k) => String(row[k]).toLowerCase().includes(needle))
    );

    return filtered.slice().sort(comparator);
  }, [items, query, comparator, searchKeys]);
}
3 files · typescript Explain with highlit

This snippet shows how to keep expensive derived data cheap in a React tree by pushing the computation behind a custom hook rather than recomputing it inline on every render. The core idea is that useMemo only re-runs its factory when its dependency array changes by reference, so the trick to using it well is controlling those dependencies and making the memoized value itself referentially stable.

In useFilteredSort hook, the hook accepts a raw list plus a query and sort config, and wraps a genuinely expensive pipeline — lowercasing and matching every row, then sorting a copy — inside useMemo. The dependency array is [items, query, comparator], so the pipeline only re-runs when one of those identities changes. The comparator is itself derived with a nested useMemo keyed on sort.key and sort.dir, which prevents a brand-new function on each render from invalidating the outer memo. That layering is the important part: a memo is only as stable as its least stable dependency.

A subtle pitfall is normalizing the query. query.trim().toLowerCase() is computed once and closed over, so the filter callback does not repeat that work per item. When the query is empty the code returns the original items reference untouched, which lets downstream React.memo children skip re-rendering entirely.

In SearchResults component, the hook is consumed like any state selector. Because the returned array keeps a stable identity across renders where nothing relevant changed, the memoized <Row> list avoids reconciliation churn. The debounced query from useDebouncedValue hook feeds the hook so keystrokes do not thrash the sort on every input event.

The trade-off worth naming: memoization is not free — it costs memory and dependency bookkeeping, and an incorrect dependency array silently serves stale data. This pattern earns its keep when the computation is measurably heavy (large lists, complex comparisons) and its inputs change less often than the component renders. For trivial derivations, plain inline computation is clearer and faster. The hook boundary also makes the memoization testable and reusable, keeping the component focused on presentation.


Related snips

typescript
export interface RetryOptions {
  retries: number;
  baseMs: number;
  maxMs: number;
  signal?: AbortSignal;
  onRetry?: (attempt: number, delay: number, err: unknown) => void;

Exponential backoff with jitter for retries

typescript reliability retry
by codesnips 2 tabs
ruby
require "csv"

class PeopleCsvStream
  include Enumerable

  HEADERS = %w[id full_name email signed_up_at plan].freeze

Resilient CSV Export as a Streamed Response

rails performance streaming
by codesnips 3 tabs
ruby
Rails.application.configure do
  config.after_initialize do
    Bullet.enable = true
    Bullet.alert = false
    Bullet.bullet_logger = true
    Bullet.console = true

N+1 query detection with Bullet gem

rails performance activerecord
by Alex Kumar 2 tabs
javascript
import { Controller } from "@hotwired/stimulus"

export default class extends Controller {
  static targets = ["form"]
  static values = { delay: { type: Number, default: 250 } }

Debounced live search with Stimulus + Turbo Streams

rails hotwire stimulus
by codesnips 4 tabs
ruby
json.array! @posts do |post|
  json.cache! ['v1', post], expires_in: 1.hour do
    json.id post.id
    json.title post.title
    json.excerpt post.excerpt
    json.published_at post.published_at

Fragment caching for expensive JSON serialization

rails caching performance
by Alex Kumar 1 tab
typescript
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'

const api = axios.create({
  baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
  timeout: 15000,

Axios API client with interceptors

react axios api
by Maya Patel 1 tab

Share this code

Here's the card — post it anywhere.

Memoized Async Search With a Cached Selector Hook in React — share card
Link copied