# Custom Hooks

After building the POS system for a few weeks, I noticed I was copying and pasting the same data-fetching pattern everywhere: set loading to true, fetch data, handle errors, set loading to false. Every product list, order history, and category selector had identical logic with different URLs. I had the same cart operations duplicated across components. The code worked, but it violated the DRY principle so hard it hurt.

That's when I discovered **custom hooks**—the ability to extract reusable stateful logic into functions. Not just any functions, but functions that can use hooks themselves. I turned 200 lines of repeated fetch logic into a single `useProducts` hook. Cart operations became `useCart`. Authentication became `useAuth`. My components went from 100 lines to 20.

## What Are Custom Hooks?

**Custom hooks are JavaScript functions that use React hooks.** They let you extract component logic into reusable functions.

**Rules**:

1. Name must start with `use` (e.g., `useProducts`, `useCart`)
2. Can call other hooks (useState, useEffect, useContext)
3. Must follow the Rules of Hooks (only call at top level)

**When to create a custom hook**:

* You're copying the same stateful logic across components
* Complex logic is making your component hard to read
* You want to share logic across your app

**In the POS system, I created hooks for**:

* `useProducts` - Fetching and filtering products
* `useCart` - Cart operations
* `useAuth` - Authentication state and actions
* `useLocalStorage` - Persisting state
* `useDebounce` - Debouncing search input

## Your First Custom Hook: useProducts

Let's extract the product-fetching logic into a reusable hook.

### Before: Repeated in Every Component

```typescript
// ProductList.tsx
function ProductList() {
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  
  useEffect(() => {
    const fetchProducts = async () => {
      try {
        setLoading(true);
        setError(null);
        const response = await fetch('/api/products');
        const data = await response.json();
        setProducts(data);
      } catch (err) {
        setError(err instanceof Error ? err.message : 'Failed to load');
      } finally {
        setLoading(false);
      }
    };
    
    fetchProducts();
  }, []);
  
  // ... component render
}

// CategoryPage.tsx - Same code again!
function CategoryPage({ category }: { category: string }) {
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  
  useEffect(() => {
    const fetchProducts = async () => {
      try {
        setLoading(true);
        setError(null);
        const response = await fetch(`/api/products?category=${category}`);
        const data = await response.json();
        setProducts(data);
      } catch (err) {
        setError(err instanceof Error ? err.message : 'Failed to load');
      } finally {
        setLoading(false);
      }
    };
    
    fetchProducts();
  }, [category]);
  
  // ... component render
}
```

**Problems**: Duplicated logic, hard to maintain, easy to introduce bugs.

### After: Single Reusable Hook

```typescript
// hooks/useProducts.ts
import { useState, useEffect } from 'react';

interface Product {
  id: string;
  name: string;
  price: number;
  stock: number;
  category: string;
  imageUrl: string;
}

interface UseProductsOptions {
  category?: string;
  search?: string;
}

export function useProducts(options: UseProductsOptions = {}) {
  const [products, setProducts] = useState<Product[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  
  const { category, search } = options;
  
  useEffect(() => {
    const fetchProducts = async () => {
      try {
        setLoading(true);
        setError(null);
        
        // Build URL with query params
        const params = new URLSearchParams();
        if (category) params.append('category', category);
        if (search) params.append('search', search);
        
        const url = `/api/products?${params.toString()}`;
        const response = await fetch(url);
        
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        const data = await response.json();
        setProducts(data);
      } catch (err) {
        setError(err instanceof Error ? err.message : 'Failed to load products');
      } finally {
        setLoading(false);
      }
    };
    
    fetchProducts();
  }, [category, search]); // Re-fetch when filters change
  
  return { products, loading, error };
}
```

### Using the Hook

```typescript
// ProductList.tsx
import { useProducts } from '../hooks/useProducts';

function ProductList() {
  const { products, loading, error } = useProducts();
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  
  return (
    <div className="product-list">
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

// CategoryPage.tsx
function CategoryPage({ category }: { category: string }) {
  const { products, loading, error } = useProducts({ category });
  
  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;
  
  return (
    <div className="category-page">
      <h1>{category} Products</h1>
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}

// SearchPage.tsx
function SearchPage({ searchTerm }: { searchTerm: string }) {
  const { products, loading, error } = useProducts({ search: searchTerm });
  
  // Same simple component!
}
```

