Skip to content

Building Reusable React Hooks: Patterns and Anti-Patterns

How to design custom React hooks that are genuinely reusable: composition patterns, testing strategies, state encapsulation and the anti-patterns to avoid.

5 min read
React hooks composition diagram showing data flow between custom hooks and components

Custom hooks are React's primary abstraction for sharing stateful logic between components. But most custom hooks are not truly reusable — they are just useEffect wrappers that move component logic into a separate file without improving the API. A well-designed hook encapsulates complexity, exposes a minimal interface, and composes with other hooks naturally.

The difference between a reusable hook and a one-off extraction is the same as the difference between a library function and copy-pasted code. One is designed for its consumers. The other just happens to be in a separate file.

The Single Responsibility Hook

Each hook should manage one concern. If your hook does fetching, caching, and polling, it is three hooks wearing a trenchcoat.

tstypescript
// ❌ Kitchen-sink hook — does too much
function useUserDashboard(userId: string) {
  const [user, setUser] = useState<User | null>(null);
  const [posts, setPosts] = useState<Post[]>([]);
  const [notifications, setNotifications] = useState<Notification[]>([]);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    setLoading(true);
    Promise.all([
      fetchUser(userId),
      fetchPosts(userId),
      fetchNotifications(userId),
    ]).then(([u, p, n]) => {
      setUser(u);
      setPosts(p);
      setNotifications(n);
      setLoading(false);
    });
  }, [userId]);
 
  return { user, posts, notifications, loading };
}
// Can't reuse the fetch logic independently
// Can't use posts fetching without notifications
tstypescript
// ✅ Focused hooks that compose
function useFetch<T>(url: string): {
  data: T | null;
  loading: boolean;
  error: Error | null;
} {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
 
  useEffect(() => {
    let cancelled = false;
    setLoading(true);
 
    fetch(url)
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(result => {
        if (!cancelled) {
          setData(result);
          setLoading(false);
        }
      })
      .catch(err => {
        if (!cancelled) {
          setError(err);
          setLoading(false);
        }
      });
 
    return () => { cancelled = true; };
  }, [url]);
 
  return { data, loading, error };
}
 
// Compose in the component
function UserDashboard({ userId }: { userId: string }) {
  const user = useFetch<User>(`/api/users/${userId}`);
  const posts = useFetch<Post[]>(`/api/users/${userId}/posts`);
  const notifications = useFetch<Notification[]>(`/api/notifications`);
 
  if (user.loading) return <Skeleton />;
  // Each data source is independent
}

Stable References with useCallback and useMemo

Hooks that return functions or objects create new references on every render, breaking React.memo and causing unnecessary re-renders in child components.

tstypescript
// ❌ New object reference on every render
function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });
 
  // This function is recreated every render
  const setStoredValue = (newValue: T) => {
    setValue(newValue);
    localStorage.setItem(key, JSON.stringify(newValue));
  };
 
  // New object every render — consumers always re-render
  return { value, setValue: setStoredValue };
}
tstypescript
// ✅ Stable references with useCallback
function useLocalStorage<T>(key: string, initialValue: T) {
  const [value, setValue] = useState<T>(() => {
    const stored = localStorage.getItem(key);
    return stored ? JSON.parse(stored) : initialValue;
  });
 
  const setStoredValue = useCallback(
    (newValue: T | ((prev: T) => T)) => {
      setValue(prev => {
        const resolved = typeof newValue === 'function'
          ? (newValue as (prev: T) => T)(prev)
          : newValue;
        localStorage.setItem(key, JSON.stringify(resolved));
        return resolved;
      });
    },
    [key]
  );
 
  return [value, setStoredValue] as const;
}
 
// Usage — tuple return like useState
const [theme, setTheme] = useLocalStorage('theme', 'light');
setTheme('dark');
setTheme(prev => prev === 'light' ? 'dark' : 'light');

The tuple return ([value, setter]) mirrors useState, making the API familiar. The useCallback ensures the setter function has a stable reference.

Encapsulating Complex State Machines

Hooks shine when they hide state machine complexity behind a simple interface.

tstypescript
// ❌ Exposing state machine internals to the component
function FileUploader() {
  const [file, setFile] = useState<File | null>(null);
  const [progress, setProgress] = useState(0);
  const [status, setStatus] = useState<'idle' | 'uploading' | 'done' | 'error'>('idle');
  const [error, setError] = useState<string | null>(null);
 
  const upload = async () => {
    if (!file) return;
    setStatus('uploading');
    setProgress(0);
    try {
      await uploadFile(file, (p) => setProgress(p));
      setStatus('done');
    } catch (err) {
      setStatus('error');
      setError(err.message);
    }
  };
 
  const reset = () => {
    setFile(null);
    setProgress(0);
    setStatus('idle');
    setError(null);
  };
 
  // Component manages all state transitions
}
tstypescript
// ✅ Hook encapsulates the state machine
interface UploadState {
  status: 'idle' | 'selecting' | 'uploading' | 'done' | 'error';
  file: File | null;
  progress: number;
  error: string | null;
  url: string | null;
}
 
