Skip to main content
Back to Elite Events

Elite Events Documentation

Technical documentation, guides, and API references for the Elite Events platform.

State Management/Hook Development

Custom Hook Development Guide

This guide covers best practices for creating and testing custom React hooks in Elite Events.

When to Create a Hook

Create a custom hook when you need to:

  • Share stateful logic between multiple components
  • Encapsulate complex state management
  • Abstract API calls or data fetching
  • Combine multiple React hooks into reusable logic
  • Handle subscriptions (WebSocket, events)

Don't create a hook for:

  • Simple one-off logic
  • Pure utility functions (use regular functions instead)
  • Logic that doesn't use React hooks

Hook Location

All hooks live in src/hooks/:

src/hooks/
├── index.ts              # Barrel export
├── useMyHook.ts          # Hook implementation
└── __tests__/
    └── useMyHook.test.ts # Hook tests

Hook Structure Template

// src/hooks/useMyHook.ts
'use client'; // Required for client-side hooks

import { useState, useEffect, useCallback, useMemo } from 'react';

/**
 * Configuration options for the hook
 */
interface UseMyHookOptions {
  /** Initial value */
  initialValue?: string;
  /** Callback when value changes */
  onChange?: (value: string) => void;
  /** Enable debug logging */
  debug?: boolean;
}

/**
 * Return type of the hook
 */
interface UseMyHookReturn {
  /** Current value */
  value: string;
  /** Loading state */
  isLoading: boolean;
  /** Error message if any */
  error: string | null;
  /** Update the value */
  setValue: (value: string) => void;
  /** Reset to initial state */
  reset: () => void;
}

/**
 * Hook description - what it does and when to use it
 *
 * @param options - Configuration options
 * @returns Hook state and methods
 *
 * @example
 * ```tsx
 * function MyComponent() {
 *   const { value, setValue, isLoading } = useMyHook({
 *     initialValue: 'hello',
 *     onChange: (v) => console.log('Changed:', v),
 *   });
 *
 *   return <input value={value} onChange={(e) => setValue(e.target.value)} />;
 * }
 * ```
 */
export function useMyHook(options: UseMyHookOptions = {}): UseMyHookReturn {
  const { initialValue = '', onChange, debug = false } = options;

  // State declarations
  const [value, setValueState] = useState(initialValue);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  // Memoized values
  const derivedValue = useMemo(() => {
    return value.toUpperCase();
  }, [value]);

  // Effects
  useEffect(() => {
    if (debug) {
      console.log('[useMyHook] Value changed:', value);
    }
  }, [value, debug]);

  // Callbacks (memoized to prevent unnecessary re-renders)
  const setValue = useCallback((newValue: string) => {
    setValueState(newValue);
    onChange?.(newValue);
  }, [onChange]);

  const reset = useCallback(() => {
    setValueState(initialValue);
    setError(null);
  }, [initialValue]);

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      // Cleanup subscriptions, timers, etc.
    };
  }, []);

  return {
    value,
    isLoading,
    error,
    setValue,
    reset,
  };
}

Naming Conventions

ConventionExampleDescription
Prefix with useuseProductsRequired by React
Descriptive nameuseProductSearchNot usePSearch
Action-based for mutationsuseAddToCartIndicates action
Resource-based for datauseCategoriesIndicates data fetched

Common Hook Patterns

Data Fetching Hook

export function useProducts(categoryId?: number) {
  const [products, setProducts] = useState<Product[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const controller = new AbortController();

    async function fetchProducts() {
      setIsLoading(true);
      setError(null);
      try {
        const response = await fetch(`/api/products?category=${categoryId}`, {
          signal: controller.signal,
        });
        if (!response.ok) throw new Error('Failed to fetch');
        const data = await response.json();
        setProducts(data.products);
      } catch (err) {
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err.message);
        }
      } finally {
        setIsLoading(false);
      }
    }

    fetchProducts();

    return () => controller.abort(); // Cleanup on unmount
  }, [categoryId]);

  return { products, isLoading, error };
}

WebSocket/Subscription Hook

export function useSocket(url: string) {
  const [isConnected, setIsConnected] = useState(false);
  const [lastMessage, setLastMessage] = useState<unknown>(null);
  const socketRef = useRef<WebSocket | null>(null);

  useEffect(() => {
    const socket = new WebSocket(url);
    socketRef.current = socket;

    socket.onopen = () => setIsConnected(true);
    socket.onclose = () => setIsConnected(false);
    socket.onmessage = (event) => setLastMessage(JSON.parse(event.data));

    return () => {
      socket.close();
    };
  }, [url]);

  const send = useCallback((data: unknown) => {
    socketRef.current?.send(JSON.stringify(data));
  }, []);

  return { isConnected, lastMessage, send };
}