**Benefits**:

* DRY (Don't Repeat Yourself)
* Easier to test
* Easier to maintain
* Components stay focused on rendering

## Building useCart Hook

Let's extract cart operations into a reusable hook.

```typescript
// hooks/useCart.ts
import { useState, useEffect, useCallback } from 'react';

interface Product {
  id: string;
  name: string;
  price: number;
}

interface CartItem {
  productId: string;
  name: string;
  price: number;
  quantity: number;
}

export function useCart() {
  // Load initial cart from localStorage
  const [cart, setCart] = useState<CartItem[]>(() => {
    const savedCart = localStorage.getItem('pos_cart');
    return savedCart ? JSON.parse(savedCart) : [];
  });
  
  // Persist cart to localStorage whenever it changes
  useEffect(() => {
    localStorage.setItem('pos_cart', JSON.stringify(cart));
  }, [cart]);
  
  // Add item to cart (or increment quantity if exists)
  const addToCart = useCallback((product: Product) => {
    setCart(prevCart => {
      const existingItem = prevCart.find(item => item.productId === product.id);
      
      if (existingItem) {
        // Increment quantity
        return prevCart.map(item =>
          item.productId === product.id
            ? { ...item, quantity: item.quantity + 1 }
            : item
        );
      } else {
        // Add new item
        return [...prevCart, {
          productId: product.id,
          name: product.name,
          price: product.price,
          quantity: 1
        }];
      }
    });
  }, []);
  
  // Remove item from cart
  const removeFromCart = useCallback((productId: string) => {
    setCart(prevCart => prevCart.filter(item => item.productId !== productId));
  }, []);
  
  // Update item quantity
  const updateQuantity = useCallback((productId: string, quantity: number) => {
    if (quantity <= 0) {
      removeFromCart(productId);
      return;
    }
    
    setCart(prevCart =>
      prevCart.map(item =>
        item.productId === productId
          ? { ...item, quantity }
          : item
      )
    );
  }, [removeFromCart]);
  
  // Clear entire cart
  const clearCart = useCallback(() => {
    setCart([]);
  }, []);
  
  // Get item by id
  const getItem = useCallback((productId: string) => {
    return cart.find(item => item.productId === productId);
  }, [cart]);
  
  // Check if item is in cart
  const isInCart = useCallback((productId: string) => {
    return cart.some(item => item.productId === productId);
  }, [cart]);
  
  // Derived values
  const itemCount = cart.reduce((sum, item) => sum + item.quantity, 0);
  const subtotal = cart.reduce((sum, item) => sum + item.price * item.quantity, 0);
  const tax = subtotal * 0.05; // 5% tax
  const total = subtotal + tax;
  
  return {
    cart,
    addToCart,
    removeFromCart,
    updateQuantity,
    clearCart,
    getItem,
    isInCart,
    itemCount,
    subtotal,
    tax,
    total
  };
}
```

### Using useCart

```typescript
// components/ProductCard.tsx
import { useCart } from '../hooks/useCart';

function ProductCard({ product }: { product: Product }) {
  const { addToCart, isInCart, getItem } = useCart();
  
  const inCart = isInCart(product.id);
  const cartItem = getItem(product.id);
  
  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      <p>{product.price} MMK</p>
      
      {inCart ? (
        <div>
          <span>In cart: {cartItem?.quantity}</span>
          <button onClick={() => addToCart(product)}>Add More</button>
        </div>
      ) : (
        <button onClick={() => addToCart(product)}>Add to Cart</button>
      )}
    </div>
  );
}

// components/CartSummary.tsx
function CartSummary() {
  const { cart, removeFromCart, updateQuantity, itemCount, total } = useCart();
  
  return (
    <div className="cart-summary">
      <h2>Cart ({itemCount} items)</h2>
      
      {cart.map(item => (
        <div key={item.productId} className="cart-item">
          <span>{item.name}</span>
          <input 
            type="number"
            value={item.quantity}
            onChange={(e) => updateQuantity(item.productId, parseInt(e.target.value))}
          />
          <button onClick={() => removeFromCart(item.productId)}>Remove</button>
        </div>
      ))}
      
      <div className="total">
        <h3>Total: {total.toLocaleString()} MMK</h3>
      </div>
    </div>
  );
}
```

## Building useAuth Hook

Authentication logic is another great candidate for a custom hook.

```typescript
// hooks/useAuth.ts
import { useState, useEffect, useCallback } from 'react';

interface User {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'cashier' | 'manager';
}

interface LoginCredentials {
  email: string;
  password: string;
}

export function useAuth() {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  
  // Check if user is already authenticated on mount
  useEffect(() => {
    const checkAuth = async () => {
      const token = localStorage.getItem('authToken');
      
      if (!token) {
        setLoading(false);
        return;
      }
      
      try {
        const response = await fetch('/api/auth/me', {
          headers: {
            'Authorization': `Bearer ${token}`
          }
        });
        
        if (response.ok) {
          const userData = await response.json();
          setUser(userData);
        } else {
          localStorage.removeItem('authToken');
        }
      } catch (err) {
        console.error('Auth check failed:', err);
        localStorage.removeItem('authToken');
      } finally {
        setLoading(false);
      }
    };
    
    checkAuth();
  }, []);
  
  // Login
  const login = useCallback(async (credentials: LoginCredentials) => {
    try {
      setError(null);
      setLoading(true);
      
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(credentials)
      });
      
      if (!response.ok) {
        throw new Error('Invalid credentials');
      }
      
      const data = await response.json();
      localStorage.setItem('authToken', data.token);
      setUser(data.user);
      
      return data.user;
    } catch (err) {
      const message = err instanceof Error ? err.message : 'Login failed';
      setError(message);
      throw err;
    } finally {
      setLoading(false);
    }
  }, []);
  
  // Logout
  const logout = useCallback(() => {
    localStorage.removeItem('authToken');
    setUser(null);
  }, []);
  
  // Check if user has specific role
  const hasRole = useCallback((role: string) => {
    return user?.role === role;
  }, [user]);
  
  // Check if user has permission
  const hasPermission = useCallback((permission: string) => {
    // In a real app, check against user permissions
    const rolePermissions: Record<string, string[]> = {
      admin: ['manage_users', 'manage_products', 'view_reports', 'process_sales'],
      manager: ['manage_products', 'view_reports', 'process_sales'],
      cashier: ['process_sales']
    };
    
    return user ? rolePermissions[user.role]?.includes(permission) : false;
  }, [user]);
  
  return {
    user,
    loading,
    error,
    isAuthenticated: !!user,
    login,
    logout,
    hasRole,
    hasPermission
  };
}
```

### Using useAuth

```typescript
// components/LoginForm.tsx
import { useAuth } from '../hooks/useAuth';

function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');
  const { login, error, loading } = useAuth();
  
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    try {
      await login({ email, password });
      // Will redirect automatically when user state changes
    } catch (err) {
      // Error already set in hook
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <h2>Login</h2>
      {error && <div className="error">{error}</div>}
      
      <input 
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
        disabled={loading}
      />
      
      <input 
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
        disabled={loading}
      />
      
      <button type="submit" disabled={loading}>
        {loading ? 'Logging in...' : 'Login'}
      </button>
    </form>
  );
}

// components/ProtectedRoute.tsx
function ProtectedRoute({ children, requiredRole }: { children: ReactNode; requiredRole?: string }) {
  const { isAuthenticated, hasRole, loading } = useAuth();
  
  if (loading) {
    return <div>Loading...</div>;
  }
  
  if (!isAuthenticated) {
    return <Navigate to="/login" />;
  }
  
  if (requiredRole && !hasRole(requiredRole)) {
    return <div>Access denied</div>;
  }
  
  return <>{children}</>;
}
```

## Building useLocalStorage Hook

A general-purpose hook for persisting state:

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

export function useLocalStorage<T>(key: string, initialValue: T) {
  // Get from localStorage or use initial value
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(`Error loading ${key} from localStorage:`, error);
      return initialValue;
    }
  });
  
  // Save to localStorage whenever value changes
  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(storedValue));
    } catch (error) {
      console.error(`Error saving ${key} to localStorage:`, error);
    }
  }, [key, storedValue]);
  
  return [storedValue, setStoredValue] as const;
}
```

### Using useLocalStorage

```typescript
// Before: Manual localStorage management
function UserPreferences() {
  const [theme, setTheme] = useState(() => {
    const saved = localStorage.getItem('theme');
    return saved || 'light';
  });
  
  useEffect(() => {
    localStorage.setItem('theme', theme);
  }, [theme]);
  
  // ...
}