function useFileUpload(options?: { maxSizeMB?: number; accept?: string[] }) {
  const [state, setState] = useState<UploadState>({
    status: 'idle',
    file: null,
    progress: 0,
    error: null,
    url: null,
  });
 
  const selectFile = useCallback((file: File) => {
    const maxSize = (options?.maxSizeMB ?? 10) * 1024 * 1024;
    if (file.size > maxSize) {
      setState(s => ({
        ...s,
        status: 'error',
        error: `File too large (max ${options?.maxSizeMB ?? 10}MB)`,
      }));
      return;
    }
 
    if (options?.accept && !options.accept.includes(file.type)) {
      setState(s => ({
        ...s,
        status: 'error',
        error: `Invalid file type. Accepted: ${options.accept!.join(', ')}`,
      }));
      return;
    }
 
    setState({ status: 'selecting', file, progress: 0, error: null, url: null });
  }, [options?.maxSizeMB, options?.accept]);
 
  const upload = useCallback(async () => {
    if (!state.file || state.status !== 'selecting') return;
 
    setState(s => ({ ...s, status: 'uploading', progress: 0 }));
 
    try {
      const url = await uploadFile(state.file, (progress) => {
        setState(s => ({ ...s, progress }));
      });
      setState(s => ({ ...s, status: 'done', url, progress: 100 }));
    } catch (err) {
      setState(s => ({
        ...s,
        status: 'error',
        error: err instanceof Error ? err.message : 'Upload failed',
      }));
    }
  }, [state.file, state.status]);
 
  const reset = useCallback(() => {
    setState({
      status: 'idle',
      file: null,
      progress: 0,
      error: null,
      url: null,
    });
  }, []);
 
  return {
    ...state,
    selectFile,
    upload,
    reset,
    canUpload: state.status === 'selecting',
  } as const;
}
 
// Clean component usage
function FileUploader() {
  const uploader = useFileUpload({ maxSizeMB: 5, accept: ['image/png', 'image/jpeg'] });
 
  return (
    <div>
      <input type="file" onChange={e => {
        if (e.target.files?.[0]) uploader.selectFile(e.target.files[0]);
      }} />
      {uploader.canUpload && <button onClick={uploader.upload}>Upload</button>}
      {uploader.status === 'uploading' && <Progress value={uploader.progress} />}
      {uploader.status === 'done' && <p>Uploaded: {uploader.url}</p>}
      {uploader.error && <p className="error">{uploader.error}</p>}
    </div>
  );
}

The component does not manage state transitions. It calls actions (selectFile, upload, reset) and reads derived state (canUpload, status). The hook owns all validation and transition logic.

Testing Custom Hooks

Test hooks through their public API — the values and functions they return — not their internal state.

tstypescript
import { renderHook, act } from '@testing-library/react';
 
describe('useLocalStorage', () => {
  beforeEach(() => localStorage.clear());
 
  it('returns initial value when storage is empty', () => {
    const { result } = renderHook(() => useLocalStorage('key', 'default'));
    expect(result.current[0]).toBe('default');
  });
 
  it('persists value to localStorage', () => {
    const { result } = renderHook(() => useLocalStorage('theme', 'light'));
 
    act(() => {
      result.current[1]('dark');
    });
 
    expect(result.current[0]).toBe('dark');
    expect(localStorage.getItem('theme')).toBe('"dark"');
  });
 
  it('supports updater function', () => {
    const { result } = renderHook(() => useLocalStorage('count', 0));
 
    act(() => {
      result.current[1](prev => prev + 1);
    });
 
    expect(result.current[0]).toBe(1);
  });
 
  it('reads existing value from storage', () => {
    localStorage.setItem('theme', '"dark"');
    const { result } = renderHook(() => useLocalStorage('theme', 'light'));
    expect(result.current[0]).toBe('dark');
  });
});

renderHook renders the hook in an isolated test component. act wraps state updates. Test the returned values and behaviors, not implementation details.

Anti-Patterns to Avoid

tstypescript
// ❌ Anti-pattern 1: Hook that just wraps a single useEffect
function useDocumentTitle(title: string) {
  useEffect(() => {
    document.title = title;
  }, [title]);
}
// This is so thin it adds indirection without abstraction value.
// Just write the useEffect in the component.
 
// ❌ Anti-pattern 2: Hook that takes too many config options
function useFetch(url, {
  method, headers, body, cache, timeout, retries,
  retryDelay, transform, onSuccess, onError,
  dedupe, polling, pollingInterval, ...rest
}) { /* ... */ }
// This is a fetch wrapper, not a hook. Use a library.
 
// ❌ Anti-pattern 3: Hook that returns too many values
function useForm() {
  return {
    values, errors, touched, dirty, isValid, isSubmitting,
    handleChange, handleBlur, handleSubmit, setFieldValue,
    setFieldError, setFieldTouched, resetForm, validateField,
    validateForm, registerField, unregisterField,
  };
}
// Return an object with methods on it instead of 15 loose values

Key Takeaways

  1. One hook, one concern — if it does fetching and caching and polling, split it into three hooks
  2. Stabilize returned references with useCallback and useMemo — unstable references break React.memo
  3. Encapsulate state machines — expose actions and derived state, not raw setState functions
  4. Return tuples for simple hooks ([value, setter]) and objects for complex ones
  5. Test through the public API — assert on returned values, not internal state
  6. Avoid trivial wrappers — if the hook is just one useEffect, it adds indirection without value
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX