fix(compiler): migrate 6 list pages to useQuery (set-state-in-effect)

Replaces the useState + useEffect + apiFetch pattern with TanStack
Query in six admin list pages — same pattern, mechanical refactor:

- admin/tags/tag-list
- admin/ports/port-list
- admin/roles/role-list
- admin/users/user-list
- admin/document-templates/template-list
- admin/webhooks/page
- dashboard/timezone-drift-banner (also: detected-tz reads via
  useSyncExternalStore so render stays pure)

Side benefits: list refetches now share a query cache across tabs
(via @tanstack/query-broadcast-client-experimental that was wired
up earlier this branch), so when admin A edits a role in one tab,
admin B's tab sees the updated row without a manual reload.

set-state-in-effect warnings: 51 → 45.

Verified: tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 23:34:24 +02:00
parent d1c9469fa7
commit 6ca94ee3f1
7 changed files with 126 additions and 169 deletions

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useCallback, useEffect, useState } from 'react'; import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/shared/page-header'; import { PageHeader } from '@/components/shared/page-header';
@@ -30,9 +31,10 @@ interface Webhook {
createdAt: string; createdAt: string;
} }
const WEBHOOKS_QUERY_KEY = ['admin', 'webhooks'] as const;
export default function WebhooksPage() { export default function WebhooksPage() {
const [webhooks, setWebhooks] = useState<Webhook[]>([]); const queryClient = useQueryClient();
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const [editTarget, setEditTarget] = useState<Webhook | null>(null); const [editTarget, setEditTarget] = useState<Webhook | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Webhook | null>(null); const [deleteTarget, setDeleteTarget] = useState<Webhook | null>(null);
@@ -44,20 +46,12 @@ export default function WebhooksPage() {
masked: string; masked: string;
} | null>(null); } | null>(null);
const loadWebhooks = useCallback(async () => { const { data: webhooks = [], isLoading: loading } = useQuery<Webhook[]>({
try { queryKey: WEBHOOKS_QUERY_KEY,
const result = await apiFetch<{ data: Webhook[] }>('/api/v1/admin/webhooks'); queryFn: () => apiFetch<{ data: Webhook[] }>('/api/v1/admin/webhooks').then((r) => r.data),
setWebhooks(result.data); });
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to load webhooks');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { const loadWebhooks = () => queryClient.invalidateQueries({ queryKey: WEBHOOKS_QUERY_KEY });
void loadWebhooks();
}, [loadWebhooks]);
async function handleDelete() { async function handleDelete() {
if (!deleteTarget) return; if (!deleteTarget) return;

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, Pencil, Trash2, History, FileText } from 'lucide-react'; import { Plus, Pencil, Trash2, History, FileText } from 'lucide-react';
import { type ColumnDef } from '@tanstack/react-table'; import { type ColumnDef } from '@tanstack/react-table';
@@ -41,27 +42,22 @@ const TYPE_LABELS: Record<string, string> = {
custom: 'Custom', custom: 'Custom',
}; };
const TEMPLATES_QUERY_KEY = ['admin', 'document-templates'] as const;
export function TemplateList() { export function TemplateList() {
const [templates, setTemplates] = useState<AdminTemplate[]>([]); const queryClient = useQueryClient();
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const [editingTemplate, setEditingTemplate] = useState<AdminTemplate | null>(null); const [editingTemplate, setEditingTemplate] = useState<AdminTemplate | null>(null);
const [historyTemplate, setHistoryTemplate] = useState<AdminTemplate | null>(null); const [historyTemplate, setHistoryTemplate] = useState<AdminTemplate | null>(null);
const [historyOpen, setHistoryOpen] = useState(false); const [historyOpen, setHistoryOpen] = useState(false);
const fetchTemplates = useCallback(async () => { const { data: templates = [], isLoading: loading } = useQuery<AdminTemplate[]>({
setLoading(true); queryKey: TEMPLATES_QUERY_KEY,
try { queryFn: () =>
const res = await apiFetch<{ data: AdminTemplate[] }>('/api/v1/admin/templates'); apiFetch<{ data: AdminTemplate[] }>('/api/v1/admin/templates').then((r) => r.data),
setTemplates(res.data); });
} finally {
setLoading(false);
}
}, []);
useEffect(() => { const fetchTemplates = () => queryClient.invalidateQueries({ queryKey: TEMPLATES_QUERY_KEY });
void fetchTemplates();
}, [fetchTemplates]);
function handleNewTemplate() { function handleNewTemplate() {
setEditingTemplate(null); setEditingTemplate(null);

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { type ColumnDef } from '@tanstack/react-table'; import { type ColumnDef } from '@tanstack/react-table';
import { Pencil, Plus } from 'lucide-react'; import { Pencil, Plus } from 'lucide-react';
@@ -24,25 +25,19 @@ interface PortRow {
createdAt: string; createdAt: string;
} }
const PORTS_QUERY_KEY = ['admin', 'ports'] as const;
export function PortList() { export function PortList() {
const [ports, setPorts] = useState<PortRow[]>([]); const queryClient = useQueryClient();
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const [editingPort, setEditingPort] = useState<PortRow | null>(null); const [editingPort, setEditingPort] = useState<PortRow | null>(null);
const fetchPorts = useCallback(async () => { const { data: ports = [], isLoading: loading } = useQuery<PortRow[]>({
setLoading(true); queryKey: PORTS_QUERY_KEY,
try { queryFn: () => apiFetch<{ data: PortRow[] }>('/api/v1/admin/ports').then((r) => r.data),
const res = await apiFetch<{ data: PortRow[] }>('/api/v1/admin/ports'); });
setPorts(res.data);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { const fetchPorts = () => queryClient.invalidateQueries({ queryKey: PORTS_QUERY_KEY });
void fetchPorts();
}, [fetchPorts]);
function handleNewPort() { function handleNewPort() {
setEditingPort(null); setEditingPort(null);

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { type ColumnDef } from '@tanstack/react-table'; import { type ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2, Plus, Lock } from 'lucide-react'; import { Pencil, Trash2, Plus, Lock } from 'lucide-react';
@@ -31,27 +32,25 @@ interface Role {
createdAt: string; createdAt: string;
} }
const ROLES_QUERY_KEY = ['admin', 'roles'] as const;
export function RoleList() { export function RoleList() {
const [roles, setRoles] = useState<Role[]>([]); const queryClient = useQueryClient();
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null); const [editingRole, setEditingRole] = useState<Role | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [viewingPermissions, setViewingPermissions] = useState<Role | null>(null); const [viewingPermissions, setViewingPermissions] = useState<Role | null>(null);
const fetchRoles = useCallback(async () => { const { data: roles = [], isLoading: loading } = useQuery<Role[]>({
setLoading(true); queryKey: ROLES_QUERY_KEY,
try { queryFn: () => apiFetch<{ data: Role[] }>('/api/v1/admin/roles').then((r) => r.data),
const res = await apiFetch<{ data: Role[] }>('/api/v1/admin/roles'); });
setRoles(res.data);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { const deleteMutation = useMutation({
void fetchRoles(); mutationFn: (id: string) => apiFetch(`/api/v1/admin/roles/${id}`, { method: 'DELETE' }),
}, [fetchRoles]); onSuccess: () => queryClient.invalidateQueries({ queryKey: ROLES_QUERY_KEY }),
});
const fetchRoles = () => queryClient.invalidateQueries({ queryKey: ROLES_QUERY_KEY });
function handleNewRole() { function handleNewRole() {
setEditingRole(null); setEditingRole(null);
@@ -63,16 +62,6 @@ export function RoleList() {
setFormOpen(true); setFormOpen(true);
} }
async function handleDeleteRole(id: string) {
setDeletingId(id);
try {
await apiFetch(`/api/v1/admin/roles/${id}`, { method: 'DELETE' });
await fetchRoles();
} finally {
setDeletingId(null);
}
}
function countPermissions(perms: Record<string, Record<string, boolean>>): string { function countPermissions(perms: Record<string, Record<string, boolean>>): string {
let granted = 0; let granted = 0;
let total = 0; let total = 0;
@@ -155,8 +144,8 @@ export function RoleList() {
title="Delete Role" title="Delete Role"
description={`Delete "${row.original.name}"? Users assigned to this role must be reassigned first.`} description={`Delete "${row.original.name}"? Users assigned to this role must be reassigned first.`}
confirmLabel="Delete" confirmLabel="Delete"
onConfirm={() => handleDeleteRole(row.original.id)} onConfirm={() => deleteMutation.mutate(row.original.id)}
loading={deletingId === row.original.id} loading={deleteMutation.isPending && deleteMutation.variables === row.original.id}
/> />
)} )}
</div> </div>

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { type ColumnDef } from '@tanstack/react-table'; import { type ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2, Plus } from 'lucide-react'; import { Pencil, Trash2, Plus } from 'lucide-react';
@@ -19,26 +20,24 @@ interface Tag {
createdAt: string; createdAt: string;
} }
const TAGS_QUERY_KEY = ['admin', 'tags'] as const;
export function TagList() { export function TagList() {
const [tags, setTags] = useState<Tag[]>([]); const queryClient = useQueryClient();
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const [editingTag, setEditingTag] = useState<Tag | null>(null); const [editingTag, setEditingTag] = useState<Tag | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const fetchTags = useCallback(async () => { const { data: tags = [], isLoading: loading } = useQuery<Tag[]>({
setLoading(true); queryKey: TAGS_QUERY_KEY,
try { queryFn: () => apiFetch<{ data: Tag[] }>('/api/v1/tags').then((r) => r.data),
const res = await apiFetch<{ data: Tag[] }>('/api/v1/tags'); });
setTags(res.data);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { const deleteMutation = useMutation({
void fetchTags(); mutationFn: (id: string) => apiFetch(`/api/v1/tags/${id}`, { method: 'DELETE' }),
}, [fetchTags]); onSuccess: () => queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY }),
});
const fetchTags = () => queryClient.invalidateQueries({ queryKey: TAGS_QUERY_KEY });
function handleNewTag() { function handleNewTag() {
setEditingTag(null); setEditingTag(null);
@@ -50,16 +49,6 @@ export function TagList() {
setFormOpen(true); setFormOpen(true);
} }
async function handleDeleteTag(id: string) {
setDeletingId(id);
try {
await apiFetch(`/api/v1/tags/${id}`, { method: 'DELETE' });
await fetchTags();
} finally {
setDeletingId(null);
}
}
const columns: ColumnDef<Tag, unknown>[] = [ const columns: ColumnDef<Tag, unknown>[] = [
{ {
accessorKey: 'name', accessorKey: 'name',
@@ -111,8 +100,8 @@ export function TagList() {
title="Delete Tag" title="Delete Tag"
description={`Are you sure you want to delete "${row.original.name}"? This action cannot be undone.`} description={`Are you sure you want to delete "${row.original.name}"? This action cannot be undone.`}
confirmLabel="Delete" confirmLabel="Delete"
onConfirm={() => handleDeleteTag(row.original.id)} onConfirm={() => deleteMutation.mutate(row.original.id)}
loading={deletingId === row.original.id} loading={deleteMutation.isPending && deleteMutation.variables === row.original.id}
/> />
</div> </div>
), ),

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import { useState, useEffect, useCallback } from 'react'; import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { type ColumnDef } from '@tanstack/react-table'; import { type ColumnDef } from '@tanstack/react-table';
import { Pencil, Trash2, Plus, ShieldCheck, ShieldOff, Power, PowerOff } from 'lucide-react'; import { Pencil, Trash2, Plus, ShieldCheck, ShieldOff, Power, PowerOff } from 'lucide-react';
@@ -27,27 +28,36 @@ interface UserRow {
assignedAt: string; assignedAt: string;
} }
const USERS_QUERY_KEY = ['admin', 'users'] as const;
export function UserList() { export function UserList() {
const [users, setUsers] = useState<UserRow[]>([]); const queryClient = useQueryClient();
const [loading, setLoading] = useState(true);
const [formOpen, setFormOpen] = useState(false); const [formOpen, setFormOpen] = useState(false);
const [editingUser, setEditingUser] = useState<UserRow | null>(null); const [editingUser, setEditingUser] = useState<UserRow | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(null);
const [togglingId, setTogglingId] = useState<string | null>(null);
const fetchUsers = useCallback(async () => { const { data: users = [], isLoading: loading } = useQuery<UserRow[]>({
setLoading(true); queryKey: USERS_QUERY_KEY,
try { queryFn: () => apiFetch<{ data: UserRow[] }>('/api/v1/admin/users').then((r) => r.data),
const res = await apiFetch<{ data: UserRow[] }>('/api/v1/admin/users'); });
setUsers(res.data);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { const fetchUsers = () => queryClient.invalidateQueries({ queryKey: USERS_QUERY_KEY });
void fetchUsers();
}, [fetchUsers]); const removeMutation = useMutation({
mutationFn: (userId: string) => apiFetch(`/api/v1/admin/users/${userId}`, { method: 'DELETE' }),
onSuccess: () => fetchUsers(),
});
const toggleMutation = useMutation({
mutationFn: (user: UserRow) =>
apiFetch(`/api/v1/admin/users/${user.userId}`, {
method: 'PATCH',
body: { isActive: !user.isActive },
}),
onSuccess: () => fetchUsers(),
});
const deletingId = removeMutation.isPending ? removeMutation.variables : null;
const togglingId = toggleMutation.isPending ? (toggleMutation.variables?.userId ?? null) : null;
function handleNewUser() { function handleNewUser() {
setEditingUser(null); setEditingUser(null);
@@ -59,27 +69,12 @@ export function UserList() {
setFormOpen(true); setFormOpen(true);
} }
async function handleRemoveUser(userId: string) { function handleRemoveUser(userId: string) {
setDeletingId(userId); removeMutation.mutate(userId);
try {
await apiFetch(`/api/v1/admin/users/${userId}`, { method: 'DELETE' });
await fetchUsers();
} finally {
setDeletingId(null);
}
} }
async function handleToggleActive(user: UserRow) { function handleToggleActive(user: UserRow) {
setTogglingId(user.userId); toggleMutation.mutate(user);
try {
await apiFetch(`/api/v1/admin/users/${user.userId}`, {
method: 'PATCH',
body: { isActive: !user.isActive },
});
await fetchUsers();
} finally {
setTogglingId(null);
}
} }
const columns: ColumnDef<UserRow, unknown>[] = [ const columns: ColumnDef<UserRow, unknown>[] = [

View File

@@ -1,7 +1,7 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState, useSyncExternalStore } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Clock, X } from 'lucide-react'; import { Clock, X } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -35,37 +35,37 @@ interface MeResponse {
* Dismissal is sticky per browser via localStorage so the rep isn't nagged * Dismissal is sticky per browser via localStorage so the rep isn't nagged
* once they've decided. Clearing storage or signing in elsewhere re-asks. * once they've decided. Clearing storage or signing in elsewhere re-asks.
*/ */
function getDetectedTimezone(): string | null {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || null;
} catch {
return null;
}
}
function getInitialDismissed(): boolean {
try {
return window.localStorage.getItem(DISMISS_STORAGE_KEY) === 'true';
} catch {
return false;
}
}
// useSyncExternalStore lets us read window.matchMedia-style external state
// without bouncing through useEffect → setState.
const detectedSubscribe = () => () => {};
export function TimezoneDriftBanner() { export function TimezoneDriftBanner() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [detected, setDetected] = useState<string | null>(null); const detected = useSyncExternalStore(detectedSubscribe, getDetectedTimezone, () => null);
const [stored, setStored] = useState<string | null>(null); const [dismissed, setDismissed] = useState(getInitialDismissed);
const [profileLoaded, setProfileLoaded] = useState(false);
const [dismissed, setDismissed] = useState(false);
// Read on mount: browser's resolved timezone (mirrors the OS setting) + const { data: profile, isSuccess: profileLoaded } = useQuery<MeResponse>({
// the user's stored preference + any prior dismissal flag. All three queryKey: ['me'],
// are stable across the lifetime of the dashboard view; the banner queryFn: () => apiFetch<MeResponse>('/api/v1/me'),
// makes a single comparison and either renders or doesn't. });
useEffect(() => { const stored =
try { profile?.data.profile?.preferences?.timezone ?? profile?.data.profile?.timezone ?? null;
setDetected(Intl.DateTimeFormat().resolvedOptions().timeZone || null);
} catch {
setDetected(null);
}
try {
const flag = window.localStorage.getItem(DISMISS_STORAGE_KEY);
if (flag === 'true') setDismissed(true);
} catch {
// Private mode or quota — proceed without dismissal memory.
}
void apiFetch<MeResponse>('/api/v1/me')
.then((res) => {
const tz = res.data.profile?.preferences?.timezone ?? res.data.profile?.timezone ?? null;
setStored(tz);
})
.catch(() => setStored(null))
.finally(() => setProfileLoaded(true));
}, []);
const mutation = useMutation({ const mutation = useMutation({
mutationFn: async (newTz: string) => { mutationFn: async (newTz: string) => {
@@ -77,7 +77,6 @@ export function TimezoneDriftBanner() {
onSuccess: () => { onSuccess: () => {
toast.success(`Timezone updated to ${formatTimezoneLabel(detected ?? '')}.`); toast.success(`Timezone updated to ${formatTimezoneLabel(detected ?? '')}.`);
queryClient.invalidateQueries({ queryKey: ['me'] }); queryClient.invalidateQueries({ queryKey: ['me'] });
setStored(detected);
}, },
onError: (err) => toastError(err), onError: (err) => toastError(err),
}); });