Zum Inhalt springen

Wiederverwendbare React-Hooks bauen: Patterns und Anti-Patterns

Wie man wirklich wiederverwendbare Custom Hooks entwirft: Kompositionsmuster, Teststrategien, State-Kapselung und die häufigsten Anti-Patterns.

5 Min. Lesezeit
React-Hooks-Kompositionsdiagramm, das den Datenfluss zwischen Custom Hooks und Komponenten zeigt

Custom Hooks sind Reacts zentrale Abstraktion, um zustandsbehaftete Logik zwischen Komponenten zu teilen. Aber die meisten Custom Hooks sind nicht wirklich wiederverwendbar – sie sind nur useEffect-Wrapper, die Komponentenlogik in eine separate Datei verschieben, ohne die API zu verbessern. Ein gut gestalteter Hook kapselt Komplexität, stellt eine minimale Schnittstelle bereit und lässt sich natürlich mit anderen Hooks komponieren.

Der Unterschied zwischen einem wiederverwendbaren Hook und einer einmaligen Extraktion ist derselbe wie zwischen einer Bibliotheksfunktion und copy-pastetem Code. Das eine ist für seine Nutzer gedacht. Das andere liegt nur zufällig in einer separaten Datei.

Der Single-Responsibility-Hook

Jeder Hook sollte genau eine Aufgabe verwalten. Wenn dein Hook Fetching, Caching und Polling macht, sind das drei Hooks im 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
}

Stabile Referenzen mit useCallback und useMemo

Hooks, die Funktionen oder Objekte zurückgeben, erzeugen bei jedem Render neue Referenzen. Das bricht React.memo und verursacht unnötige Re-Renders in Kindkomponenten.

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');

Das Tupel als Rückgabewert ([value, setter]) spiegelt useState wider und macht die API vertraut. Das useCallback sorgt dafür, dass die Setter-Funktion eine stabile Referenz hat.

Komplexe State Machines kapseln

Hooks zeigen ihre Stärke, wenn sie die Komplexität einer State Machine hinter einer einfachen Schnittstelle verstecken.

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>
  );
}

Die Komponente verwaltet keine Zustandsübergänge. Sie ruft Aktionen auf (selectFile, upload, reset) und liest abgeleiteten State (canUpload, status). Der Hook besitzt die gesamte Validierungs- und Übergangslogik.

Custom Hooks testen

Teste Hooks über ihre öffentliche API – die Werte und Funktionen, die sie zurückgeben – nicht über ihren internen 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 rendert den Hook in einer isolierten Testkomponente. act umschließt State-Updates. Teste die zurückgegebenen Werte und Verhaltensweisen, nicht Implementierungsdetails.

Anti-Patterns, die man vermeiden sollte

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

Die wichtigsten Erkenntnisse

  1. Ein Hook, eine Aufgabe – wenn er Fetching, Caching und Polling macht, teile ihn in drei Hooks auf
  2. Stabilisiere zurückgegebene Referenzen mit useCallback und useMemo – instabile Referenzen brechen React.memo
  3. Kapsle State Machines – stelle Aktionen und abgeleiteten State bereit, keine rohen setState-Funktionen
  4. Gib Tupel für einfache Hooks zurück ([value, setter]) und Objekte für komplexe
  5. Teste über die öffentliche API – prüfe zurückgegebene Werte, nicht den internen State
  6. Vermeide triviale Wrapper – wenn der Hook nur ein einziges useEffect ist, erzeugt er Indirektion ohne Mehrwert
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX