2026

4. Februar 2026

useEncapsulation: Warum jeder React Hook ein Zuhause verdient

Custom Hooks sind nicht nur praktisch. Sie sind das wichtigste Architektur-Werkzeug in modernem React. Dieser Artikel erklärt wann, warum und wie man Hooks kapselt. Mit Patterns, Anti-Patterns und Praxisbeispielen.

S
Sascha Becker
Author

20 Min. Lesezeit

useEncapsulation: Warum jeder React Hook ein Zuhause verdient

useEncapsulation: Warum jeder React Hook ein Zuhause verdient

Es gibt einen Moment im Leben jeder React-Komponente, in dem sie eine Grenze überschreitet. Es fängt harmlos an, ein einzelnes useState, vielleicht ein useEffect zum Daten laden. Dann fügt jemand einen Toggle hinzu. Dann ein Formularfeld. Dann ein Subscription. Ehe man sichs versieht, liest sich die Komponenten-Funktion wie ein Bewusstseinsstrom: State-Deklarationen, Event-Handler, Effects und Refs wild durcheinander, ohne erkennbare Struktur.

Der Code funktioniert noch. Aber ihn zu lesen erfordert, die gesamte Funktion im Kopf zu behalten und mental zu gruppieren: „diese drei Zeilen gehören zusammen" und „dieser Handler gehört zu diesem State." Die Komponente ist zu einer flachen Liste von Implementierungsdetails ohne Nähte geworden.

Custom Hooks lösen dieses Problem. Nicht durch Abstraktion um der Abstraktion willen, sondern indem sie zusammengehöriger Logik einen Namen und eine Grenze geben. Sie sind Reacts Antwort auf Kapselung, und das am meisten unterschätzte Architektur-Werkzeug im Ökosystem.

Das Problem: Verstreuter State

Betrachten wir eine Komponente, die ein Modal und eine Sucheingabe verwaltet:

tsx
function UserDirectory() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [users, setUsers] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(false);
const openModal = () => setIsModalOpen(true);
const closeModal = () => setIsModalOpen(false);
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
setSearchQuery(e.target.value);
};
const resetSearch = () => setSearchQuery('');
useEffect(() => {
setIsLoading(true);
fetchUsers(searchQuery)
.then(setUsers)
.finally(() => setIsLoading(false));
}, [searchQuery]);
return (
// ... JSX das alles oben Genannte verwendet
);
}

Vier State-Variablen. Vier Handler. Ein Effect. Alles in denselben Funktionskörper geworfen. Der Modal-State (isModalOpen, openModal, closeModal) hat nichts mit der Such-Logik (searchQuery, handleSearchChange, resetSearch) zu tun, aber sie sitzen nebeneinander, nur getrennt durch die Reihenfolge, in der man sie zufällig geschrieben hat.

Das ist kein Lesbarkeitsproblem. Es ist ein strukturelles Problem. Wenn die Komponente wächst, steigen die mentalen Kosten, um zu verstehen welche Teile zusammengehören, linear mit jedem neuen Hook-Aufruf.

Die Lösung: Zusammengehöriger Logik einen Namen geben

Die Modal-Logik in einen Custom Hook extrahieren:

tsx
function useModal(initialState = false) {
const [isOpen, setIsOpen] = useState(initialState);
const open = () => setIsOpen(true);
const close = () => setIsOpen(false);
const toggle = () => setIsOpen((prev) => !prev);
return { isOpen, open, close, toggle };
}

Die Such-Logik in einen weiteren:

