Skip to content
BoringStack
GitHub

Notifications

5 min read

UI Notifications

The UI consumes the API notifications subsystem. The feed is a TanStack Query infinite list, mutations are optimistic with rollback, and an optional SSE EventSource keeps the cache live while the user is authenticated. The bell + popover mount inside AppShell, so every authenticated route gets them for free.

SSE

live cache updates

Optimistic

mark-read mutations

Event-agnostic

pre-rendered strings

The backend ships pre-rendered { title, body, ctaUrl, ctaLabel } strings. The UI renders them as plain content, no per-event-type switches.

sequenceDiagram
  participant API
  participant SSE as EventSource (Browser)
  participant Hook as useNotificationStream
  participant Cache as TanStack Query cache
  participant Bell as NotificationBell
  participant Toast as Sonner

  Note over Hook,SSE: AppShell mounts the hook for every authenticated route
  Hook->>SSE: open /api/v1/notifications/stream
  API-->>SSE: PUBLISH notifications:user:<id>
  SSE-->>Hook: message event
  Hook->>Hook: parseStreamMessage (defensive)
  Hook->>Cache: prepend to list page · bump unreadCount
  Cache-->>Bell: badge re-renders
  Hook->>Toast: title + body + optional CTA
Notification feature map
src/features/notifications/
  • Notifications.constants.ts
  • Notifications.list.queries.tsfeed and unread count reads
  • Notifications.mutations.tsoptimistic mark-read/archive operations
  • Notifications.preferences.queries.tspreference grid reads and writes
  • Notifications.cache.tscache merge and rollback helpers
  • Notifications.utils.ts
  • Notifications.stream-utils.tsdefensive SSE parsing
  • Notifications.types.ts
  • useNotificationStream.tsmounted once from AppShell
  • components/
    • NotificationBell/
    • NotificationCenterPopover/
    • NotificationListItem/
    • NotificationsPage/
    • NotificationsPreferencesPage/
    • PreferenceRow/
    • PreferenceCell/

Same anatomy as features/dashboard/ (queries + utils at the feature root, components in components/<Name>/ with the 8-file layout). The query surface is split across three files (reads, mutations, preferences) to keep each file under the max-hooks-per-file threshold enforced by @boring-stack-pkg/eslint-plugin-react-component-architecture.

The full hook surface, grouped by file:

Notifications.list.queries.ts
useNotificationsList(status?: "unread" | "read" | "archived")
useUnreadNotificationCount()
// Notifications.mutations.ts
useMarkNotificationRead() // optimistic
useArchiveNotification() // optimistic
useMarkAllNotificationsRead() // optimistic
// Notifications.preferences.queries.ts
useNotificationPreferences()
useUpdateNotificationPreferences()

useUnreadNotificationCount reads from the list cache when present and falls back to a server query otherwise. Every mutation snapshots the cache, applies the optimistic write via the helpers in Notifications.cache.ts, and rolls back if the request fails. onSettled invalidates both list and unread-count keys so the UI reconciles with the server.

useNotificationStream is mounted once in AppShell.hooks.ts. It opens a credentialed EventSource against ${VITE_API_URL}/api/v1/notifications/stream only when capabilities.features.notifications.sse === true (from GET /api/v1/capabilities/). When SSE is disabled on the API (NOTIFICATIONS_SSE_ENABLED=false, the default), notifications still work through the paginated feed and mutations — just without live push.

mergeStreamNotificationIntoCache(qc, notification);
toast(notification.title, {
description: notification.body,
action: notification.ctaUrl !== null
? { label: notification.ctaLabel ?? t("notifications.openCta"),
onClick: () => { void navigate(notification.ctaUrl); } }
: undefined
});

Defensive parse: malformed JSON or messages with the wrong shape are dropped with a warn log, never thrown.

PathComponent
/notificationsNotificationsPage
/notifications/preferencesNotificationsPreferencesPage

Both wrap inside <AppShell>, which holds the header, bell, logout, and the SSE hook.

You don’t. The backend ships pre-rendered strings; the UI is event-agnostic by design. To add a new event type, the API defines it (see API notifications) and the bell, page, and toast pick it up with no UI change.

If you ever need a per-event-type visual treatment (badge colour, icon), branch on notification.eventType inside NotificationListItem only. Don’t fork the page.

Browser push notifications via the W3C Push API + VAPID. The whole flow lives in useWebPush.hooks.ts (under src/hooks/) plus a small service worker at public/sw.js. The Settings page renders a state-aware “Browser notifications” card that wraps the hook.

Setup

Generate VAPID keys on the API (bun run vapid:generate) and paste the public key into the UI as VITE_VAPID_PUBLIC_KEY. Without it, the Settings card renders “Web Push is not configured for this deploy.”

Service worker

public/sw.js is copied to the dist root by Vite (no plugin needed) so the scope is /. Two handlers: push calls showNotification(title, { body, data: { url } }); notificationclick focuses the matching tab if open, otherwise opens the URL. Registered once from src/app/main.tsx (gated on 'serviceWorker' in navigator).

useWebPush contract

Returns { isSupported, isConfigured, permission, isSubscribed, isPending, subscribe, unsubscribe }. The Settings card maps that state machine into copy: unsupported / not-configured / blocked / not-subscribed / subscribed. Lives in src/hooks/ because the feature accounts consumes it without owning it.

Preference grid integration

web-push appears alongside in-app and email in PREFERENCE_CHANNEL_COLUMNS. Toggling it follows the same pattern as other channels: the backend dispatcher reads notification_preference rows for (userId, eventType, channel).

Cross-tab sync via BroadcastChannel

Each tab holds its own SSE connection; the badge converges via cache invalidation.

Notification grouping

Backend doesn’t roll up “3 people liked your post” yet; UI doesn’t either.

Per-event-type custom rendering

Backend ships pre-rendered strings; UI stays event-agnostic.