SWR Data Flows & Mutation UX
What you'll learn
~25 min- Use the platform's shared SWR fetchers and mutation hooks
- Wire MUI Snackbar feedback to every mutation -- no silent failures
- Implement optimistic updates with rollback on error
The platform data flow
Every module on the DS platform follows the same data flow:
- Page mounts → SWR hook calls
useSWR('/api/vehicle-fleet', fetcher) - Fetcher runs → shared fetcher from
swr-helpers.tscallsfetch(), checks the response envelope, extracts.data - Data arrives → MUI DataGrid renders the rows
- User mutates →
useSWRMutationsends POST/PUT/DELETE, SWR revalidates, snackbar confirms - Error occurs → snackbar shows error message from response
.error.message, row reverts if optimistic
No ad-hoc fetch() calls scattered through components. No axios instances. No hand-rolled loading states. The shared fetcher and SWR handle caching, revalidation, error state, and loading indicators consistently across every module.
The shared fetcher
The platform defines fetchers in src/lib/swr-helpers.ts. Every module uses them:
// The standard fetcher for list endpointsexport const listFetcher = async (url: string) => { const res = await fetch(url); if (!res.ok) { const body = await res.json(); throw new Error(body.error?.message || 'Request failed'); } const json = await res.json(); return json.data; // Extracts from the { data: [...], meta: {...} } envelope};
// The standard fetcher for mutation endpointsexport const mutationFetcher = async ( url: string, { arg }: { arg: { method: string; body?: unknown } }) => { const res = await fetch(url, { method: arg.method, headers: { 'Content-Type': 'application/json' }, body: arg.body ? JSON.stringify(arg.body) : undefined, }); if (!res.ok) { const body = await res.json(); throw new Error(body.error?.message || 'Request failed'); } return res.json();};Because five developers writing five modules will handle errors five different ways. One swallows errors silently. One shows [object Object] in the snackbar. One forgets to set Content-Type. The shared fetcher does it correctly once, and everyone uses it. The entire team benefits every time the fetcher is improved — add retry logic, add request timing, add auth token refresh — and no module code changes.
useSWR for reads, useSWRMutation for writes
The hook structure for every module looks the same:
import useSWR from 'swr';import useSWRMutation from 'swr/mutation';import { listFetcher, mutationFetcher } from '@/lib/swr-helpers';
export function useVehicleFleet() { const { data, error, isLoading, mutate } = useSWR( '/api/vehicle-fleet', listFetcher );
const { trigger: create } = useSWRMutation( '/api/vehicle-fleet', mutationFetcher );
const { trigger: update } = useSWRMutation( '/api/vehicle-fleet', mutationFetcher );
const { trigger: remove } = useSWRMutation( '/api/vehicle-fleet', mutationFetcher );
return { data, error, isLoading, create: (body: VehicleFleetCreate) => create({ method: 'POST', body }), update: (body: VehicleFleetUpdate) => update({ method: 'PUT', body }), remove: (id: number) => remove({ method: 'DELETE', body: { id } }), mutate, };}The hook exposes a clean interface. The page component calls create(formData) and does not care about HTTP methods, fetch configuration, or error parsing. That is the hook’s job.
Snackbar on every mutation
The platform rule: every mutation surfaces feedback to the user. No silent successes. No silent failures. The MUI Snackbar component handles this:
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error';}>({ open: false, message: '', severity: 'success' });
const handleCreate = async (formData: VehicleFleetCreate) => { try { await create(formData); await mutate(); // Revalidate the list setSnackbar({ open: true, message: 'Vehicle added successfully', severity: 'success', }); } catch (err) { setSnackbar({ open: true, message: err instanceof Error ? err.message : 'Failed to add vehicle', severity: 'error', }); }};Every try has a catch. Every catch shows a snackbar. No empty catch blocks. Ever.
An empty catch {} means the user clicked a button, something failed, and nothing happened. They click again. Same nothing. They file a support ticket that says “the app is broken.” The real error was a 403 because their session expired, and a snackbar saying “Session expired — please log in again” would have solved it in 3 seconds. Empty catches turn debuggable errors into mystery bugs.
Optimistic updates
For operations where the user expects instant feedback (toggling a status, editing a name), the platform uses optimistic updates:
const handleStatusToggle = async (vehicle: VehicleFleetRow) => { const newStatus = vehicle.status === 'active' ? 'inactive' : 'active';
// Optimistically update the local cache await mutate( data?.map(v => v.id === vehicle.id ? { ...v, status: newStatus } : v ), { revalidate: false } );
try { await update({ id: vehicle.id, status: newStatus }); await mutate(); // Revalidate with server truth setSnackbar({ open: true, message: 'Status updated', severity: 'success' }); } catch (err) { // Rollback: revalidate from server to restore original state await mutate(); setSnackbar({ open: true, message: err instanceof Error ? err.message : 'Failed to update status', severity: 'error', }); }};The pattern: update the UI immediately, send the request, and if it fails, rollback by revalidating from the server. The user sees the change instantly. If the server rejects it, the UI reverts and the snackbar explains why.
Use optimistic updates for low-risk, high-frequency actions: status toggles, inline edits, reordering. Do not use them for irreversible operations like deletes or actions that trigger downstream processes. For those, show a confirmation dialog and wait for the server response before updating the UI.
The prompt
This prompt generates a complete SWR-powered page component with mutation feedback:
Build the vehicle-fleet page component atsrc/app/(protected)/vehicle-fleet/page.tsx
Requirements:
1. DATA FETCHING: - Import useVehicleFleet from '@/hooks/useVehicleFleet' - Use the hook's data, error, isLoading, create, update, remove, mutate - Show MUI Skeleton while isLoading is true - Show error alert if error is defined
2. TABLE: - MUI DataGrid with columns: vehicle_id, make, model, year, assigned_to, status, actions - Sortable columns - Status column renders colored chip (active=green, inactive=gray, maintenance=amber)
3. MUTATIONS WITH FEEDBACK: - Add button opens MUI Dialog with form fields - Edit button opens same dialog pre-populated - Delete button opens confirmation dialog, then soft-deletes - MUI Snackbar after every mutation (success green, error red) - NO empty catch blocks -- every error shows in the snackbar - Optimistic update for status toggle (active ↔ inactive)
4. SWR PATTERNS: - After create/update: await mutate() to revalidate - After delete: await mutate() to revalidate - Optimistic status toggle with rollback on error - No direct fetch() calls -- everything goes through the hook
5. DENSITY: - Wrap the content in DensityGate from '@/components/DensityGate' - Executive: summary cards (total vehicles, active count, by status) - Operational: the full DataGrid with actions - Technical: DataGrid plus raw JSON viewer and audit columns
6. STYLING: - 'use client' directive at top - MUI 7 components throughout - Import module CSS from '@/styles/vehicle-fleet.module.css'
Every mutation MUST show feedback. No silent operations.Watch it work
What to verify in the generated component
| Check | What to look for |
|---|---|
| No direct fetch() calls | The component should only use the hook methods (create, update, remove). If you see fetch('/api/vehicle-fleet') anywhere in the page component, the AI bypassed the hook. |
| Snackbar on every mutation | Count the mutation handlers (create, update, delete, status toggle). Each one must have a try/catch with setSnackbar() in both branches. |
| No empty catch blocks | Search for catch { or catch (e) {}. Every catch must surface the error to the user. |
| Optimistic update has rollback | The status toggle’s catch block must call mutate() without arguments to revalidate from the server. Without this, a failed toggle leaves the UI in a wrong state. |
mutate() after every write | After create, update, and delete, the code must call mutate() to refresh the list. Without it, the DataGrid shows stale data until the user manually refreshes. |
| DensityGate wrapper | The main content must be wrapped in <DensityGate> with three children for executive, operational, and technical views. |
After the page is generated, manually test each mutation path: add a record (snackbar appears?), edit it (snackbar?), toggle status (instant update?), delete it (confirmation dialog → snackbar?). Then test failure: stop the dev server’s API, try a mutation — the error snackbar should appear with a meaningful message, not a blank or “[object Object]”.
A user clicks 'Save' on the edit dialog. The snackbar shows 'Vehicle updated successfully' but the DataGrid still shows the old values. What is the most likely cause?
What’s next
Your module reads and writes data through a clean SWR pipeline. But where does the data come from? The next lesson covers the platform’s data provenance system — the data_source column, the provenance map, and why an empty table is not a broken table.