tsx
function useSearch(fetcher: (query: string) => Promise<User[]>) {
const [query, setQuery] = useState("");
const [results, setResults] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(false);
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
setQuery(e.target.value);
};
const reset = () => setQuery("");
useEffect(() => {
let cancelled = false;
setIsLoading(true);
fetcher(query)
.then((data) => {
if (!cancelled) setResults(data);
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
}, [query, fetcher]);
return { query, results, isLoading, handleChange, reset };
}

Jetzt liest sich die Komponente wie ein Inhaltsverzeichnis:

tsx
function UserDirectory() {
const modal = useModal();
const search = useSearch(fetchUsers);
return (
// ... JSX mit modal.isOpen, search.results, etc.
);
}

Zwei Zeilen. Zwei benannte Konzepte. Die Implementierungsdetails sind nicht verschwunden, sie wurden an einen Ort verlagert, wo man sie isoliert verstehen kann.

Beachte, dass der extrahierte useSearch-Hook jetzt ein Cleanup-Flag (cancelled) enthält, das in der ursprünglichen Inline-Version fehlte. Das ist kein Zufall, die Isolierung der Fetch-Logik in einer eigenen Funktion machte das fehlende Cleanup offensichtlich. Wenn ein Effect zwischen zwanzig anderen Zeilen in einer Komponente lebt, übersieht man leicht ein fehlendes Return. In einem fokussierten Hook springt die Lücke einem ins Auge.

Das bringt mehr als eine kürzere Komponente:

  • Abhängigkeiten werden explizit. Die Funktionssignatur eines Custom Hooks ist seine Abhängigkeitsliste. useSearch(fetcher) verrät auf einen Blick: Dieser Hook hängt von einer Fetcher-Funktion ab. Sonst nichts von der Außenwelt.
  • Interna können sich frei weiterentwickeln. Der useModal-Hook verwendet heute useState. Morgen könntest du ihn auf useReducer umbauen für Exit-Animationen. Die konsumierende Komponente ändert sich nicht, sie ruft weiterhin modal.open() auf und liest modal.isOpen. Der Vertrag ist stabil, auch wenn sich die Implementierung weiterentwickelt.
  • Testbarkeit verbessert sich dramatisch. Eine 200-Zeilen-Komponente zu testen, die UI mit Datenabruf und Event-Handling vermischt, ist mühsam. useSearch isoliert mit einem gemockten Fetcher zu testen ist unkompliziert. Man testet die Logik, nicht das DOM.
  • Wiederverwendung passiert natürlich. Du hast useModal nicht für Wiederverwendung geschrieben. Du hast ihn für Kapselung geschrieben. Aber jetzt kann jedes Modal in deiner App ihn verwenden. Wiederverwendung ist ein Nebeneffekt guter Struktur, nicht das Ziel.

tsx
type ModalState = { isOpen: boolean; isAnimating: boolean };
type ModalAction =
| { type: "OPEN" }
| { type: "CLOSE" }
| { type: "ANIMATION_END" };
function modalReducer(state: ModalState, action: ModalAction): ModalState {
switch (action.type) {
case "OPEN":
return { isOpen: true, isAnimating: true };
case "CLOSE":
return { isOpen: false, isAnimating: true };
case "ANIMATION_END":
return { ...state, isAnimating: false };
default:
return state;
}
}
function useModal(initialState = false) {
const [state, dispatch] = useReducer(modalReducer, {
isOpen: initialState,
isAnimating: false,
});
const open = useCallback(() => dispatch({ type: "OPEN" }), []);
const close = useCallback(() => dispatch({ type: "CLOSE" }), []);
const toggle = useCallback(() => {
dispatch(state.isOpen ? { type: "CLOSE" } : { type: "OPEN" });
}, [state.isOpen]);
return {
isOpen: state.isOpen,
isAnimating: state.isAnimating,
open,
close,
toggle,
};
}

Die konsumierende Komponente ruft weiterhin modal.open() auf. Sie hat jetzt nur zusätzlich Zugriff auf modal.isAnimating, wenn sie es braucht.

Benennung: Der schwierigste Teil

Der Name eines Custom Hooks ist seine Dokumentation. Wenn er falsch gewählt ist, wird die Abstraktion zu einer Black Box, der niemand vertraut.

Das use-Präfix ist nicht verhandelbar

Reacts Linter erzwingt das use-Präfix. Ohne es kann React nicht überprüfen, ob deine Funktion die Regeln der Hooks befolgt (keine bedingten Aufrufe, keine Aufrufe in Schleifen). Das ist keine Konvention: es ist eine technische Anforderung.

Benennungsmuster
MusterWann verwendenBeispiele
use + SubstantivVerwaltet ein bestimmtes State-StückuseModal, useAuth, useCart
use + VerbFührt eine Aktion oder Seiteneffekt aususeFetch, useDebounce, useIntersect
use + Substantiv + StateBetont State-ManagementuseFormState, useSelectionState
use + On/Handle + EventKapselt Event-Handler-LogikuseOnClickOutside, useOnKeyPress
Benenne das Verhalten, nicht die Implementierung
tsx
// Schlecht - der Name beschreibt die Implementierung
function useStateWithCallback() { ... }
// Gut - der Name beschreibt das Verhalten
function useNotification() { ... }

Ein Entwickler, der useNotification() liest, weiß was es tut. Ein Entwickler, der useStateWithCallback() liest, weiß wie es intern funktioniert: genau das Detail, das der Hook verbergen sollte.

Wann eine Funktion KEIN Hook sein sollte

Wenn deine Funktion intern keine React Hooks aufruft, gib ihr nicht das use-Präfix. Es ist eine normale Hilfsfunktion:

tsx
// Das ist KEIN Hook - es ruft keine Hooks auf
function sortUsers(users: User[], key: keyof User): User[] {
return [...users].sort((a, b) => /* ... */);
}
// Das IST ein Hook - es verwendet useState
function useSortedUsers(users: User[], key: keyof User) {
const [sortKey, setSortKey] = useState(key);
const sorted = useMemo(() => sortUsers(users, sortKey), [users, sortKey]);
return { sorted, sortKey, setSortKey };
}

Das use-Präfix ist ein Versprechen: „Ich enthalte React-State oder -Effects." Dieses Versprechen zu brechen verwirrt sowohl den Linter als auch deine Kollegen.

Patterns

State + Handler

Die häufigste Custom-Hook-Form: Ein State mit seinen zugehörigen Handlern gruppieren.

tsx
function useCounter(initial = 0) {
const [count, setCount] = useState(initial);
const increment = useCallback(() => setCount((c) => c + 1), []);
const decrement = useCallback(() => setCount((c) => c - 1), []);
const reset = useCallback(() => setCount(initial), [initial]);
return { count, increment, decrement, reset };
}

Einfach. Fokussiert. Jede Zeile dient einem einzigen Konzept.

Abgeleiteter State

Wenn du Werte aus bestehendem State berechnen musst, leite sie innerhalb des Hooks ab, statt sie separat zu speichern:

tsx
function usePasswordStrength(password: string) {
const strength = useMemo(() => {
if (password.length === 0) return "none";
if (password.length < 6) return "weak";
if (password.length < 10) return "medium";
const hasUpper = /[A-Z]/.test(password);
const hasNumber = /\d/.test(password);
const hasSpecial = /[^A-Za-z0-9]/.test(password);
return hasUpper && hasNumber && hasSpecial ? "strong" : "medium";
}, [password]);
const colors = {
none: "grey",
weak: "red",
medium: "orange",
strong: "green",
} as const;
const color = colors[strength];
return { strength, color };
}

Kein useEffect. Kein extra State. Nur abgeleitete Werte. Das ist eines der mächtigsten und am wenigsten genutzten Patterns, viele Entwickler greifen zu useEffect + useState, wenn useMemo allein genügen würde.

Komposition

Hooks können andere Hooks aufrufen. So baut man komplexes Verhalten aus einfachen Bausteinen:

tsx
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
function useDebouncedSearch(fetcher: (q: string) => Promise<User[]>) {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 300);
const [results, setResults] = useState<User[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!debouncedQuery) {
setResults([]);
return;
}
const controller = new AbortController();
setIsLoading(true);
fetcher(debouncedQuery)
.then((data) => {
if (!controller.signal.aborted) setResults(data);
})
.finally(() => {
if (!controller.signal.aborted) setIsLoading(false);
});
return () => controller.abort();
}, [debouncedQuery, fetcher]);
return { query, setQuery, results, isLoading };
}