// After: Simple hook
function UserPreferences() {
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  
  // That's it! Automatically persisted
}
```

## Building useDebounce Hook

For search inputs that shouldn't trigger API calls on every keystroke:

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

export function useDebounce<T>(value: T, delay: number = 500): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);
  
  useEffect(() => {
    // Set timeout to update debounced value after delay
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
    
    // Cleanup: cancel timeout if value changes before delay
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);
  
  return debouncedValue;
}
```

### Using useDebounce

```typescript
// components/ProductSearch.tsx
import { useState } from 'react';
import { useDebounce } from '../hooks/useDebounce';
import { useProducts } from '../hooks/useProducts';

function ProductSearch() {
  const [searchTerm, setSearchTerm] = useState('');
  const debouncedSearch = useDebounce(searchTerm, 300);
  
  // Only fetches when user stops typing for 300ms
  const { products, loading } = useProducts({ search: debouncedSearch });
  
  return (
    <div>
      <input 
        type="text"
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        placeholder="Search products..."
      />
      
      {loading && <div>Searching...</div>}
      
      <div className="results">
        {products.map(product => (
          <ProductCard key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
}
```

**Without debounce**: Type "chicken" → 7 API calls (c, ch, chi, chic, chick, chicke, chicken)

**With debounce**: Type "chicken" → 1 API call (after you stop typing)

