NewWacht Bench is live — AI-assisted development for Wacht
GuidesNotifications

Frontend Inbox with Hooks

Build a production inbox with pagination, scoped filters, mutation actions, and resilient refresh behavior.

Frontend Inbox with Hooks

Drop-in bell first

For most apps, the prebuilt NotificationBell is all you need — it renders the icon, unread badge, and an opt-in popover with the full inbox UI. Reach for hooks only when you need custom layout, filters, or actions beyond what the bell ships with.

import { NotificationBell } from "@wacht/nextjs";
// or "@wacht/react-router" / "@wacht/tanstack-router"

export function HeaderActions() {
  return <NotificationBell />;
}

NotificationBell accepts className, showBadge, scope (same shape as useNotifications), and an onAction(payload) callback fired when a user clicks an actionable CTA inside a notification.

For more layout control: drop NotificationPopover next to your own trigger, or render NotificationPanel inline (e.g. on a dedicated /notifications page). Both take the same scope + onAction props as the bell.

Building from scratch with useNotifications

useNotifications gives you the core read model and mutation actions for inbox UX. Use it when the drop-in components don't fit.

What the hook provides

  1. Cursor pagination via loadMore().
  2. Filtered list by scope/channels/severity/state.
  3. Item actions: read/unread, archive, star.
  4. Bulk actions: markAllAsRead(), archiveAllRead().
  5. Stream-assisted freshness via internal useNotificationStream wiring.

Inbox composition example

import { useNotifications } from "@wacht/react-router";

export function NotificationsInbox() {
  const {
    notifications,
    loading,
    hasMore,
    loadMore,
    markAsRead,
    markAsUnread,
    archiveNotification,
    starNotification,
    markAllAsRead,
    archiveAllRead,
    refetch,
  } = useNotifications({
    scope: "all",
    is_archived: false,
    limit: 25,
    severity: "warning",
  });

  if (loading) return <div>Loading notifications...</div>;

  return (
    <section>
      <div>
        <button onClick={() => markAllAsRead()}>Mark all read</button>
        <button onClick={() => archiveAllRead()}>Archive all read</button>
        <button onClick={() => refetch()}>Refresh</button>
      </div>

      {notifications.map((n) => (
        <article key={n.id}>
          <h4>{n.title}</h4>
          <p>{n.body}</p>
          <button onClick={() => markAsRead(n.id)}>Read</button>
          <button onClick={() => markAsUnread(n.id)}>Unread</button>
          <button onClick={() => archiveNotification(n.id)}>Archive</button>
          <button onClick={() => starNotification(n.id)}>Star</button>
        </article>
      ))}

      <button disabled={!hasMore} onClick={() => loadMore()}>
        Load more
      </button>
    </section>
  );
}

UX guidance

  1. Mark-as-read on explicit row open, not hover.
  2. Keep active filters in URL state for shareable support context.
  3. Render clear empty states for “no data” vs “error”.
  4. Show mutation progress on action buttons for repeated actions.

State consistency guidance

  1. Let hook-managed optimistic updates handle immediate UX.
  2. Call refetch() after bulk operations when consistency matters.
  3. Treat backend as source of truth after reconnect or tab refocus.

Validation checklist

  1. Filtered views remain consistent after mutations.
  2. Bulk actions affect intended filtered set.
  3. Cursor pagination preserves ordering and no duplicates.
  4. Error states are actionable and recoverable.

On this page