Ein Thema, das mir schon länger auf den Nägeln brennt: Wie trennt man eigentlich Darstellung von Logik in SolidJS? In diesem Artikel zeige ich dir, wie Custom Hooks dabei helfen können, aus einem 400-Zeilen-Monster eine saubere, wartbare Komponente zu machen.
Kennst du das? Du fängst mit einer einfachen Komponente an, und plötzlich macht sie alles:
Ich hatte kürzlich so eine Komponente - einen Kalender-Dialog mit über 400 Zeilen Code. Ein echtes Monster!
const CalendarDialog = () => {
// 15 verschiedene Signals und Stores
const [calendarEntry, setCalendarEntry] = createStore(/* ... */);
const [originalEntry, setOriginalEntry] = createSignal(null);
const [isDeleting, setIsDeleting] = createSignal(false);
// ... und noch 12 weitere
// API-Calls direkt in der Komponente
const handleSave = async () => {
const response = await saveCalendarEntry(calendarEntry);
if (response?.ok) {
// DOM-Manipulation
document.getElementById("snackbar")?.dispatchEvent(/* ... */);
// Event-Dispatching
window.dispatchEvent(new Event("calendar:reload"));
}
};
// 200 Zeilen JSX mit repeating Code
return (
<div>
<input
attr:outline-color={
isFieldChanged(calendarEntry, ["title"])
? "#eb6914"
: undefined
}
/>
<input
attr:outline-color={
isFieldChanged(calendarEntry, ["description"])
? "#eb6914"
: undefined
}
/>
{/* ... 20 weitere Inputs mit der gleichen Logik */}
</div>
);
};
Das Problem: Alles ist gekoppelt, nichts ist testbar, und Änderungen werden zum Alptraum.
Die Idee ist simpel: Wir lagern die gesamte Business-Logik in einen Custom Hook aus. Die Komponente kümmert sich nur noch um die Darstellung.
Für unser Refactoring brauchen wir eigentlich nur SolidJS - aber ich zeige dir auch, wie du es strukturierst:
mkdir hooks
touch hooks/useCalendarEntry.js
touch hooks/useFieldValidation.js
Hier kommt der Kern unserer Lösung:
// hooks/useCalendarEntry.js
import { createSignal } from "solid-js";
import { createStore } from "solid-js/store";
import {
saveCalendarEntry,
editCalendarEntry,
deleteCalendarEntry,
} from "../api/calendar-api";
const initialEntry = {
title: "",
description: "",
contactPerson: {
name: "",
email: "",
phone: "",
},
publishDate: null,
deadlineDate: null,
};
export const useCalendarEntry = () => {
const [entry, setEntry] = createStore(structuredClone(initialEntry));
const [originalEntry, setOriginalEntry] = createSignal(null);
const [isLoading, setIsLoading] = createSignal(false);
const [isEditing, setIsEditing] = createSignal(false);
const [showDeleteButton, setShowDeleteButton] = createSignal(false);
// Entry Management
const resetEntry = () => {
setEntry(structuredClone(initialEntry));
setOriginalEntry(null);
setIsLoading(false);
setIsEditing(false);
setShowDeleteButton(false);
};
const loadEntry = (data) => {
resetEntry();
if (!data) return; // Neuer Eintrag
if (data.id) {
// Bestehenden Eintrag bearbeiten
setEntry(data);
setIsEditing(true);
setShowDeleteButton(true);
} else {
// Neuer Eintrag mit Vorlage
setEntry(data);
// WICHTIG: Deep Clone für Vergleich!
setOriginalEntry(structuredClone(data));
}
};
// API Operations mit strukturierten Rückgaben
const saveEntry = async () => {
setIsLoading(true);
try {
const response = isEditing()
? await editCalendarEntry(entry)
: await saveCalendarEntry(entry);
if (response?.ok) {
return { success: true, message: "Eintrag gespeichert" };
}
return { success: false, error: "Speichern fehlgeschlagen" };
} catch (error) {
return { success: false, error: error.message };
} finally {
setIsLoading(false);
}
};
const deleteEntry = async () => {
setIsLoading(true);
try {
const response = await deleteCalendarEntry(entry.id);
return response?.ok
? { success: true, message: "Eintrag gelöscht" }
: { success: false, error: "Löschen fehlgeschlagen" };
} catch (error) {
return { success: false, error: error.message };
} finally {
setIsLoading(false);
}
};
// Utility Functions
const updateContactPerson = (field, value) => {
setEntry("contactPerson", {
...entry.contactPerson,
[field]: value,
});
};
const safeString = (value) => value || "";
return {
// State
entry,
originalEntry,
isLoading,
isEditing,
showDeleteButton,
// Actions
setEntry,
loadEntry,
resetEntry,
saveEntry,
deleteEntry,
updateContactPerson,
// Utils
safeString,
};
};
Für die Feld-Validierung und Styling:
// utils/fieldValidation.js
export const getByPath = (obj, path) => {
return path.reduce((current, key) => current?.[key], obj);
};
export const hasFieldChanged = (current, original, fieldPath) => {
if (!original) return false;
const currentValue = getByPath(current, fieldPath);
const originalValue = getByPath(original, fieldPath);
return originalValue && currentValue !== originalValue;
};
export const getFieldOutlineColor = (
currentEntry,
originalEntry,
fieldPath,
changeColor = "#eb6914",
) => {
// Fall 1: Bearbeitung mit original-Property
if (currentEntry.id && currentEntry.original) {
const currentValue = getByPath(currentEntry, fieldPath);
const originalValue = getByPath(currentEntry.original, fieldPath);
return currentValue !== originalValue ? changeColor : undefined;
}
// Fall 2: Neuer Eintrag mit Template
if (hasFieldChanged(currentEntry, originalEntry, fieldPath)) {
return changeColor;
}
return undefined;
};
Jetzt wird die Komponente richtig schön:
// CalendarDialog.jsx
import { onMount, onCleanup, Show } from "solid-js";
import { useCalendarEntry } from "../hooks/useCalendarEntry";
import { getFieldOutlineColor } from "../utils/fieldValidation";
const CalendarDialog = () => {
let dialogRef;
const {
entry,
originalEntry,
isLoading,
isEditing,
showDeleteButton,
setEntry,
loadEntry,
resetEntry,
saveEntry,
deleteEntry,
updateContactPerson,
safeString,
} = useCalendarEntry();
// UI Helper - jetzt viel sauberer!
const getOutlineColor = (fieldPath) =>
getFieldOutlineColor(entry, originalEntry(), fieldPath);
// Event Handlers - nur noch UI-Logik
const handleDialogOpen = (event) => {
loadEntry(event.detail);
};
const handleSave = async () => {
const result = await saveEntry();
if (result.success) {
closeDialog();
showNotification(result.message);
window.dispatchEvent(new Event("calendar:reload"));
} else {
showNotification(result.error, "error");
}
};
const handleDelete = async () => {
const result = await deleteEntry();
if (result.success) {
closeDialog();
showNotification(result.message);
window.dispatchEvent(new Event("calendar:reload"));
} else {
showNotification(result.error, "error");
}
};
const closeDialog = () => {
dialogRef?.dispatchEvent(new Event("dialog:close"));
resetEntry();
};
const showNotification = (message, type = "success") => {
const snackbar = document.getElementById("snackbar");
snackbar?.dispatchEvent(
new CustomEvent("snackbar:open", {
detail: { message, type },
}),
);
};
// Lifecycle
onMount(() => {
dialogRef?.addEventListener("dialog:open", handleDialogOpen);
});
onCleanup(() => {
dialogRef?.removeEventListener("dialog:open", handleDialogOpen);
});
return (
<dialog ref={dialogRef}>
<h2>Kalendereintrag</h2>
<div class="form-row">
<input
placeholder="Titel"
value={safeString(entry.title)}
onInput={(e) => setEntry("title", e.target.value)}
style={{
"outline-color": getOutlineColor(["title"]),
}}
disabled={isLoading()}
/>
<input
placeholder="Kontaktperson"
value={safeString(entry.contactPerson?.name)}
onInput={(e) => updateContactPerson("name", e.target.value)}
style={{
"outline-color": getOutlineColor([
"contactPerson",
"name",
]),
}}
disabled={isLoading()}
/>
</div>
<div class="form-row">
<input
placeholder="Beschreibung"
value={safeString(entry.description)}
onInput={(e) => setEntry("description", e.target.value)}
style={{
"outline-color": getOutlineColor(["description"]),
}}
disabled={isLoading()}
/>
<input
placeholder="E-Mail"
value={safeString(entry.contactPerson?.email)}
onInput={(e) =>
updateContactPerson("email", e.target.value)
}
style={{
"outline-color": getOutlineColor([
"contactPerson",
"email",
]),
}}
disabled={isLoading()}
/>
</div>
<div class="actions">
<Show when={showDeleteButton()}>
<button
onClick={handleDelete}
disabled={isLoading()}
class="btn-danger"
>
{isLoading() ? "Wird gelöscht..." : "Löschen"}
</button>
</Show>
<button
onClick={closeDialog}
disabled={isLoading()}
class="btn-secondary"
>
Abbrechen
</button>
<button
onClick={handleSave}
disabled={isLoading()}
class="btn-primary"
>
{isLoading()
? "Wird gespeichert..."
: isEditing()
? "Speichern"
: "Erstellen"}
</button>
</div>
</dialog>
);
};
export default CalendarDialog;
Vorher:
getOutlineColor
AufrufNachher:
1. Separation of Concerns
// Business Logic im Hook
const { saveEntry, deleteEntry } = useCalendarEntry();
// UI Logic in der Komponente
const handleSave = async () => {
const result = await saveEntry();
// Nur UI-Feedback
};
2. Wiederverwendbarkeit
// Der Hook kann überall verwendet werden
const AnotherComponent = () => {
const { entry, setEntry } = useCalendarEntry();
// Komplett andere UI, gleiche Logik
};
3. Testbarkeit
// Hook isoliert testen
const { result } = renderHook(() => useCalendarEntry());
act(() => result.current.setEntry("title", "Test"));
expect(result.current.entry.title).toBe("Test");
4. DRY-Prinzip
// Statt 12x das Gleiche
const getOutlineColor = (fieldPath) =>
getFieldOutlineColor(entry, originalEntry(), fieldPath);
Custom Hooks in SolidJS sind ein Game-Changer für die Code-Organisation. Sie helfen dabei:
Das Refactoring von 400 Zeilen auf 3 kleine, fokussierte Module war eine der befriedigendsten Code-Sessions, die ich hatte.
Der Trick liegt darin, sich zu fragen: "Was ist Business Logic und was ist UI?" Alles was nicht direkt mit Rendering zu tun hat, gehört in den Hook.
Bonustipp: Fangt klein an! Nehmt eine Komponente, die euch ärgert, und lagert nur den State aus. Der Rest folgt von selbst.
Für Feedback bin ich immer dankbar. Gerne an jacob@derkuba.de
Viele Grüße
Euer Kuba
PS: Dieser Artikel wurde mit Hilfe KI sprachlich aufgehübscht.