useDebouncedSearch komponiert useDebounce, ohne dessen Interna zu kennen. Jeder Hook löst ein Problem. Zusammen lösen sie ein komplexes.

Return-Form: Objekt vs. Tuple

Verwende Tuples, wenn der Hook zwei oder drei verwandte Werte zurückgibt (wie useState selbst). Verwende Objekte, wenn der Hook mehr als drei Werte zurückgibt oder die Werte keine natürliche Reihenfolge haben. Objekte erlauben Destructuring nach Name, was selbstdokumentierend ist:

tsx
// Tuple - spiegelt useState, Position traegt Bedeutung
function useToggle(initial = false): [boolean, () => void] {
const [value, setValue] = useState(initial);
const toggle = useCallback(() => setValue((v) => !v), []);
return [value, toggle];
}
const [isVisible, toggleVisibility] = useToggle(); // Klar
const { user, logout } = useAuth(); // Auch klar

Anti-Patterns

1. Der Gott-Hook

Ein Hook, der alles macht, ist nicht besser als eine Komponente, die alles macht:

tsx
// Das nicht machen
function useApp() {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState("light");
const [notifications, setNotifications] = useState([]);
const [cart, setCart] = useState([]);
const [isMenuOpen, setIsMenuOpen] = useState(false);
// ... 200 weitere Zeilen
}