## Composing Hooks Together

Hooks can use other hooks. Here's a powerful pattern:

```typescript
// hooks/useProductSearch.ts
import { useState } from 'react';
import { useDebounce } from './useDebounce';
import { useProducts } from './useProducts';

export function useProductSearch() {
  const [searchTerm, setSearchTerm] = useState('');
  const [category, setCategory] = useState('all');
  
  const debouncedSearch = useDebounce(searchTerm, 300);
  
  const { products, loading, error } = useProducts({
    search: debouncedSearch,
    category: category === 'all' ? undefined : category
  });
  
  return {
    searchTerm,
    setSearchTerm,
    category,
    setCategory,
    products,
    loading,
    error
  };
}
```

### Using the Composed Hook

```typescript
// components/ProductSearchPage.tsx
function ProductSearchPage() {
  const {
    searchTerm,
    setSearchTerm,
    category,
    setCategory,
    products,
    loading,
    error
  } = useProductSearch();
  
  return (
    <div>
      <input 
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
      />
      
      <select value={category} onChange={(e) => setCategory(e.target.value)}>
        <option value="all">All</option>
        <option value="food">Food</option>
        <option value="drinks">Drinks</option>
      </select>
      
      {loading && <div>Loading...</div>}
      {error && <div>Error: {error}</div>}
      
      {products.map(product => (
        <ProductCard key={product.id} product={product} />
      ))}
    </div>
  );
}
```

**The component is so clean!** All the complex logic is in the hook.

## Rules of Hooks

These are CRITICAL and React will break if you violate them:

### 1. Only Call Hooks at the Top Level

```typescript
// ❌ WRONG - Conditional hook
function ProductList({ showProducts }: { showProducts: boolean }) {
  if (showProducts) {
    const { products } = useProducts(); // ❌ Conditional!
  }
}

// ❌ WRONG - Hook in loop
function MultipleProducts({ ids }: { ids: string[] }) {
  ids.forEach(id => {
    const product = useProduct(id); // ❌ In loop!
  });
}

// ✅ CORRECT
function ProductList({ showProducts }: { showProducts: boolean }) {
  const { products } = useProducts(); // ✅ Top level
  
  if (!showProducts) return null;
  
  return <div>{/* render products */}</div>;
}
```

