--- name: hooks description: Creates React hooks (timers, debounce, store-review, async wrappers) with Jest tests. Use when building or refactoring custom hooks. --- # Hooks ``` src/hooks/ ├── __tests__/.test.tsx └── use.ts(x) ``` Hooks are co-located with their tests. Export the function as a named export. ## Common Hook Patterns ### `useTimeout` Runs `callback` once `delay` ms after `start` becomes `true`. Cleans up on unmount or when inputs change. ```ts import { useEffect } from 'react'; export const useTimeout = (callback: () => void, delay: number, start: boolean) => { useEffect(() => { if (!start) return; const id = setTimeout(callback, delay); return () => clearTimeout(id); }, [callback, delay, start]); }; ``` Usage: ```ts const [show, setShow] = useState(true); useTimeout(() => setShow(false), 2000, show); ``` ### `useInterval` (no stale closure) ```ts import { useEffect, useRef } from 'react'; export const 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]); }; ``` ### `useDebounce` ```ts import { useEffect, useState } from 'react'; export const useDebounce = (value: T, delay: number): T => { const [debounced, setDebounced] = useState(value); useEffect(() => { const id = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(id); }, [value, delay]); return debounced; }; ``` ### `usePrevious` ```ts import { useEffect, useRef } from 'react'; export const usePrevious = (value: T): T | undefined => { const ref = useRef(); useEffect(() => { ref.current = value; }, [value]); return ref.current; }; ``` ### `useRateApp` (wraps `expo-store-review`) ```ts import * as StoreReview from 'expo-store-review'; import { useCallback } from 'react'; export const useRateApp = () => { const requestReview = useCallback(async () => { try { const hasAction = await StoreReview.hasAction(); const isAvailable = await StoreReview.isAvailableAsync(); if (isAvailable && hasAction) await StoreReview.requestReview(); } catch (err) { console.error('Error requesting app review:', err); } }, []); return { requestReview }; }; ``` ## Composing Stores + Side Effects Domain hooks that combine stores, derived data, and side effects: - Read state with `useStore(s => s.field)` selectors. - Wrap expensive computations in `useMemo`. - Wrap callbacks consumed by children in `useCallback`. - Schedule/cancel side effects inside `useEffect`; always cleanup. ```ts import { useEffect, useMemo } from 'react'; import { useItemStore } from '@/store/useItemStore'; import { scheduleReminder, cancelReminders } from '@/lib/notifications'; export const useItemReminders = (itemId: string) => { const item = useItemStore(s => s.items[itemId]); const reminderDates = useMemo(() => { if (!item) return []; return computeReminderDates(item); }, [item]); useEffect(() => { if (!reminderDates.length) return; const ids: string[] = []; reminderDates.forEach(d => scheduleReminder(d).then(id => ids.push(id))); return () => { cancelReminders(ids).catch(err => console.error(err)); }; }, [reminderDates]); }; ``` ## Testing Hooks (`pnpm test`) Tests live in `src/hooks/__tests__/` and run under the **node** Jest config — the file pattern allows them since they end in `.test.tsx`. ```ts import { act, renderHook } from '@testing-library/react-native'; import { useTimeout } from '../useTimeout'; describe('useTimeout', () => { beforeEach(() => { jest.useFakeTimers(); }); afterEach(() => { jest.useRealTimers(); }); it('fires the callback once delay elapses', () => { const cb = jest.fn(); renderHook(() => useTimeout(cb, 1000, true)); act(() => jest.advanceTimersByTime(1000)); expect(cb).toHaveBeenCalledTimes(1); }); it('does not fire when start=false', () => { const cb = jest.fn(); renderHook(() => useTimeout(cb, 1000, false)); act(() => jest.advanceTimersByTime(2000)); expect(cb).not.toHaveBeenCalled(); }); }); ``` ### Mocking ```ts jest.mock('@/lib/notifications', () => ({ scheduleReminder: jest.fn(), cancelReminders: jest.fn(), })); jest.mock('@/store/useItemStore', () => ({ useItemStore: jest.fn(), })); import * as notifications from '@/lib/notifications'; const scheduleSpy = notifications.scheduleReminder as jest.MockedFunction; ``` ## Best Practices 1. Always cleanup in `useEffect` returns (timers, subscriptions, async). 2. Put every read value into the dependency array. 3. Use a `ref` for callbacks that should not retrigger the effect. 4. `useCallback` on returned functions; `useMemo` on returned objects. 5. Accept `null`/`undefined` as a way to disable an effect (see `useInterval`). 6. `try/catch` + `console.error` for async work; never silently swallow. ## Checklist - [ ] Hook in `src/hooks/use.ts(x)` with `use` prefix. - [ ] Cleanup returned from every effect with side effects. - [ ] Full dependency arrays; refs used to avoid stale closures. - [ ] Returned functions wrapped in `useCallback`. - [ ] `try/catch` + `console.error` for async paths. - [ ] Test file in `src/hooks/__tests__/use.test.tsx`. - [ ] `jest.useFakeTimers()` for timer-based hooks; restored in `afterEach`. - [ ] External modules mocked via `jest.mock(...)`.