agentleFS
jaballerjaballer/react-claude-skills/react-utility-snippets/SKILL.md

react-utility-snippets

Skill0 starsChanged 5 months agoMITView on GitHub

Paste-ready React hooks and helpers that other react-* skills reference but don't define — cn (clsx + tailwind-merge), useMediaQuery (SSR-safe), usePrefersReducedMotion, useFocusTrap, useAnnouncer + LiveRegion, useOnClickOutside, useLocalStorage (SSR-safe), useIsomorphicLayoutEffect, useDebounce, useThrottle, useEventListener, useInterval, useId patterns. Every snippet is SSR-safe and dependency-light. Load this skill at the start of any interview build. Triggers on "useMediaQuery", "focus trap", "cn helper", "clsx", "tailwind-merge", "live region", "useAnnouncer", "useLocalStorage", "useDebounce", "useThrottle", "snippet", "utility hook", "paste-ready", "click outside", "prefers reduced motion".

The file

We split this skill into the rules it gives an agent

4 rules. About 0 are common, 1 is distinctive, and 3 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈1

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 3

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Note: if you're using native <dialog> with .showModal(), the browser handles focus trap for you — skip this hook.

    line 159
  2. Very specific:

    Note: if using the popover attribute, the browser handles light dismiss — skip this hook.

    line 272
  3. Very specific:

    Use this anywhere you'd reach for useLayoutEffect in code that also runs server-side — silences the SSR warning while keeping sync behavior …

    line 318
  4. Distinctive:

    Use for scroll position, window size on resize, mouse coordinates.

    line 371

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

react-utility-snippets

Paste-ready React utility hooks and helpers that the other react-* skills reference but don't define. Every snippet is SSR-safe, dependency-light, and ready to drop into a fresh interview build. Don't waste time reinventing these mid-build.

The full kit

UtilityReach for it when
cnConditional Tailwind class composition
useMediaQueryRead a media query into React state
usePrefersReducedMotionGate animations on user preference
useFocusTrapTrap Tab inside modal / dialog / off-canvas
useAnnouncer + <LiveRegion>Imperative screen reader announcements
useOnClickOutsideClose popover / menu / dropdown
useLocalStoragePersist tiny UI state
useIsomorphicLayoutEffectuseLayoutEffect without SSR warning
useDebounce / useThrottleDefer or rate-limit derived values
useEventListenerCleaner window/element event listener
useIntervalsetInterval that respects component lifecycle
useId patternsStable IDs for label/input/error wiring

cn — className composer

// lib/cn.ts
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

clsx handles conditional strings; twMerge resolves Tailwind class conflicts (px-2 then px-4 → final px-4). If Tailwind isn't on the table, drop twMerge and just use clsx.

<button className={cn('btn', variant === 'primary' && 'btn-primary', className)} />

useMediaQuery — SSR-safe

The naive window.matchMedia(...) version crashes during SSR. This version starts with a safe default and syncs after mount.

// hooks/useMediaQuery.ts
import { useEffect, useState } from 'react';

export function useMediaQuery(query: string, defaultValue = false): boolean {
  const [matches, setMatches] = useState(defaultValue);

  useEffect(() => {
    const mql = window.matchMedia(query);
    setMatches(mql.matches);
    const onChange = (e: MediaQueryListEvent) => setMatches(e.matches);
    mql.addEventListener('change', onChange);
    return () => mql.removeEventListener('change', onChange);
  }, [query]);

  return matches;
}

The defaultValue argument lets you pass a sensible SSR default (false for desktop-first, true for mobile-first) so first paint isn't always wrong.

const isMobile = useMediaQuery('(max-width: 767px)');
const prefersDark = useMediaQuery('(prefers-color-scheme: dark)');

usePrefersReducedMotion — the specific shortcut

import { useMediaQuery } from './useMediaQuery';

export function usePrefersReducedMotion() {
  return useMediaQuery('(prefers-reduced-motion: reduce)', false);
}
const reduced = usePrefersReducedMotion();
const transition = reduced ? 'none' : 'transform 200ms var(--ease)';

useFocusTrap — vanilla, no library

The 30-line implementation that keeps Tab and Shift+Tab cycling inside a container. Use this when a modal/dialog/menu is open.

// hooks/useFocusTrap.ts
import { useEffect, type RefObject } from 'react';

const FOCUSABLE_SELECTORS = [
  'a[href]',
  'button:not([disabled])',
  'input:not([disabled])',
  'select:not([disabled])',
  'textarea:not([disabled])',
  '[tabindex]:not([tabindex="-1"])',
].join(',');

export function useFocusTrap(
  ref: RefObject<HTMLElement>,
  active: boolean
) {
  useEffect(() => {
    if (!active) return;
    const root = ref.current;
    if (!root) return;

    const focusables = () =>
      Array.from(root.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTORS))
        .filter(el => !el.hasAttribute('aria-hidden') && el.offsetParent !== null);

    // Move focus inside on activate
    const els = focusables();
    els[0]?.focus();

    const onKey = (e: KeyboardEvent) => {
      if (e.key !== 'Tab') return;
      const current = focusables();
      if (current.length === 0) return;
      const first = current[0];
      const last = current[current.length - 1];

      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    };

    root.addEventListener('keydown', onKey);
    return () => root.removeEventListener('keydown', onKey);
  }, [ref, active]);
}
function Modal({ open, onClose, children }: ModalProps) {
  const ref = useRef<HTMLDivElement>(null);
  useFocusTrap(ref, open);
  if (!open) return null;
  return (
    <div ref={ref} role="dialog" aria-modal="true">
      {children}
    </div>
  );
}

Note: if you're using native <dialog> with .showModal(), the browser handles focus trap for you — skip this hook.

useAnnouncer + <LiveRegion> — imperative screen reader announcements

For announcing state changes (form submitted, item added, search results loaded) to screen reader users without inline aria-live regions everywhere.

// components/Announcer.tsx
'use client';
import {
  createContext, useContext, useState, useCallback,
  type ReactNode,
} from 'react';

type AnnouncerContext = {
  announce: (message: string, priority?: 'polite' | 'assertive') => void;
};

const Ctx = createContext<AnnouncerContext>({ announce: () => {} });

export function AnnouncerProvider({ children }: { children: ReactNode }) {
  const [polite, setPolite] = useState('');
  const [assertive, setAssertive] = useState('');

  const announce = useCallback(
    (msg: string, priority: 'polite' | 'assertive' = 'polite') => {
      // Clear then set so identical messages still announce
      if (priority === 'polite') {
        setPolite('');
        setTimeout(() => setPolite(msg), 50);
      } else {
        setAssertive('');
        setTimeout(() => setAssertive(msg), 50);
      }
    },
    []
  );

  return (
    <Ctx.Provider value={{ announce }}>
      {children}
      <div aria-live="polite" aria-atomic="true" className="sr-only">
        {polite}
      </div>
      <div aria-live="assertive" aria-atomic="true" className="sr-only">
        {assertive}
      </div>
    </Ctx.Provider>
  );
}

export function useAnnouncer() {
  return useContext(Ctx);
}

The sr-only utility (Tailwind ships this; otherwise):

.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
// In root layout:
<AnnouncerProvider>{children}</AnnouncerProvider>

// Anywhere in the tree:
const { announce } = useAnnouncer();
announce('Subscribed — check your inbox', 'polite');
announce('Error: payment declined', 'assertive');

Why polite vs assertive: polite queues the message (doesn't interrupt). assertive interrupts whatever the screen reader is saying. Use assertive only for errors / urgent state changes.

useOnClickOutside — close popovers cleanly

// hooks/useOnClickOutside.ts
import { useEffect, type RefObject } from 'react';

export function useOnClickOutside<T extends HTMLElement>(
  ref: RefObject<T>,
  handler: (event: MouseEvent | TouchEvent) => void
) {
  useEffect(() => {
    const listener = (event: MouseEvent | TouchEvent) => {
      const el = ref.current;
      if (!el || el.contains(event.target as Node)) return;
      handler(event);
    };
    document.addEventListener('mousedown', listener);
    document.addEventListener('touchstart', listener);
    return () => {
      document.removeEventListener('mousedown', listener);
      document.removeEventListener('touchstart', listener);
    };
  }, [ref, handler]);
}
const ref = useRef<HTMLDivElement>(null);
useOnClickOutside(ref, () => setOpen(false));
return <div ref={ref}>…</div>;

Note: if using the popover attribute, the browser handles light dismiss — skip this hook.

useLocalStorage — SSR-safe

// hooks/useLocalStorage.ts
import { useEffect, useState } from 'react';

export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
  const [value, setValue] = useState<T>(initialValue);

  useEffect(() => {
    try {
      const raw = window.localStorage.getItem(key);
      if (raw !== null) setValue(JSON.parse(raw));
    } catch {}
  }, [key]);

  const set = (next: T | ((prev: T) => T)) => {
    setValue(prev => {
      const v = typeof next === 'function' ? (next as (p: T) => T)(prev) : next;
      try {
        window.localStorage.setItem(key, JSON.stringify(v));
      } catch {}
      return v;
    });
  };

  return [value, set];
}

