Drei Erkenntnisse aus unserem internen Dev-Hub
Drei Erkenntnisse aus dem Bau eines internen Dev-Dashboards: warum die URL der bessere useState ist, warum supabase.channel() eine Registry ist und keine Fabrik – und was ALTER TABLE RENAME in Postgres alles nicht umbenennt.
Seit ein paar Wochen bauen wir uns ein internes Dev-Dashboard: Kanban-Board über alle Projekte, Kalender, Code-Browser mit Dateibaum und Diff-Ansicht, Team-Chat mit Anwesenheit und ein KI-Assistent, der Tasks anlegen und Commits zuordnen kann. Next.js, React, Supabase – nichts Exotisches. Trotzdem hat mir kaum ein Projekt in letzter Zeit so viele kleine, übertragbare Lektionen beschert wie dieses. Drei davon möchte ich festhalten, weil ich bei jeder einzelnen dachte: Das hätte ich gern vorher gewusst.
1. Die URL ist der bessere useState
Das Dashboard hatte irgendwann fünf Reiter-Zustände und drei Drawer – alle in useState. Beim Neuladen startete jede Seite bei null: Welcher Projektreiter offen war, welcher Commit im Drawer stand, welcher Inbox-Filter aktiv war – weg.
Der Reflex wäre localStorage gewesen. Damit hatte ich beim Dark-Mode gute Erfahrungen gemacht: Zustand, der einen Reload überlebt. Aber für UI-Navigation ist localStorage das falsche Werkzeug, und der Grund lässt sich in einem Satz sagen: Die Adresszeile ist nicht nur Erinnerung, sondern auch Adresse. Sie überlebt den Reload und die Zurück-Taste und die Weitergabe. „Schau dir den Architektur-Reiter an" lässt sich als Link in den Team-Chat werfen – mit localStorage nicht.
Bei der Umstellung haben sich drei Regeln herauskristallisiert, die allgemeiner sind als das Projekt:
Aufzählbare Werte brauchen eine Whitelist. Ein Reiter-Parameter aus der URL ist User-Input. ?tab=architektur ist gültig, ?tab=xyz darf die Oberfläche nicht in einen Zustand zwingen, den es nicht gibt. Freie Werte (eine Commit-ID im Drawer) werden dagegen ohnehin gegen den geladenen Bestand aufgelöst – dort erledigt sich die Validierung von selbst.
replace für Reiter, push für Drawer. Ein Reiterwechsel ist kein Navigationsschritt – sonst muss man nach vier Reitern viermal zurück. Beim Drawer dagegen soll die Zurück-Taste schließen, das ist auf einmal geschenkte UX:
// Reiterwechsel: ersetzt den History-Eintrag
router.replace(`?tab=${tab}`);
// Drawer öffnen: eigener History-Eintrag, Zurück-Taste schließt
router.push(`?commit=${sha}`);
Der Standardwert kommt nicht in die URL. Sonst trägt jede Seite nach dem ersten Klick einen Parameter, der nichts aussagt – und zwei URLs, die dieselbe Ansicht meinen, sehen verschieden aus.
Eine Next.js-Fußnote noch: useSearchParams verlangt beim statischen Export eine Suspense-Grenze um die Komponente. Der Fehler kommt erst beim Build, nicht im Dev-Server – wer die Umstellung plant, plant die <Suspense>-Wrapper gleich mit.
2. supabase.channel() ist eine Registry, keine Fabrik
Der Fehler stand plötzlich in der Konsole und klang nach Interna:
cannot add postgres_changes callbacks after subscribe()
Die Ursache ist ein Missverständnis, das vermutlich jeder hat, bis es ihn erwischt: supabase.channel(name) sieht aus wie ein Konstruktor, ist aber ein Registry-Lookup. Beim zweiten Aufruf mit demselben Namen kommt keine neue Instanz zurück, sondern die bestehende, bereits abonnierte – und ein weiteres .on() darauf wirft.
Bei uns riefen drei Komponenten denselben Hook auf, der intern einen Realtime-Channel abonnierte: eine Statusleiste, ein Analyse-Knopf und ein Formular. Erster Render abonniert, zweiter Render knallt.
Es gibt die naheliegende Lösung: ein geteiltes Abo mit Referenzzähler – ein Modul hält den Channel, Komponenten melden sich an und ab. So machen wir es an anderer Stelle auch. Aber beim Hinsehen war die ehrlichere Lösung eine andere: Zwei der drei Aufrufer wollten gar nichts lesen. Sie lösten nur Aktionen aus. Der Hook wurde deshalb geteilt – ein Schreib-Hook ohne jedes Abo, ein Lese-Hook mit Abo, und nur die Statusleiste liest:
// vorher: alle drei abonnieren, obwohl nur eine liest
useAuftraege(); // Leiste, Knopf, Formular
// nachher: Lesen und Schreiben getrennt
useAuftraege(); // nur die Leiste (abonniert)
useAuftragStarten(); // Knopf & Formular (kein Channel)
Und weil ein Kommentar niemanden davon abhält, den Lese-Hook doch zweimal einzubauen, trägt der Channel-Name jetzt eine laufende Nummer. Zwei gleichzeitige Nutzer bekommen dann zwei Channels statt einer Exception. Das ist Defensive im besten Sinn: Der zukünftige Fehler wurde nicht verboten, sondern harmlos gemacht.
Die Lektion ist dabei gar nicht Supabase-spezifisch: Wenn mehrere Komponenten denselben Daten-Hook aufrufen, lohnt die Frage, wie viele davon die Daten überhaupt brauchen. Oft ist die halbe Antwort auf ein Ressourcen-Problem, die Ressource seltener anzufordern.
3. ALTER TABLE RENAME nimmt Indizes und Policies nicht mit
Wir mussten eine Tabelle umbenennen – der KI-Assistent des Hubs bekam einen neuen Namen, und der alte steckte in der Route, in einer Edge Function und in der Tabelle *_sessions. Route und Function sind schnell erledigt. Die Tabelle sah auch schnell aus:
ALTER TABLE yarak_sessions RENAME TO sensei_sessions;
Läuft durch, Daten da, fertig? Nicht ganz. Postgres benennt bei einem Tabellen-Rename weder Indizes noch Policies noch Constraints mit um. Die funktionieren weiter – sie hängen intern an der Objekt-ID, nicht am Namen – aber im Schema steht dann ein yarak_sessions_user_idx auf einer Tabelle namens sensei_sessions. Genau die Sorte Altlast, die in einem Jahr niemand mehr erklären kann:
ALTER TABLE yarak_sessions RENAME TO sensei_sessions;
ALTER INDEX yarak_sessions_user_idx RENAME TO sensei_sessions_user_idx;
ALTER POLICY "yarak_sessions_select" ON sensei_sessions
RENAME TO "sensei_sessions_select";
-- … und so weiter, für jedes abhängige Objekt einzeln
Die zweite Erkenntnis aus derselben Migration ist subtiler. Die alte Migration, die die Tabelle unter ihrem alten Namen angelegt hatte, blieb unangetastet – umbenannt wird in einer neuen Migration. Die Versuchung, die alte Datei „sauber" nachzuziehen, ist real, aber falsch: Eine ausgeführte Migration ist Geschichtsschreibung, kein aktueller Zustand. Wer sie nachträglich umschreibt, verfälscht die Historie – und lässt die neue Rename-Migration bei einem frischen Aufsetzen ins Leere laufen, weil die Tabelle dann nie unter ihrem alten Namen existiert hat.
Die Lektion: Ein Rename ist in Postgres kein einzelnes Statement, sondern eine kleine Inventur. Und Migrationen behandelt man wie Git-Commits auf einem geteilten Branch – was ausgeführt wurde, wird nicht mehr umgeschrieben.
Was die drei gemeinsam haben
Keine der drei Erkenntnisse ist ein Framework-Trick. Es sind Zustandsfragen: Wo lebt der UI-Zustand (URL statt Speicher)? Wer teilt sich eine Verbindung (Lesen getrennt von Schreiben)? Was gehört zur Historie und was zum Ist-Zustand (Migrationen)? Interne Tools sind dafür das perfekte Übungsgelände – die Nutzer sitzen einen Schreibtisch weiter, und jede falsche Entscheidung meldet sich innerhalb von Stunden statt Quartalen.