# Hook Testing

Test custom hooks in isolation without creating wrapper components.

## API

```typescript { .api }
function renderHook<Result, Props>(
  callback: (props: Props) => Result,
  options?: RenderHookOptions<Props>
): RenderHookResult<Result, Props>;

interface RenderHookOptions<Props> {
  /**
   * Initial props passed to the hook callback
   */
  initialProps?: Props;

  /**
   * Custom container element (same as render options)
   */
  container?: HTMLElement;

  /**
   * Base element for queries (same as render options)
   */
  baseElement?: HTMLElement;

  /**
   * Use hydration instead of normal render
   */
  hydrate?: boolean;

  /**
   * Force synchronous rendering.
   * Only supported in React 18. Not supported in React 19+.
   * Throws an error if used with React 19 or later.
   */
  legacyRoot?: boolean;

  /**
   * Wrapper component for providers
   */
  wrapper?: React.JSXElementConstructor<{ children: React.ReactNode }>;

  /**
   * Enable React.StrictMode wrapper
   */
  reactStrictMode?: boolean;

  /**
   * React 19+ only: Callback when React catches an error in an Error Boundary.
   * Only available in React 19 and later.
   */
  onCaughtError?: (error: Error, errorInfo: { componentStack?: string }) => void;

  /**
   * Callback when React automatically recovers from errors.
   * Available in React 18 and later.
   */
  onRecoverableError?: (error: Error, errorInfo: { componentStack?: string }) => void;
}

interface RenderHookResult<Result, Props> {
  /**
   * Stable reference to the latest hook return value.
   * Access the current value via result.current
   */
  result: {
    current: Result;
  };

  /**
   * Re-renders the hook with new props
   * @param props - New props to pass to the hook callback
   */
  rerender: (props?: Props) => void;

  /**
   * Unmounts the test component, triggering cleanup effects
   */
  unmount: () => void;
}
```

## Common Patterns

### Basic Hook Test
```typescript
function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);
  const increment = () => setCount(c => c + 1);
  return { count, increment };
}

test('useCounter increments', () => {
  const { result } = renderHook(() => useCounter(0));

  expect(result.current.count).toBe(0);

  act(() => result.current.increment());
  expect(result.current.count).toBe(1);
});
```

### Hook with Props
```typescript
function useGreeting(name: string) {
  return `Hello, ${name}!`;
}

test('useGreeting with different names', () => {
  const { result, rerender } = renderHook(
    ({ name }) => useGreeting(name),
    { initialProps: { name: 'Alice' } }
  );

  expect(result.current).toBe('Hello, Alice!');

  rerender({ name: 'Bob' });
  expect(result.current).toBe('Hello, Bob!');
});
```

### Hook with Context
```typescript
function useUser() {
  return useContext(UserContext);
}

test('useUser returns context value', () => {
  const wrapper = ({ children }) => (
    <UserContext.Provider value={{ name: 'John' }}>
      {children}
    </UserContext.Provider>
  );

  const { result } = renderHook(() => useUser(), { wrapper });

  expect(result.current).toEqual({ name: 'John' });
});
```

### Async Hook
```typescript
function useData(url: string) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    fetch(url)
      .then(res => res.json())
      .then(data => {
        setData(data);
        setLoading(false);
      });
  }, [url]);

  return { data, loading };
}

test('useData fetches', async () => {
  const { result } = renderHook(() => useData('/api/user'));

  expect(result.current.loading).toBe(true);

  await waitFor(() => {
    expect(result.current.loading).toBe(false);
  });

  expect(result.current.data).toBeDefined();
});
```

## Testing Patterns

### State Management Hook
```typescript
function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = () => setValue(v => !v);
  const setTrue = () => setValue(true);
  const setFalse = () => setValue(false);
  return { value, toggle, setTrue, setFalse };
}

test('useToggle manages boolean state', () => {
  const { result } = renderHook(() => useToggle());

  expect(result.current.value).toBe(false);

  act(() => result.current.toggle());
  expect(result.current.value).toBe(true);

  act(() => result.current.setFalse());
  expect(result.current.value).toBe(false);
});
```

### Effect Cleanup
```typescript
function useEventListener(event: string, handler: () => void) {
  useEffect(() => {
    window.addEventListener(event, handler);
    return () => window.removeEventListener(event, handler);
  }, [event, handler]);
}

test('useEventListener cleans up', () => {
  const handler = jest.fn();
  const { unmount } = renderHook(() => useEventListener('click', handler));

  window.dispatchEvent(new Event('click'));
  expect(handler).toHaveBeenCalledTimes(1);

  unmount();

  window.dispatchEvent(new Event('click'));
  expect(handler).toHaveBeenCalledTimes(1);  // Not called after unmount
});
```

### Hook with Dependencies
```typescript
function useDebounce(value: string, delay: number) {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(handler);
  }, [value, delay]);

  return debouncedValue;
}

test('useDebounce delays updates', async () => {
  const { result, rerender } = renderHook(
    ({ value, delay }) => useDebounce(value, delay),
    { initialProps: { value: 'initial', delay: 500 } }
  );

  expect(result.current).toBe('initial');

  rerender({ value: 'updated', delay: 500 });
  expect(result.current).toBe('initial');  // Not yet updated

  await waitFor(() => {
    expect(result.current).toBe('updated');
  }, { timeout: 1000 });
});
```

### Complex Hook with Actions
```typescript
function useList<T>(initial: T[] = []) {
  const [items, setItems] = useState(initial);

  return {
    items,
    add: useCallback((item: T) => setItems(prev => [...prev, item]), []),
    remove: useCallback((index: number) =>
      setItems(prev => prev.filter((_, i) => i !== index)), []
    ),
    clear: useCallback(() => setItems([]), []),
  };
}

test('useList manages array', () => {
  const { result } = renderHook(() => useList(['a']));

  expect(result.current.items).toEqual(['a']);

  act(() => result.current.add('b'));
  expect(result.current.items).toEqual(['a', 'b']);

  act(() => result.current.remove(0));
  expect(result.current.items).toEqual(['b']);

  act(() => result.current.clear());
  expect(result.current.items).toEqual([]);
});
```

### Hook with External Store
```typescript
function useStore() {
  const [state, setState] = useState(store.getState());

  useEffect(() => {
    const unsubscribe = store.subscribe(setState);
    return unsubscribe;
  }, []);

  return state;
}

test('useStore syncs with store', () => {
  const { result } = renderHook(() => useStore());

  expect(result.current.count).toBe(0);

  act(() => store.dispatch({ type: 'INCREMENT' }));

  expect(result.current.count).toBe(1);
});
```

## Important Notes

### act() Wrapping
Wrap state updates in act() to ensure React processes them:

```typescript
// ✅ Correct
act(() => result.current.action());

// ❌ Wrong - may have timing issues
result.current.action();
```

### result.current Stability
The `result` object is stable, but `result.current` updates with each render:

```typescript
const { result } = renderHook(() => useState(0));

// ❌ Wrong - stale reference
const [count, setCount] = result.current;
act(() => setCount(1));
console.log(count);  // Still 0

// ✅ Correct - always use result.current
act(() => result.current[1](1));
console.log(result.current[0]);  // 1
```

### Async Operations
Use waitFor for async hook updates:

```typescript
const { result } = renderHook(() => useAsync());

await waitFor(() => {
  expect(result.current.isLoaded).toBe(true);
});
```

## Production Patterns

### Custom Wrapper Utility
```typescript
// test-utils.tsx
import { renderHook, RenderHookOptions } from '@testing-library/react';

export function renderHookWithProviders<Result, Props>(
  callback: (props: Props) => Result,
  options?: RenderHookOptions<Props>
) {
  return renderHook(callback, {
    wrapper: ({ children }) => (
      <QueryClientProvider client={queryClient}>
        <ThemeProvider>{children}</ThemeProvider>
      </QueryClientProvider>
    ),
    ...options,
  });
}
```

