All files / use-set/src useSet.ts

100% Statements 44/44
100% Branches 18/18
100% Functions 14/14
100% Lines 42/42

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141                  21x 21x                                                                                                                 41x 41x 21x     41x 21x         41x 41x   41x 9x 9x 1x   8x 8x 8x       41x 3x 3x 1x   2x 2x 2x       41x 6x 6x   6x   6x 3x     3x 3x 1x   2x   3x       41x 2x     41x 3x     41x   41x 21x       41x    
import { useCallback, useMemo, useRef, useState } from "react";
import type { SetInitializer, UseSetActions, UseSetReturn } from "./types";
 
/**
 * Resolve a {@link SetInitializer} to a concrete `Set`. A function initializer
 * is invoked once; any iterable/Set is copied into a fresh `Set` so the caller's
 * original object is never mutated.
 */
function resolveInitial<T>(initial?: SetInitializer<T>): Set<T> {
  const value = typeof initial === "function" ? initial() : initial;
  return new Set(value ?? []);
}
 
/**
 * A React hook for managing `Set` state with immutable, ergonomic updates.
 *
 * Returns a tuple of the current (read-only) set and a stable set of actions.
 * Every mutation produces a brand-new `Set` so React re-renders correctly and
 * the previous state is never mutated in place. Updates that would not change
 * anything (adding an existing value, removing an absent value, clearing an
 * empty set) are skipped to avoid needless re-renders.
 *
 * Features:
 * - Immutable updates (`new Set` on every change) with a `ReadonlySet` return type
 * - Stable action identities — safe to use as effect dependencies
 * - `useState`-style lazy initialization; accepts `Set`, iterables, or a factory
 * - `toggle` with an optional `force` argument (like `DOMTokenList.toggle`)
 * - Full TypeScript generics for the element type
 *
 * @template T - Element type.
 * @param initialState - Initial values, or a factory returning them. Defaults to empty.
 * @returns `[set, { add, remove, toggle, has, clear, reset }]`
 *
 * @example
 * ```tsx
 * function ItemList({ items }: { items: Item[] }) {
 *   const [selected, { toggle, has, clear }] = useSet<string>();
 *
 *   return (
 *     <>
 *       {items.map((item) => (
 *         <label key={item.id}>
 *           <input
 *             type="checkbox"
 *             checked={has(item.id)}
 *             onChange={() => toggle(item.id)}
 *           />
 *           {item.name}
 *         </label>
 *       ))}
 *       <button onClick={clear}>Clear selection ({selected.size})</button>
 *     </>
 *   );
 * }
 * ```
 *
 * @example
 * ```tsx
 * // Tag filter with reset
 * const [tags, { add, remove, reset }] = useSet<string>(["react", "hooks"]);
 * add("typescript");
 * remove("hooks");
 * reset(); // back to the initial tags
 * ```
 */
export function useSet<T>(initialState?: SetInitializer<T>): UseSetReturn<T> {
  // Resolve the initial set exactly once and keep it for `reset`.
  const initialRef = useRef<Set<T> | null>(null);
  if (initialRef.current === null) {
    initialRef.current = resolveInitial(initialState);
  }
 
  const [set, setSet] = useState<Set<T>>(
    () => new Set(initialRef.current as Set<T>)
  );
 
  // Mirror the latest set so `has` can be a stable callback that still reads
  // fresh state.
  const setStateRef = useRef(set);
  setStateRef.current = set;
 
  const add = useCallback((value: T) => {
    setSet((prev) => {
      if (prev.has(value)) {
        return prev;
      }
      const next = new Set(prev);
      next.add(value);
      return next;
    });
  }, []);
 
  const remove = useCallback((value: T) => {
    setSet((prev) => {
      if (!prev.has(value)) {
        return prev;
      }
      const next = new Set(prev);
      next.delete(value);
      return next;
    });
  }, []);
 
  const toggle = useCallback((value: T, force?: boolean) => {
    setSet((prev) => {
      const present = prev.has(value);
      // Decide the desired membership: honor `force` when provided, else flip.
      const shouldHave = force === undefined ? !present : force;
 
      if (shouldHave === present) {
        return prev; // already in the desired state — no-op
      }
 
      const next = new Set(prev);
      if (shouldHave) {
        next.add(value);
      } else {
        next.delete(value);
      }
      return next;
    });
  }, []);
 
  const clear = useCallback(() => {
    setSet((prev) => (prev.size === 0 ? prev : new Set<T>()));
  }, []);
 
  const reset = useCallback(() => {
    setSet(new Set(initialRef.current as Set<T>));
  }, []);
 
  const has = useCallback((value: T) => setStateRef.current.has(value), []);
 
  const actions = useMemo<UseSetActions<T>>(
    () => ({ add, remove, toggle, has, clear, reset }),
    [add, remove, toggle, has, clear, reset]
  );
 
  return [set, actions];
}