### 2. Only Call Hooks from React Functions

```typescript
// ❌ WRONG - Hook in regular function
function calculateTotal(cart) {
  const [total, setTotal] = useState(0); // ❌ Not in React function
  return total;
}

// ✅ CORRECT - Hook in component
function CartTotal({ cart }) {
  const [total, setTotal] = useState(0); // ✅ In component
  return <div>{total}</div>;
}

// ✅ CORRECT - Hook in custom hook
function useCartTotal(cart) {
  const [total, setTotal] = useState(0); // ✅ In custom hook
  return total;
}
```

### 3. Custom Hooks Must Start with "use"

```typescript
// ❌ WRONG - Doesn't start with "use"
function fetchProducts() {
  const [products, setProducts] = useState([]); // ❌ React can't detect this
}

// ✅ CORRECT
function useProducts() {
  const [products, setProducts] = useState([]); // ✅ React knows it's a hook
}
```

## Real POS Example: Complete Hook Setup

Here's how I organize hooks in the POS system:

```typescript
// hooks/index.ts - Barrel export
export { useProducts } from './useProducts';
export { useCart } from './useCart';
export { useAuth } from './useAuth';
export { useLocalStorage } from './useLocalStorage';
export { useDebounce } from './useDebounce';
export { useOrders } from './useOrders';

// Using multiple hooks together
// pages/CheckoutPage.tsx
import { useProducts, useCart, useAuth } from '../hooks';

function CheckoutPage() {
  const { user } = useAuth();
  const { products, loading: productsLoading } = useProducts();
  const { 
    cart, 
    addToCart, 
    removeFromCart, 
    total 
  } = useCart();
  
  if (productsLoading) return <div>Loading...</div>;
  
  return (
    <div className="checkout-page">
      <header>
        <h1>Welcome, {user?.name}</h1>
      </header>
      
      <div className="products">
        {products.map(product => (
          <ProductCard 
            key={product.id}
            product={product}
            onAdd={() => addToCart(product)}
          />
        ))}
      </div>
      
      <div className="cart">
        {cart.map(item => (
          <CartItem 
            key={item.productId}
            item={item}
            onRemove={() => removeFromCart(item.productId)}
          />
        ))}
        <div>Total: {total} MMK</div>
      </div>
    </div>
  );
}
```

## Common Mistakes to Avoid

### 1. Not Following Hook Rules

```typescript
// ❌ Conditional hook call
if (needsProducts) {
  const { products } = useProducts();
}
```

### 2. Forgetting Dependencies

```typescript
// ❌ Missing searchTerm dependency
useEffect(() => {
  fetch(`/api/products?search=${searchTerm}`);
}, []); // Should include searchTerm!
```

### 3. Not Using useCallback for Functions

```typescript
// ❌ New function on every render
const addToCart = (product) => {
  setCart([...cart, product]);
};

// ✅ Stable function reference
const addToCart = useCallback((product) => {
  setCart(prev => [...prev, product]);
}, []);
```

### 4. Overusing Custom Hooks

```typescript
// ❌ Hook for everything
function useButtonClick() {
  const [clicked, setClicked] = useState(false);
  return { clicked, onClick: () => setClicked(true) };
}

// ✅ Just use state directly - too simple for a hook
```

## Key Learnings

1. **Custom hooks** extract reusable stateful logic
2. **Name must start with "use"** (useProducts, useCart)
3. **Can use other hooks** (useState, useEffect, other custom hooks)
4. **Follow Rules of Hooks** (top level, React functions only)
5. **Return values** that make sense for the hook's purpose
6. **Use useCallback** for stable function references
7. **Compose hooks** to build powerful abstractions
8. **Don't overuse** - simple state can stay in components

## Next Steps

You've mastered custom hooks for organizing logic. But the POS isn't a single page—it has products, cart, checkout, and order history screens. How do you navigate between them? In the next article, we'll add **React Router** to build a multi-page navigation system with protected routes.

Continue to: [Routing →](https://blog.htunnthuthu.com/getting-started/programming/react-101/react-101-routing)