Wenn dein Hook unzusammenhängende Belange verwaltet, kapselt er nicht, er verlagert nur das Chaos. Jeder Belang sollte ein eigener Hook sein: useAuth, useTheme, useNotifications, useCart, useMenu.

2. Die voreilige Abstraktion

Nicht jeder useState-Aufruf braucht einen Custom Hook:

tsx
// Dieser Hook bringt keinen Mehrwert
function useIsOpen() {
const [isOpen, setIsOpen] = useState(false);
return { isOpen, setIsOpen };
}

Das ist nur useState mit Extra-Schritten. Ein Custom Hook sollte seine Existenz verdienen, indem er mehrere zusammengehörige Logik-Stücke gruppiert. State, Effects, Handler, abgeleitete Werte, nicht indem er ein einzelnes Primitiv umwickelt.

3. useEffect als State-Synchronisierer

Das häufigste Anti-Pattern in React-Codebasen:

tsx
// Das nicht machen
function useFormattedPrice(cents: number) {
const [formatted, setFormatted] = useState("");
useEffect(() => {
setFormatted(`$${(cents / 100).toFixed(2)}`);
}, [cents]);
return formatted;
}

Das erzeugt einen unnötigen Render-Zyklus: Rendern mit veraltetem Wert, Effect feuert, State-Update, erneutes Rendern mit korrektem Wert. Die Lösung ist trivial:

tsx
// Stattdessen das machen
function useFormattedPrice(cents: number) {
return useMemo(() => `$${(cents / 100).toFixed(2)}`, [cents]);
}

Oder noch einfacher, das muss überhaupt kein Hook sein:

tsx
function formatPrice(cents: number): string {
return `$${(cents / 100).toFixed(2)}`;
}

useEffect ist für Seiteneffekte: Netzwerk-Requests, Subscriptions, DOM-Mutationen, Timer. Wenn du es verwendest, um Daten aus Props oder State in anderen State zu transformieren, kämpfst du gegen React, statt mit ihm zu arbeiten.

4. Fehlende Bereinigung

Jede Subscription, jeder Timer oder Listener, der in einem useEffect eingerichtet wird, muss bereinigt werden:

tsx
// Memory-Leak beim Unmount
function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
useEffect(() => {
const handler = () =>
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
window.addEventListener("resize", handler);
// Fehlt: return () => window.removeEventListener('resize', handler);
}, []);
return size;
}

Das leakt. Jedes Mal wenn die Komponente mountet, wird ein neuer Listener hinzugefügt und nie entfernt. Die Lösung:

tsx
function useWindowSize() {
const [size, setSize] = useState({
width: typeof window !== "undefined" ? window.innerWidth : 0,
height: typeof window !== "undefined" ? window.innerHeight : 0,
});
useEffect(() => {
const handler = () =>
setSize({
width: window.innerWidth,
height: window.innerHeight,
});
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []);
return size;
}
5. Veraltete Closures

Closures erfassen Variablen zum Zeitpunkt ihrer Erstellung. Wenn ein Callback auf State referenziert, aber nicht neu erstellt wird wenn sich der State ändert, liest er veraltete Werte:

tsx
// Bug: Der Alert zeigt immer den initialen Count
function useCounter() {
const [count, setCount] = useState(0);
const showCount = useCallback(() => {
setTimeout(() => alert(count), 1000);
}, []); // 'count' fehlt in den Dependencies
return { count, setCount, showCount };
}

Die eslint-plugin-react-hooks exhaustive-deps-Regel fängt das ab. Wenn der Linter sagt, eine Abhängigkeit fehlt, hat er fast immer Recht.

6. JSX aus Hooks zurückgeben

Hooks geben Daten und Handler zurück. Sie geben keine UI zurück:

tsx
// Das nicht machen
function useErrorBanner(error: string | null) {
const banner = error ? <div className="error">{error}</div> : null;
return { banner };
}
// Stattdessen - Daten zurückgeben, Komponente rendert
function useError() {
const [error, setError] = useState<string | null>(null);
const clear = useCallback(() => setError(null), []);
return { error, setError, clear };
}

Die Komponente bestimmt die UI. Der Hook verwaltet den State. Jeder macht das, worin er gut ist.

Wann man nicht extrahieren sollte

Kapselung ist nicht die Antwort auf jedes Problem. Manchmal ist eine Komponente mit drei useState-Aufrufen und zwei Handlern so wie sie ist perfekt lesbar. Einen Hook zu extrahieren, der nur einmal verwendet wird und nur eine State-Variable enthält, fügt eine Indirektionsebene hinzu, ohne Klarheit zu schaffen.

Eine gute Faustregel: Extrahiere, wenn die Logik ein Konzept mit einem Namen bildet. Wenn du den Hook nicht benennen kannst, ohne seine Implementierung zu beschreiben („useStateAndEffectFürDasDing"), gehört die Logik wahrscheinlich noch nicht in einen Hook. Warte, bis sich das Konzept herauskristallisiert: sei es durch Wiederverwendung, Komplexität, oder das einfache Bedürfnis, die Komponente zu lesen, ohne in Details zu ertrinken.

Das Ziel ist nicht null Hooks in Komponenten. Das Ziel ist, dass jeder Hook-Aufruf in einer Komponente sich wie ein Satz liest: Diese Komponente verwendet Authentifizierung, ein Modal und eine entprellte Suche. Wenn sich die Komponente wie ein Absatz voller Absichten liest statt wie eine Wand aus Mechanik, hast du die richtige Extraktionsebene gefunden.

Tooling: Maschinen die Disziplin durchsetzen lassen

Gute Gewohnheiten sind leichter aufrechtzuerhalten, wenn die Toolchain einem den Rücken stärkt. Man muss sich nicht allein auf Code Reviews verlassen, um verstreute Hooks, Gott-Hooks oder veraltete Closures zu finden. Mehrere ESLint-Regeln, einige React-spezifisch, einige allgemein, können die in diesem Artikel besprochenen Leitplanken automatisieren.

Die Grundlage. Jedes React-Projekt sollte dies aktiviert haben:

json
{
"rules": {
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn"
}
}

rules-of-hooks stellt sicher, dass Hooks nie bedingt oder in Schleifen aufgerufen werden. exhaustive-deps fängt veraltete Closures ab, indem es warnt, wenn eine Abhängigkeit in useEffect, useMemo oder useCallback fehlt. Deaktiviere es nicht, wenn du dagegen kämpfst, muss wahrscheinlich der Code umstrukturiert werden, nicht die Regel unterdrückt.

Ab v6.1.1 kannst du auch additionalHooks verwenden, um die exhaustive-deps-Prüfung auf eigene Custom Hooks anzuwenden, die Dependency-Arrays akzeptieren:

json
{
"react-hooks/exhaustive-deps": [
"warn",
{
"additionalHooks": "(useCustomEffect|useAnimationFrame)"
}
]
}

Kyle Shevlins Plugin, das die Kernthese dieses Artikels durchsetzt: Verwende React Hooks nicht direkt in Komponenten. Seine einzige Regel, prefer-custom-hooks, warnt immer wenn useState, useEffect, useRef oder ein anderer React Hook direkt in einer Komponente statt in einem Custom Hook erscheint.

json
{
"plugins": ["use-encapsulation"],
"rules": {
"use-encapsulation/prefer-custom-hooks": ["warn"]
}
}

Du kannst bestimmte Hooks mit der allow-Option auf eine Whitelist setzen, falls strikte Durchsetzung für deine Codebasis zu aggressiv ist. Verwende das sparsam, ein gelegentliches eslint-disable ist besser als eine pauschale Ausnahme.

Gott-Hooks mit allgemeinen ESLint-Regeln erkennen

Es gibt kein dediziertes "Gott-Hook-Detektor"-Plugin. Aber allgemeine Komplexitätsregeln gelten für Hooks genauso wie für jede andere Funktion:

json
{
"rules": {
"max-lines-per-function": [
"warn",
{
"max": 80,
"skipBlankLines": true,
"skipComments": true
}
],
"complexity": ["warn", 10],
"max-statements": ["warn", 15],
"max-depth": ["warn", 3]
}
}
  • max-lines-per-function erkennt Hooks, die zu gross geworden sind, um sie auf einen Blick zu verstehen. 80 Zeilen sind ein vernünftiger Startwert, genug für einen useReducer mit ein paar Handlern, eng genug um Hooks zu markieren, die fünf unzusammenhängende Belange verwalten.
  • complexity misst zyklomatische Komplexität, die Anzahl unabhängiger Pfade durch die Funktion. Ein Hook mit einer Komplexität von 15 hat zu viele Verzweigungen und sollte aufgeteilt werden.
  • max-statements begrenzt die Anzahl der Anweisungen. Wenn ein Hook 20 const-Deklarationen hat, macht er fast sicher zu viel.
  • max-depth erkennt tief verschachtelte Bedingungen und Schleifen in Hooks.

Keine davon ist React-bewusst, aber Hooks sind Funktionen, und diese Regeln funktionieren bei allen Funktionen.

Wenn du React 19+ mit dem React Compiler verwendest, enthält eslint-plugin-react-hooks jetzt zusätzliche Regeln wie react-hooks/purity und react-hooks/refs, die validieren, ob deine Komponenten und Hooks sicher für automatische Memoization sind. Diese erzwingen Kapselung nicht direkt, belohnen aber gut strukturierte Hooks - je einfacher und reiner dein Hook, desto mehr kann der Compiler optimieren.

Alles zusammen

Eine praktische ESLint-Konfiguration, die die meisten Patterns aus diesem Artikel durchsetzt:

eslint.config.js
import reactHooks from "eslint-plugin-react-hooks";
import useEncapsulation from "eslint-plugin-use-encapsulation";
export default [
{
plugins: {
"react-hooks": reactHooks,
"use-encapsulation": useEncapsulation,
},
rules: {
// Kern-Hook-Korrektheit
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
// Kapselung durchsetzen
"use-encapsulation/prefer-custom-hooks": ["warn"],
// Gott-Hooks erkennen
"max-lines-per-function": [
"warn",
{
max: 80,
skipBlankLines: true,
skipComments: true,
},
],
complexity: ["warn", 10],
"max-statements": ["warn", 15],
},
},
];

Das wird nicht jedes Anti-Pattern finden. Kein Linter ersetzt Urteilsvermögen. Aber es verschiebt den Standard: Statt sich allein auf Disziplin zu verlassen, stupst die Toolchain einen in Richtung Kapselung, markiert Komplexität bevor sie zum Problem wird, und verhindert die häufigsten Korrektheitsfehler automatisch.

Praxisbeispiel: Ein vollständiger useForm-Hook

Um alles zusammenzuführen, hier ein produktionsreifer Form-Hook, der mehrere Patterns kombiniert. State-Management, abgeleiteter State, Validierung und sauberes API-Design:

tsx
type ValidationRule<T> = {
validate: (value: T[keyof T], values: T) => boolean;
message: string;
};
type FieldConfig<T> = {
initialValue: T[keyof T];
rules?: ValidationRule<T>[];
};
type FormConfig<T> = {
[K in keyof T]: FieldConfig<T>;
};
function useForm<T extends Record<string, unknown>>(config: FormConfig<T>) {
type Errors = Partial<Record<keyof T, string>>;
// config wird als stabil erwartet (außerhalb von render definiert oder memoized).
// initialValues einmalig ableiten vermeidet Neuberechnung.
const [initialValues] = useState<T>(() => {
const values = {} as T;
for (const key in config) {
values[key] = config[key].initialValue;
}
return values;
});
const [values, setValues] = useState<T>(initialValues);
const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});
const [isSubmitting, setIsSubmitting] = useState(false);
const errors = useMemo<Errors>(() => {
const result: Errors = {};
for (const key in config) {
const rules = config[key].rules ?? [];
for (const rule of rules) {
if (!rule.validate(values[key], values)) {
result[key] = rule.message;
break;
}
}
}
return result;
}, [values, config]);
const isValid = Object.keys(errors).length === 0;
const setValue = useCallback(<K extends keyof T>(field: K, value: T[K]) => {
setValues((prev) => ({ ...prev, [field]: value }));
}, []);
const setFieldTouched = useCallback((field: keyof T) => {
setTouched((prev) => ({ ...prev, [field]: true }));
}, []);
const reset = useCallback(() => {
setValues(initialValues);
setTouched({});
setIsSubmitting(false);
}, [initialValues]);
const handleSubmit = useCallback(
(onSubmit: (values: T) => Promise<void>) => async (e: FormEvent) => {
e.preventDefault();
setTouched(() => {
const allTouched: Partial<Record<keyof T, boolean>> = {};
for (const key in config) allTouched[key] = true;
return allTouched;
});
setValues((currentValues) => {
const currentErrors: Errors = {};
for (const key in config) {
for (const rule of config[key].rules ?? []) {
if (!rule.validate(currentValues[key], currentValues)) {
currentErrors[key] = rule.message;
break;
}
}
}
if (Object.keys(currentErrors).length > 0) return currentValues;
setIsSubmitting(true);
onSubmit(currentValues).finally(() => setIsSubmitting(false));
return currentValues;
});
},
[config],
);
return {
values,
errors,
touched,
isValid,
isSubmitting,
setValue,
setFieldTouched,
reset,
handleSubmit,
};
}

Verwendung:

tsx
function SignupForm() {
const form = useForm({
email: {
initialValue: "",
rules: [
{
validate: (v) => typeof v === "string" && v.length > 0,
message: "Pflichtfeld",
},
{
validate: (v) => typeof v === "string" && v.includes("@"),
message: "Ungueltige E-Mail",
},
],
},
password: {
initialValue: "",
rules: [
{
validate: (v) => typeof v === "string" && v.length >= 8,
message: "Mindestens 8 Zeichen",
},
],
},
});
return (
<form
onSubmit={form.handleSubmit(async (values) => {
await api.signup(values.email, values.password);
})}
>
<input
value={form.values.email}
onChange={(e) => form.setValue("email", e.target.value)}
onBlur={() => form.setFieldTouched("email")}
/>
{form.touched.email && form.errors.email && (
<span>{form.errors.email}</span>
)}
{/* ... Passwort-Feld, Submit-Button */}
</form>
);
}

Der Hook verwaltet Form-State, Validierung, Touched-Tracking und den Submission-Flow. Er rendert keine Inputs, entscheidet keine Fehler-Stile und diktiert kein Layout. Die Komponente besitzt die UI. Der Hook besitzt die Logik.


S
Geschrieben von
Sascha Becker
Weitere Artikel