### Testing React Query Hooks
```typescript
import { renderHook, waitFor } from '@testing-library/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

test('useUserQuery fetches user', async () => {
  const queryClient = new QueryClient({
    defaultOptions: { queries: { retry: false } },
  });

  const wrapper = ({ children }) => (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );

  const { result } = renderHook(() => useUserQuery('123'), { wrapper });

  expect(result.current.isLoading).toBe(true);

  await waitFor(() => {
    expect(result.current.isSuccess).toBe(true);
  });

  expect(result.current.data).toEqual({ id: '123', name: 'John' });
});
```

### Testing Custom Form Hook
```typescript
function useForm(initialValues) {
  const [values, setValues] = useState(initialValues);
  const [errors, setErrors] = useState({});

  const handleChange = (name, value) => {
    setValues(prev => ({ ...prev, [name]: value }));
    setErrors(prev => ({ ...prev, [name]: undefined }));
  };

  const validate = () => {
    const newErrors = {};
    if (!values.email) newErrors.email = 'Required';
    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  return { values, errors, handleChange, validate };
}

test('useForm validates', () => {
  const { result } = renderHook(() => useForm({ email: '' }));

  expect(result.current.errors).toEqual({});

  act(() => {
    const isValid = result.current.validate();
    expect(isValid).toBe(false);
  });

  expect(result.current.errors).toEqual({ email: 'Required' });

  act(() => result.current.handleChange('email', 'test@example.com'));

  expect(result.current.errors).toEqual({});

  act(() => {
    const isValid = result.current.validate();
    expect(isValid).toBe(true);
  });
});
```

## Important Notes

### act() Wrapping

State updates in hooks must be wrapped in `act()` to ensure React processes updates properly:

```typescript
import { renderHook, act } from '@testing-library/react';

const { result } = renderHook(() => useCounter());

// Wrap state updates in act()
act(() => {
  result.current.increment();
});
```

### result.current Stability

The `result` object itself is stable across renders, but `result.current` is updated with each render. Always access the latest value through `result.current`:

```typescript
const { result } = renderHook(() => useState(0));

// WRONG: Destructuring creates a stale reference
const [count, setCount] = result.current;
act(() => setCount(1));
console.log(count); // Still 0 (stale)

// CORRECT: Access via result.current
act(() => result.current[1](1));
console.log(result.current[0]); // 1 (current)
```

### Async Operations

Use `waitFor` for async operations:

```typescript
import { renderHook, waitFor } from '@testing-library/react';

const { result } = renderHook(() => useAsyncHook());

await waitFor(() => {
  expect(result.current.isLoaded).toBe(true);
});
```

## Deprecated Types

The following type aliases are deprecated and provided for backward compatibility:

```typescript { .api }
/** @deprecated Use RenderHookOptions instead */
type BaseRenderHookOptions<Props, Q, Container, BaseElement> = RenderHookOptions<Props>;

/** @deprecated Use RenderHookOptions with hydrate: false instead */
interface ClientRenderHookOptions<Props, Q, Container, BaseElement> extends RenderHookOptions<Props> {
  hydrate?: false | undefined;
}

/** @deprecated Use RenderHookOptions with hydrate: true instead */
interface HydrateHookOptions<Props, Q, Container, BaseElement> extends RenderHookOptions<Props> {
  hydrate: true;
}
```

## Best Practices

1. **Wrap updates in act()** - All state changes must be in act()
2. **Access via result.current** - Never destructure result.current
3. **Use waitFor for async** - Don't assume timing
4. **Test in isolation** - Focus on hook logic, not component behavior
5. **Provide context** - Use wrapper for hooks needing context/providers
6. **Test cleanup** - Verify useEffect cleanup with unmount()
