Claude-skill-registry bellog-hooks

Provides custom React hooks patterns and best practices specific to Bellog. Triggers when creating custom hooks or implementing interactive features.

install
source · Clone the upstream repo
git clone https://github.com/majiayu000/claude-skill-registry
Claude Code · Install into ~/.claude/skills/
T=$(mktemp -d) && git clone --depth=1 https://github.com/majiayu000/claude-skill-registry "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/data/bellog-hooks" ~/.claude/skills/majiayu000-claude-skill-registry-bellog-hooks && rm -rf "$T"
manifest: skills/data/bellog-hooks/SKILL.md
source content

Bellog Hook Patterns

This skill defines the patterns and best practices for creating custom React hooks in the Bellog blog project.

Hook Location

All custom hooks:

/src/hooks/

Naming convention:

use[Feature].ts
(camelCase)

Core Hook Patterns

Bellog uses three main patterns:

  1. Scroll-based hooks - Track scroll position and direction
  2. Observer-based hooks - Use IntersectionObserver for viewport detection
  3. Content processing hooks - Parse and transform data

Pattern 1: Scroll-Based Hooks

Example:

useScrollSpy.ts

Structure

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

export function useScrollSpy() {
  // 1. State with useRef for position tracking
  const [activeId, setActiveId] = useState<string>("");
  const positionsRef = useRef<Map<string, number>>(new Map());

  // 2. Handler with useCallback for optimization
  const handleScroll = useCallback(() => {
    const scrollPosition = window.scrollY + OFFSET;

    // Find active section logic
    let active = "";
    positionsRef.current.forEach((position, id) => {
      if (scrollPosition >= position) {
        active = id;
      }
    });

    setActiveId(active);
  }, []);

  // 3. Effect with cleanup
  useEffect(() => {
    // Passive listener for better performance
    window.addEventListener('scroll', handleScroll, { passive: true });

    // Initial call
    handleScroll();

    // Cleanup
    return () => {
      window.removeEventListener('scroll', handleScroll);
    };
  }, [handleScroll]);

  return { activeId, setPositions: (positions) => {
    positionsRef.current = positions;
  }};
}

Key Features

  • useRef for positions - Avoids re-renders on position updates
  • useCallback - Prevents handler recreation
  • Passive listener - Better scroll performance
  • Cleanup - Remove listeners to prevent memory leaks

Constants Import

import { HEADER_OFFSET, SCROLL_SPY_OFFSET } from '@/constants/ui';

Use these constants instead of magic numbers.

Pattern 2: Observer-Based Hooks

Example:

useTocObserver.ts

Structure

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

export function useTocObserver() {
  const [activeId, setActiveId] = useState<string>("");
  const observerRef = useRef<IntersectionObserver | null>(null);

  useEffect(() => {
    // 1. Create observer
    observerRef.current = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            setActiveId(entry.target.id);
          }
        });
      },
      {
        rootMargin: '-80px 0px -80% 0px', // Adjust for header
        threshold: 0
      }
    );

    // 2. Observe elements
    const headings = document.querySelectorAll('h2, h3');
    headings.forEach(heading => {
      observerRef.current?.observe(heading);
    });

    // 3. Cleanup: unobserve and disconnect
    return () => {
      headings.forEach(heading => {
        observerRef.current?.unobserve(heading);
      });
      observerRef.current?.disconnect();
    };
  }, []); // Dependencies

  return activeId;
}

Key Features

  • IntersectionObserver - Efficient viewport detection
  • useRef for observer - Stable reference across renders
  • Proper cleanup - unobserve + disconnect
  • rootMargin - Account for fixed headers

Pattern 3: Content Processing Hooks

Example:

useHeadings.ts

Structure

import { useEffect, useState } from 'react';

interface Heading {
  id: string;
  text: string;
  level: number;
}