Reads happen in useEffect (after mount) to avoid SSR hydration mismatch. If you need the value during initial server render, use a cookie instead.

useIsomorphicLayoutEffect

import { useEffect, useLayoutEffect } from 'react';

export const useIsomorphicLayoutEffect =
  typeof window !== 'undefined' ? useLayoutEffect : useEffect;

Use this anywhere you'd reach for useLayoutEffect in code that also runs server-side — silences the SSR warning while keeping sync behavior on the client.

useDebounce

// hooks/useDebounce.ts
import { useEffect, useState } from 'react';

export function useDebounce<T>(value: T, delay = 300): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

Classic search-as-you-type:

const [query, setQuery] = useState('');
const debounced = useDebounce(query, 250);
useEffect(() => { search(debounced); }, [debounced]);

useThrottle

import { useEffect, useRef, useState } from 'react';

export function useThrottle<T>(value: T, limit = 200): T {
  const [throttled, setThrottled] = useState(value);
  const last = useRef(Date.now());

  useEffect(() => {
    const now = Date.now();
    const remaining = limit - (now - last.current);
    if (remaining <= 0) {
      last.current = now;
      setThrottled(value);
    } else {
      const id = setTimeout(() => {
        last.current = Date.now();
        setThrottled(value);
      }, remaining);
      return () => clearTimeout(id);
    }
  }, [value, limit]);

  return throttled;
}

Use for scroll position, window size on resize, mouse coordinates.

useEventListener

// hooks/useEventListener.ts
import { useEffect, useRef } from 'react';

export function useEventListener<K extends keyof WindowEventMap>(
  type: K,
  handler: (event: WindowEventMap[K]) => void,
  target: Window | HTMLElement | null =
    typeof window !== 'undefined' ? window : null
) {
  const saved = useRef(handler);
  useEffect(() => { saved.current = handler; }, [handler]);

  useEffect(() => {
    if (!target) return;
    const listener = (e: Event) => saved.current(e as WindowEventMap[K]);
    target.addEventListener(type, listener);
    return () => target.removeEventListener(type, listener);
  }, [type, target]);
}

saved.current keeps the handler reference fresh without re-attaching the listener on every render.

useEventListener('keydown', e => {
  if (e.key === 'Escape') close();
});

useInterval — SSR-safe and pause-able

// hooks/useInterval.ts
import { useEffect, useRef } from 'react';

export function useInterval(callback: () => void, delay: number | null) {
  const saved = useRef(callback);
  useEffect(() => { saved.current = callback; }, [callback]);

  useEffect(() => {
    if (delay === null) return;
    const id = setInterval(() => saved.current(), delay);
    return () => clearInterval(id);
  }, [delay]);
}

Pass null to pause:

useInterval(() => setSeconds(s => s + 1), running ? 1000 : null);

useId patterns

useId() (React 18+) returns a stable SSR-safe ID. Use it to wire label/input/error/description together.

import { useId } from 'react';

function Field({ label, error }: { label: string; error?: string }) {
  const id = useId();
  const errorId = `${id}-error`;
  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input
        id={id}
        aria-invalid={!!error}
        aria-describedby={error ? errorId : undefined}
      />
      {error && <p id={errorId} role="alert">{error}</p>}
    </div>
  );
}

For multiple related IDs from one useId():

const id = useId();
const labelId = `${id}-label`;
const buttonId = `${id}-button`;
const menuId = `${id}-menu`;

Common pitfalls

MistakeFix
useMediaQuery reads window on first renderUse the SSR-safe version above
Focus trap leaks Tab to address barThe wrap-around handler covers Shift+Tab + Tab
useDebounce cleanup forgottenAlways return () => clearTimeout(id)
useLocalStorage reads synchronously during renderRead after mount in useEffect
useEventListener re-attaches every renderStore handler in a ref
useId used as a key in a listuseId is per-component, not per-item — use item.id for keys

Narration phrases

  • "I keep these hooks in a lib/hooks/ directory — each is SSR-safe and tested. Saves me from reinventing them in every component."
  • "I'm using usePrefersReducedMotion instead of inlining the matchMedia call so the intent is obvious at the call site."
  • "The focus trap is a 30-line hook — for production I'd reach for focus-trap-react but the inline version is enough for the time-boxed build."
  • "I went with useDebounce over useDeferredValue because I want a real time delay; deferred is React's scheduling-aware version which behaves differently under concurrent rendering."

Authoritative references

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.