Form State Hook

export function useFormField<T>(initialValue: T, validate?: (value: T) => string | null) {
  const [value, setValue] = useState(initialValue);
  const [touched, setTouched] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleChange = useCallback((newValue: T) => {
    setValue(newValue);
    if (touched && validate) {
      setError(validate(newValue));
    }
  }, [touched, validate]);

  const handleBlur = useCallback(() => {
    setTouched(true);
    if (validate) {
      setError(validate(value));
    }
  }, [value, validate]);

  const reset = useCallback(() => {
    setValue(initialValue);
    setTouched(false);
    setError(null);
  }, [initialValue]);

  return {
    value,
    touched,
    error,
    isValid: !error,
    onChange: handleChange,
    onBlur: handleBlur,
    reset,
  };
}

Pagination Hook

export function usePagination<T>(
  fetchFn: (page: number, limit: number) => Promise<{ items: T[]; total: number }>,
  options: { initialPage?: number; limit?: number } = {}
) {
  const { initialPage = 1, limit = 20 } = options;
  const [page, setPage] = useState(initialPage);
  const [items, setItems] = useState<T[]>([]);
  const [total, setTotal] = useState(0);
  const [isLoading, setIsLoading] = useState(false);

  const totalPages = Math.ceil(total / limit);
  const hasNext = page < totalPages;
  const hasPrev = page > 1;

  useEffect(() => {
    async function load() {
      setIsLoading(true);
      const result = await fetchFn(page, limit);
      setItems(result.items);
      setTotal(result.total);
      setIsLoading(false);
    }
    load();
  }, [page, limit, fetchFn]);

  return {
    items,
    page,
    totalPages,
    hasNext,
    hasPrev,
    isLoading,
    nextPage: () => hasNext && setPage(p => p + 1),
    prevPage: () => hasPrev && setPage(p => p - 1),
    goToPage: setPage,
  };
}

Testing Hooks

Every hook MUST have tests in src/hooks/__tests__/:

// src/hooks/__tests__/useMyHook.test.ts
import { renderHook, act, waitFor } from '@testing-library/react';
import { useMyHook } from '../useMyHook';

describe('useMyHook', () => {
  it('should initialize with default value', () => {
    const { result } = renderHook(() => useMyHook());
    expect(result.current.value).toBe('');
    expect(result.current.isLoading).toBe(false);
  });

  it('should initialize with provided value', () => {
    const { result } = renderHook(() => useMyHook({ initialValue: 'test' }));
    expect(result.current.value).toBe('test');
  });

  it('should update value when setValue is called', () => {
    const { result } = renderHook(() => useMyHook());

    act(() => {
      result.current.setValue('new value');
    });

    expect(result.current.value).toBe('new value');
  });

  it('should call onChange callback', () => {
    const onChange = jest.fn();
    const { result } = renderHook(() => useMyHook({ onChange }));

    act(() => {
      result.current.setValue('test');
    });

    expect(onChange).toHaveBeenCalledWith('test');
  });

  it('should reset to initial value', () => {
    const { result } = renderHook(() => useMyHook({ initialValue: 'initial' }));

    act(() => {
      result.current.setValue('changed');
    });
    expect(result.current.value).toBe('changed');

    act(() => {
      result.current.reset();
    });
    expect(result.current.value).toBe('initial');
  });

  it('should handle async operations', async () => {
    const { result } = renderHook(() => useMyAsyncHook());

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

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

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

Best Practices

Do's

  • Use TypeScript - Define interfaces for options and return types
  • Document with JSDoc - Include description, params, examples
  • Memoize callbacks - Use useCallback for functions returned
  • Memoize expensive values - Use useMemo for computed values
  • Handle cleanup - Return cleanup function from useEffect
  • Handle errors - Catch and expose errors in return
  • Export from index - Add to src/hooks/index.ts

Don'ts

  • Don't call hooks conditionally - Breaks React rules
  • Don't mutate state directly - Always use setter functions
  • Don't return unstable references - Memoize objects/arrays
  • Don't ignore dependencies - Include all deps in arrays
  • Don't forget cleanup - Cancel requests, clear timers

Existing Hooks Reference

HookPurposeLocation
useDebounceDebounce values/callbackssrc/hooks/useDebounce.ts
useProductsFetch product datasrc/hooks/useProducts.ts
useCategoriesFetch categoriessrc/hooks/useCategories.ts
useSocketWebSocket connectionsrc/hooks/useSocket.ts
useToastToast notificationssrc/hooks/useToast.ts
useThemeTheme managementsrc/hooks/useTheme.ts
useFocusTrapAccessibility focus trapsrc/hooks/useFocusTrap.ts
Documentation | Elite Events | Philip Rehberger