export function useHeadings() {
  const [headings, setHeadings] = useState<Heading[]>([]);

  useEffect(() => {
    // 1. Extract headings from DOM
    const elements = document.querySelectorAll('h2, h3');

    // 2. Process into structured data
    const processedHeadings = Array.from(elements).map(el => ({
      id: el.id,
      text: el.textContent || '',
      level: parseInt(el.tagName[1])
    }));

    // 3. Update state
    setHeadings(processedHeadings);

    // 4. Handle empty state
    if (processedHeadings.length === 0) {
      console.warn('No headings found');
    }
  }, []); // Re-run only on mount

  return headings;
}

Key Features

  • DOM querying - Extract content from rendered HTML
  • Data transformation - Convert to usable structure
  • Empty state handling - Graceful degradation
  • Type safety - Explicit return type

Custom Hook Template

Use this template for new hooks:

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

/**
 * Hook description: What it does and when to use it
 *
 * @example
 * const value = useCustomHook();
 */
export function useCustomHook() {
  // 1. State declarations
  const [state, setState] = useState<Type>(initialValue);

  // 2. Refs (for values that don't cause re-renders)
  const refValue = useRef<Type>(initialValue);

  // 3. Callbacks (for stable function references)
  const handleEvent = useCallback(() => {
    // Event handling logic
  }, [/* dependencies */]);

  // 4. Effects (side effects, subscriptions)
  useEffect(() => {
    // Setup
    // ...

    // Cleanup
    return () => {
      // Cleanup logic
    };
  }, [/* dependencies */]);

  // 5. Return value (keep API minimal)
  return state;
  // or
  return { state, setState, handleEvent };
}

Hook Best Practices

1. Naming

// ✅ Correct
useScrollSpy
useTocObserver
useScrollPosition
useMediaQuery

// ❌ Wrong
scrollSpy
ObserverHook
scrollPositionHook

2. Return Values

// ✅ Single value when simple
return activeId;

// ✅ Object when multiple values
return { activeId, setActiveId, isScrolling };

// ❌ Too many values
return { value1, value2, value3, value4, value5 }; // Too complex

3. Dependencies

// ✅ Correct dependencies
useEffect(() => {
  doSomething(value);
}, [value]); // value is used

// ❌ Missing dependencies
useEffect(() => {
  doSomething(value);
}, []); // value not in deps!

// ✅ ESLint exhaustive-deps will catch this

4. Cleanup

// ✅ Always cleanup event listeners
useEffect(() => {
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, [handleScroll]);

// ✅ Always cleanup observers
useEffect(() => {
  const observer = new IntersectionObserver(...);
  // observe elements
  return () => observer.disconnect();
}, []);

// ✅ Always cleanup timers
useEffect(() => {
  const timer = setTimeout(...);
  return () => clearTimeout(timer);
}, []);

5. Performance

// ✅ Use passive listeners for scroll/touch
{ passive: true }

// ✅ Use useCallback for stable references
const handler = useCallback(() => {...}, [deps]);

// ✅ Use useRef to avoid re-renders
const ref = useRef(value);

// ✅ Debounce/throttle expensive operations
const debouncedHandler = useMemo(
  () => debounce(handler, 100),
  [handler]
);

Common Hook Patterns

Scroll Position Hook

export function useScrollPosition() {
  const [scrollY, setScrollY] = useState(0);

  useEffect(() => {
    const handleScroll = () => {
      setScrollY(window.scrollY);
    };

    window.addEventListener('scroll', handleScroll, { passive: true });
    handleScroll(); // Initial value

    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  return scrollY;
}

Scroll Direction Hook

export function useScrollDirection() {
  const [scrollDirection, setScrollDirection] = useState<'up' | 'down'>('up');
  const lastScrollY = useRef(0);

  useEffect(() => {
    const handleScroll = () => {
      const currentScrollY = window.scrollY;

      if (currentScrollY > lastScrollY.current) {
        setScrollDirection('down');
      } else if (currentScrollY < lastScrollY.current) {
        setScrollDirection('up');
      }

      lastScrollY.current = currentScrollY;
    };

    window.addEventListener('scroll', handleScroll, { passive: true });
    return () => window.removeEventListener('scroll', handleScroll);
  }, []);

  return scrollDirection;
}

Media Query Hook

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

  useEffect(() => {
    const media = window.matchMedia(query);
    setMatches(media.matches);

    const listener = (e: MediaQueryListEvent) => {
      setMatches(e.matches);
    };

    media.addEventListener('change', listener);
    return () => media.removeEventListener('change', listener);
  }, [query]);

  return matches;
}

// Usage
const isMobile = useMediaQuery('(max-width: 768px)');

Debounce Hook

export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(handler);
  }, [value, delay]);

  return debouncedValue;
}

// Usage
const debouncedSearch = useDebounce(searchTerm, 300);

Previous Value Hook

export function usePrevious<T>(value: T): T | undefined {
  const ref = useRef<T>();

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
}

Integration with Components

Usage Pattern

"use client";

import { useScrollSpy } from '@/hooks/useScrollSpy';
import { HEADER_OFFSET } from '@/constants/ui';

export function TableOfContents({ headings }: Props) {
  // 1. Use the hook
  const { activeId, setPositions } = useScrollSpy();

  // 2. Update positions when headings change
  useEffect(() => {
    const positions = new Map();
    headings.forEach(heading => {
      const element = document.getElementById(heading.id);
      if (element) {
        positions.set(heading.id, element.offsetTop - HEADER_OFFSET);
      }
    });
    setPositions(positions);
  }, [headings, setPositions]);

  // 3. Use the returned value
  return (
    <nav>
      {headings.map(heading => (
        <a
          key={heading.id}
          className={activeId === heading.id ? 'active' : ''}
        >
          {heading.text}
        </a>
      ))}
    </nav>
  );
}

TypeScript Patterns

Generic Hooks

export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T) => void] {
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch {
      return initialValue;
    }
  });

  const setValue = (value: T) => {
    setStoredValue(value);
    window.localStorage.setItem(key, JSON.stringify(value));
  };

  return [storedValue, setValue];
}

Return Type Inference

// ✅ Let TypeScript infer when possible
export function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);
  const toggle = () => setValue(v => !v);
  return [value, toggle] as const; // as const for tuple
}

// Result: [boolean, () => void]

Hook Testing Checklist

  • Hook is pure (same inputs → same outputs)
  • All dependencies in useEffect arrays
  • Cleanup functions defined where needed
  • Event listeners use { passive: true } for performance
  • useCallback used for stable function references
  • useRef used for values that don't need re-renders
  • Type safety: explicit return type or inferred correctly
  • JSDoc comments for complex hooks
  • Constants imported from @/constants/ui
  • File named use[Feature].ts in /src/hooks/

Common Mistakes

Mistake 1: Missing Cleanup

// ❌ Wrong
useEffect(() => {
  window.addEventListener('scroll', handleScroll);
}, []); // Missing cleanup!

// ✅ Correct
useEffect(() => {
  window.addEventListener('scroll', handleScroll);
  return () => window.removeEventListener('scroll', handleScroll);
}, []);

Mistake 2: Incorrect Dependencies

// ❌ Wrong
useEffect(() => {
  fetchData(id);
}, []); // id should be in deps!

// ✅ Correct
useEffect(() => {
  fetchData(id);
}, [id]);

Mistake 3: Using State Instead of Ref

// ❌ Wrong (causes unnecessary re-renders)
const [lastScrollY, setLastScrollY] = useState(0);

// ✅ Correct (no re-renders)
const lastScrollY = useRef(0);

Quick Reference

// Scroll-based pattern
const [value, setValue] = useState(initial);
const handleScroll = useCallback(() => {...}, []);
useEffect(() => {
  window.addEventListener('scroll', handleScroll, { passive: true });
  return () => window.removeEventListener('scroll', handleScroll);
}, [handleScroll]);

// Observer-based pattern
const observerRef = useRef<IntersectionObserver | null>(null);
useEffect(() => {
  observerRef.current = new IntersectionObserver(...);
  // observe elements
  return () => observerRef.current?.disconnect();
}, []);

// Processing pattern
const [data, setData] = useState<Type[]>([]);
useEffect(() => {
  const processed = processData();
  setData(processed);
}, [dependency]);

Remember: Hooks are for reusable logic. If it's only used once, consider keeping it in the component.