React hooks & components

The @notifykitjs/react package provides hooks and pre-built components for building notification UIs. Everything is typed against your notification definitions.

ApproachWhen to use
Hooks (useInbox, usePreferences)Full control over UI — you render everything yourself
Components (<Inbox />, <NotificationBell />)Quick start — pre-built with customization via render props
Client SDK (createNotifyKitClient)Non-React environments (Vue, Svelte, vanilla JS)

Which approach fits?

Do you need a custom design?

Yes → use hooks (useInbox, usePreferences). No → use components (<Inbox />, <NotificationBell />) for instant UI.

Are you using React?

Yes → hooks or components (both work). No → use createNotifyKitClient directly — same HTTP layer, no React dependency.

Do you need realtime updates?

Configure a realtime adapter server-side. Hooks connect automatically — no client config needed.

Setup

npm install @notifykitjs/react

Wrap your app in the provider (see Next.js integration):

app/layout.tsx
import { NotifyKitProvider } from "@notifykitjs/react"

<NotifyKitProvider options={{ baseUrl: "/api/notifykit" }}>
  {children}
</NotifyKitProvider>

Hook quick reference

Every hook and its key methods on one screen. Find what you need, then scroll down for full docs and examples:

useInbox()

Full inbox state + mutations. Returns items, unreadCount, status, realtimeStatus.

markRead(id) markAllRead() archive(id) deleteItem(id) refresh()

usePreferences()

Channel toggles for the current user. Returns items, status, error.

isEnabled(id, channel) update({ notificationId, channels }) refresh()

useUnreadCount()

Just the badge number. Lighter than useInbox when you only need the count.

{ unreadCount: number }

createNotifyKitClient()

Non-React SDK. Same HTTP layer, no hooks. Works with Vue, Svelte, vanilla JS.

client.inbox.list() client.preferences.list() client.notifications.list()
All hooks share state through the provider. If useInbox() marks an item as read, useUnreadCount() decrements automatically — no manual coordination needed between components.

useInbox()

Fetches and manages the current user's inbox. Automatically connects to realtime updates when a realtime adapter is configured server-side.

Return fieldTypeDescription
itemsInboxItem[]Current inbox items
status"idle" | "loading" | "ready" | "error"Fetch state
unreadCountnumberCount of unread items
realtimeStatus"connected" | "connecting" | "disconnected"SSE connection state
markRead(id)Promise<InboxItem>Mark one item as read
markAllRead()Promise<number>Mark all read, returns count
archive(id)Promise<InboxItem>Archive an item
deleteItem(id)Promise<void>Permanently delete
refresh()Promise<InboxItem[]>Re-fetch from server
components/inbox.tsx
import { useInbox } from "@notifykitjs/react"

function InboxPage() {
  const { items, markRead } = useInbox()

  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>
          <strong>{item.title}</strong>
          {item.body && <p>{item.body}</p>}
          {!item.readAt && (
            <button onClick={() => markRead(item.id)}>Mark read</button>
          )}
        </li>
      ))}
    </ul>
  )
}

Options

// Disable auto-loading (useful for conditional rendering)
const inbox = useInbox({ autoLoad: false })

// Then manually load when ready:
useEffect(() => { inbox.refresh() }, [])

Handling loading & error states

The status field tells you where the hook is in its lifecycle. Always handle at least loading and error for production UIs:

function InboxWithStates() {
  const { items, status, markRead, refresh } = useInbox()

  if (status === "loading") return <Skeleton count={3} />
  if (status === "error") return (
    <div className="error">
      Failed to load notifications.
      <button onClick={refresh}>Retry</button>
    </div>
  )

  if (items.length === 0) return <p>No notifications yet.</p>

  return items.map(item => (
    <NotificationCard key={item.id} item={item} onRead={markRead} />
  ))
}
statusRenderNotes
"idle"Nothing (or skeleton)Brief initial state before first fetch starts
"loading"Skeleton / spinnerFirst load only — subsequent refreshes don't reset status
"ready"Your inbox UIitems is populated
"error"Error message + retry buttonUsually a 401 (session expired) or network failure
Mutations don't change status. Calling markRead() or archive() updates the item optimistically without flipping status back to "loading". If the server rejects, the item reverts silently. Handle mutation errors with a try/catch on the returned promise.

usePreferences()

Fetches and manages the current user's notification preferences. Updates are optimistic — the UI updates immediately, then reverts on error.

Return fieldTypeDescription
itemsRecipientPreference[]All preferences for this user
status"idle" | "loading" | "ready" | "error"Fetch state
errorstring | nullError message if fetch/update failed
isEnabled(id, channel)booleanCheck if a channel is enabled for a notification
update(input)Promise<RecipientPreference>Toggle channels (optimistic)
refresh()Promise<RecipientPreference[]>Re-fetch from server
components/notification-settings.tsx
import { usePreferences } from "@notifykitjs/react"

function NotificationSettings() {
  const { isEnabled, update } = usePreferences()

  return (
    <label>
      <input
        type="checkbox"
        checked={isEnabled("comment_mentioned", "email")}
        onChange={(e) => update({
          notificationId: "comment_mentioned",
          channels: { email: e.target.checked },
        })}
      />
      Email me when someone mentions me
    </label>
  )
}

Full preferences page

A real settings page lists all notifications (grouped by category) with per-channel toggles. Combine usePreferences with the notification metadata endpoint:

import { usePreferences } from "@notifykitjs/react"
import { createNotifyKitClient } from "@notifykitjs/react"
import { useEffect, useState } from "react"

const client = createNotifyKitClient({ baseUrl: "/api/notifykit" })

type NotificationMeta = {
  id: string
  description?: string
  category?: string
  channels: string[]
  required?: boolean
}

function NotificationPreferencesPage() {
  const { isEnabled, update, status } = usePreferences()
  const [notifications, setNotifications] = useState<NotificationMeta[]>([])

  useEffect(() => {
    client.notifications.list().then(setNotifications)
  }, [])

  if (status === "loading" || notifications.length === 0) {
    return <p>Loading preferences...</p>
  }

  // Group by category
  const grouped = Object.groupBy(notifications, n => n.category ?? "General")

  return (
    <div className="preferences-page">
      <h1>Notification settings</h1>
      <p>Choose how you want to be notified for each type of event.</p>

      {Object.entries(grouped).map(([category, items]) => (
        <section key={category}>
          <h2>{category}</h2>
          <table>
            <thead>
              <tr>
                <th>Notification</th>
                <th>Inbox</th>
                <th>Email</th>
              </tr>
            </thead>
            <tbody>
              {items?.map(n => (
                <tr key={n.id}>
                  <td>
                    <strong>{n.id.replace(/_/g, " ")}</strong>
                    {n.description && <p>{n.description}</p>}
                    {n.required && <span className="badge">Required</span>}
                  </td>
                  {["inbox", "email"].map(ch => (
                    <td key={ch}>
                      {n.channels.includes(ch) ? (
                        <input
                          type="checkbox"
                          checked={isEnabled(n.id, ch)}
                          disabled={n.required}
                          onChange={e => update({
                            notificationId: n.id,
                            channels: { [ch]: e.target.checked },
                          })}
                          aria-label={`${ch} for ${n.id.replace(/_/g, " ")}`}
                        />
                      ) : (
                        <span aria-label="Not available"></span>
                      )}
                    </td>
                  ))}
                </tr>
              ))}
            </tbody>
          </table>
        </section>
      ))}
    </div>
  )
}
DetailWhy
notifications.list()Fetches registered notification metadata — IDs, channels, categories, and required flags
Object.groupBy()Groups notifications by their category field for a cleaner settings layout
n.requiredRequired notifications can't be toggled off — disable the checkbox and show a badge
n.channels.includes(ch)Only show a toggle if that notification actually supports the channel
Optimistic by default. The update() call toggles the checkbox immediately, then syncs with the server. If the server rejects (e.g. trying to disable a required notification), the checkbox reverts. No loading spinners needed for individual toggles.

useUnreadCount()

A lightweight hook that returns only the unread notification count. Use it when you need a badge number but don't need the full inbox payload — it skips fetching items entirely, so it's cheaper on bandwidth and renders faster.

Return fieldTypeDescription
unreadCountnumberCurrent unread notification count
status"idle" | "loading" | "ready" | "error"Fetch state
errorstring | nullError message if the count request failed
refresh()Promise<number>Manually re-fetch the count

Options

OptionTypeDefaultDescription
pollIntervalnumber | falsefalseMilliseconds between automatic re-fetches. Set to false to rely on realtime updates only.
import { useUnreadCount } from "@notifykitjs/react"

function NotificationBadge() {
  const { unreadCount } = useUnreadCount({ pollInterval: 10_000 })

  if (unreadCount === 0) return null

  return (
    <span className="badge">{unreadCount > 99 ? "99+" : unreadCount}</span>
  )
}

When to use useUnreadCount() vs useInbox()

useUnreadCount()

You only need the badge number — nav bars, tab titles, app icons. Skips fetching full items.

useInbox().unreadCount

You already render the inbox list. No extra request — the count comes free with the items payload.

Both stay in sync. If you mount both hooks on the same page, they share the same realtime connection. Marking an item read via useInbox decrements useUnreadCount automatically.

Pre-built components

ComponentWhat it rendersCustomization
<NotificationBell />Unread count badgerender prop
<Inbox />Full inbox list with actionsrenderItem, emptyState

<NotificationBell />

Renders the unread count. Pass a custom renderer for full control:

import { NotificationBell } from "@notifykitjs/react"

// Default: renders "(3)" when 3 unread
<NotificationBell />

// Custom:
<NotificationBell render={({ unreadCount }) => (
  <div className="bell-icon">
    {unreadCount > 0 && <span className="badge">{unreadCount}</span>}
  </div>
)} />

<Inbox />

Renders the full inbox list with mark-read buttons. Customize with renderItem and emptyState:

import { Inbox } from "@notifykitjs/react"

<Inbox
  emptyState={<p>You're all caught up!</p>}
  renderItem={({ item, markRead }) => (
    <div className="notification-card">
      <h4>{item.title}</h4>
      <p>{item.body}</p>
      {item.actionUrl && <a href={item.actionUrl}>View</a>}
      {!item.readAt && (
        <button onClick={() => markRead(item.id)}>Dismiss</button>
      )}
    </div>
  )}
/>
Common pattern. Use <NotificationBell /> in your nav bar and <Inbox /> in a dropdown or dedicated page. Both share the same useInbox() state under the hood — the unread count updates when items are read.

Client SDK (advanced)

Not using React? The client SDK is the same HTTP layer that powers the hooks — just without the React state management. Use it with Vue, Svelte, vanilla JS, or server-side scripts.

For non-React environments or custom integrations, use the client directly:

import { createNotifyKitClient } from "@notifykitjs/react"

const client = createNotifyKitClient({ baseUrl: "/api/notifykit" })

// Inbox operations
const items = await client.inbox.list()
await client.inbox.markRead(items[0].id)
await client.inbox.markAllRead()
await client.inbox.archive(items[0].id)
await client.inbox.deleteItem(items[0].id)

// Preferences
const prefs = await client.preferences.list()
await client.preferences.update({
  notificationId: "comment_mentioned",
  channels: { email: false },
})

// Notification metadata (for building settings UIs)
const notifications = await client.notifications.list()
// [{ id: "comment_mentioned", channels: ["inbox","email"], ... }]

Putting it together

The most common pattern: a bell icon in your nav that opens a notification dropdown. Both components share state automatically through the provider.

import { useInbox } from "@notifykitjs/react"
import { useState } from "react"

function NotificationCenter() {
  const { items, unreadCount, markRead, markAllRead } = useInbox()
  const [open, setOpen] = useState(false)

  return (
    <div className="relative">
      <button onClick={() => setOpen(!open)}>
        🔔 {unreadCount > 0 && <span className="badge">{unreadCount}</span>}
      </button>

      {open && (
        <div className="dropdown">
          <div className="header">
            <span>Notifications</span>
            {unreadCount > 0 && (
              <button onClick={markAllRead}>Mark all read</button>
            )}
          </div>
          {items.length === 0
            ? <p>You're all caught up!</p>
            : items.map(item => (
                <a
                  key={item.id}
                  href={item.actionUrl ?? "#"}
                  onClick={() => !item.readAt && markRead(item.id)}
                  className={item.readAt ? "read" : "unread"}
                >
                  <strong>{item.title}</strong>
                  {item.body && <p>{item.body}</p>}
                </a>
              ))
          }
        </div>
      )}
    </div>
  )
}

Realtime

When a realtime adapter is configured server-side, useInbox() automatically connects and receives live updates. New inbox items appear instantly, and the unread count updates in real time.

realtimeStatusMeaningUser experience
"connected"SSE stream openNew items appear instantly without refresh
"connecting"Handshake in progressShow a subtle loading indicator
"disconnected"No realtime adapter configuredFalls back to polling on refresh()
No config needed client-side. The React hooks detect the SSE endpoint automatically. If the server has a realtime adapter, the client connects. If not, everything works the same — just without live pushes.

Common UI recipes

Real-world inbox UIs need more than a flat list. These patterns use the same useInbox() data — no extra API calls.

Filtering: all / unread / archived

function FilteredInbox() {
  const { items, refresh } = useInbox()
  const [tab, setTab] = useState<"all" | "unread" | "archived">("all")

  const filtered = items.filter(item => {
    if (tab === "unread") return !item.readAt
    if (tab === "archived") return !!item.archivedAt
    return !item.archivedAt // "all" = non-archived
  })

  return (
    <>
      <nav className="tabs" role="tablist">
        {(["all", "unread", "archived"] as const).map(t => (
          <button
            key={t}
            role="tab"
            aria-selected={tab === t}
            onClick={() => setTab(t)}
          >
            {t === "all" ? "All" : t === "unread" ? "Unread" : "Archived"}
          </button>
        ))}
      </nav>
      {filtered.length === 0
        ? <p className="empty">No {tab} notifications</p>
        : filtered.map(item => <NotificationCard key={item.id} item={item} />)
      }
    </>
  )
}

Grouping by date

function GroupedInbox() {
  const { items } = useInbox()

  const groups = Object.groupBy(items, item => {
    const d = new Date(item.createdAt)
    const today = new Date()
    if (d.toDateString() === today.toDateString()) return "Today"
    const yesterday = new Date(today)
    yesterday.setDate(yesterday.getDate() - 1)
    if (d.toDateString() === yesterday.toDateString()) return "Yesterday"
    return d.toLocaleDateString(undefined, { month: "short", day: "numeric" })
  })

  return (
    <>
      {Object.entries(groups).map(([label, groupItems]) => (
        <section key={label}>
          <h4 className="group-label">{label}</h4>
          {groupItems.map(item => <NotificationCard key={item.id} item={item} />)}
        </section>
      ))}
    </>
  )
}

Rendering by notification type

Real inboxes render different notification types with different UIs — a team invite needs action buttons, a comment mention shows a preview, and a deploy notification shows a status badge. Use item.notificationId to branch:

function TypedNotificationCard({ item, markRead }: {
  item: InboxItem
  markRead: (id: string) => Promise<InboxItem>
}) {
  switch (item.notificationId) {
    case "team_invite":
      return (
        <div className="notification-card notification-card--invite">
          <strong>{item.title}</strong>
          <div className="notification-actions">
            <button onClick={() => acceptInvite(item)}>Accept</button>
            <button onClick={() => declineInvite(item)}>Decline</button>
          </div>
        </div>
      )

    case "comment_mentioned":
      return (
        <div className="notification-card notification-card--mention">
          <strong>{item.title}</strong>
          {item.body && <blockquote>{item.body}</blockquote>}
          <a href={item.actionUrl ?? "#"}>View thread</a>
        </div>
      )

    case "deploy_succeeded":
    case "deploy_failed":
      return (
        <div className="notification-card notification-card--deploy">
          <span className={item.notificationId === "deploy_succeeded" ? "badge-green" : "badge-red"}>
            {item.notificationId === "deploy_succeeded" ? "✓" : "✗"}
          </span>
          <strong>{item.title}</strong>
          {item.actionUrl && <a href={item.actionUrl}>View logs</a>}
        </div>
      )

    default:
      return (
        <div className="notification-card">
          <strong>{item.title}</strong>
          {item.body && <p>{item.body}</p>}
          {!item.readAt && (
            <button onClick={() => markRead(item.id)}>Dismiss</button>
          )}
        </div>
      )
  }
}
PatternWhen to useConsideration
Switch on notificationId3–10 notification types with distinct UIsSimple and explicit. Add a default case for unknown types to avoid blank cards after deploys.
Registry object10+ types or dynamic rendering (plugins, third-party)Map notificationId → Component. Scales without long switch blocks.
Feature-based branchingShared structure with small variations (badge color, icon)One component with conditional props. Best when types differ only in accent, not layout.
// Registry pattern — scales to many types without a long switch
const RENDERERS: Record<string, React.ComponentType<{ item: InboxItem }>> = {
  team_invite: InviteCard,
  comment_mentioned: MentionCard,
  deploy_succeeded: DeployCard,
  deploy_failed: DeployCard,
}

function NotificationCard({ item }: { item: InboxItem }) {
  const Renderer = RENDERERS[item.notificationId] ?? GenericCard
  return <Renderer item={item} />
}
Always include a default renderer. When you add a new notification type server-side, users with an older client bundle will receive items with an unrecognized notificationId. The default case prevents blank cards between deploys.

Infinite scroll / load more

function PaginatedInbox() {
  const { items, status } = useInbox()
  const [visibleCount, setVisibleCount] = useState(10)

  const visible = items.slice(0, visibleCount)
  const hasMore = visibleCount < items.length

  return (
    <>
      {visible.map(item => <NotificationCard key={item.id} item={item} />)}
      {hasMore && (
        <button onClick={() => setVisibleCount(c => c + 10)}>
          Load more ({items.length - visibleCount} remaining)
        </button>
      )}
    </>
  )
}
PatternWhen to useConsideration
Tab filteringInbox with 20+ items where unread mattersClient-side filter — all items are already loaded by the hook
Date groupingActivity feed or timeline-style UIUse Object.groupBy() (ES2024) or a polyfill
Load more / infinite scrollLarge inboxes (100+ items)Slice the already-fetched array — for true pagination, pass limit to the API
Empty states per tabAlways — avoids confusing blank screensDifferent message per tab: "All caught up!" vs "No archived items"
All filtering is client-side. The hook fetches the full inbox once. Tabs, grouping, and search filter the same array in memory — no extra network requests. For server-side filtering (large inboxes), pass ?archived=true or ?limit=50 to the REST API directly via createNotifyKitClient().

Toast notifications on new items

The inbox list shows history — but users want to know immediately when something new arrives. A toast (temporary popup) bridges that gap. Here's how to react to new realtime items and show a transient notification:

import { useInbox } from "@notifykitjs/react"
import { useEffect, useRef, useState } from "react"

function useNotificationToast() {
  const { items } = useInbox()
  const [toast, setToast] = useState<{ id: string; title: string } | null>(null)
  const prevCount = useRef(items.length)

  useEffect(() => {
    // Only trigger on new items (count increased), not on initial load
    if (items.length > prevCount.current && prevCount.current > 0) {
      const newest = items[0]
      setToast({ id: newest.id, title: newest.title })

      // Auto-dismiss after 5 seconds
      const timer = setTimeout(() => setToast(null), 5000)
      return () => clearTimeout(timer)
    }
    prevCount.current = items.length
  }, [items.length])

  return { toast, dismiss: () => setToast(null) }
}

function AppLayout({ children }: { children: React.ReactNode }) {
  const { toast, dismiss } = useNotificationToast()

  return (
    <>
      {children}
      {toast && (
        <div className="toast" role="status" aria-live="polite">
          <span>{toast.title}</span>
          <button onClick={dismiss} aria-label="Dismiss">×</button>
        </div>
      )}
    </>
  )
}
DecisionWhy
Check prevCount > 0Prevents a toast on initial page load — only triggers for live arrivals
Use items[0] as the newestItems are sorted newest-first by default. If you re-sort, adjust the index.
Auto-dismiss after 5 secondsToasts shouldn't require action. Persistent popups become banner blindness.
role="status" + aria-live="polite"Screen readers announce the toast without interrupting the current task

Stacking multiple toasts

When notifications arrive in rapid succession (e.g. during a broadcast), queue them instead of replacing:

function useToastQueue(maxVisible = 3) {
  const { items } = useInbox()
  const [toasts, setToasts] = useState<Array<{ id: string; title: string }>>([])
  const prevCount = useRef(items.length)

  useEffect(() => {
    if (items.length > prevCount.current && prevCount.current > 0) {
      const newest = items[0]
      setToasts(prev => [{ id: newest.id, title: newest.title }, ...prev].slice(0, maxVisible))

      setTimeout(() => {
        setToasts(prev => prev.filter(t => t.id !== newest.id))
      }, 5000)
    }
    prevCount.current = items.length
  }, [items.length])

  return { toasts, dismiss: (id: string) => setToasts(prev => prev.filter(t => t.id !== id)) }
}
Cap visible toasts at 3. More than three stacked toasts overwhelm the screen. If a burst arrives (5 notifications in 2 seconds), show the first 3 and let them auto-dismiss — the inbox list has the rest. For digest-worthy bursts, consider showing "3 new notifications" as a single toast instead.

Accessibility

Notification UIs are difficult to get right for screen readers and keyboard users. Follow these patterns to ensure all users can interact with their notifications:

PatternWhyImplementation
Live region for new itemsScreen readers announce new notifications without focus changearia-live="polite" on the inbox container
Badge announces countUsers can't see a visual badge — they need a text equivalentaria-label=`${unreadCount} unread notifications`
Keyboard-navigable listArrow keys should move between items, Enter/Space should activaterole="listbox" with role="option" children
Action buttons labeled"Mark read" is ambiguous without context of which itemaria-label=`Mark ${item.title} as read`
function AccessibleInbox() {
  const { items, unreadCount, markRead } = useInbox()

  return (
    <>
      <button aria-label={`Notifications, ${unreadCount} unread`}>
        🔔 {unreadCount > 0 && <span aria-hidden="true">{unreadCount}</span>}
      </button>

      <ul role="listbox" aria-label="Notifications" aria-live="polite">
        {items.map(item => (
          <li key={item.id} role="option" aria-selected={!item.readAt}>
            <a href={item.actionUrl ?? "#"}>
              <strong>{item.title}</strong>
              {item.body && <p>{item.body}</p>}
            </a>
            {!item.readAt && (
              <button
                aria-label={`Mark "${item.title}" as read`}
                onClick={() => markRead(item.id)}
              >
                Dismiss
              </button>
            )}
          </li>
        ))}
      </ul>
    </>
  )
}
Test with VoiceOver or NVDA. Open your inbox, send a test notification, and verify the screen reader announces it without stealing focus. Then tab through the list and confirm every action is reachable and labeled.

Testing components

Components that use NotifyKit hooks need a provider wrapper in tests. The provider talks to a real (in-memory) NotifyKit instance — no HTTP mocking needed.

ApproachSpeedWhat it testsBest for
In-memory handlerSub-millisecondFull round-trip: hook → API → database → responseMost component tests — realistic without network
Mock fetchInstantComponent rendering given specific API responsesEdge cases (errors, empty states, malformed data)
E2E (Playwright/Cypress)SecondsReal browser, real server, real SSESmoke tests, realtime behavior, cross-browser
import { render, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { describe, it, expect, beforeEach } from "vitest"
import { NotifyKitProvider } from "@notifykitjs/react"
import { createNotifyKit, memoryAdapter, fakeEmailProvider, createHandler } from "@notifykitjs/core"
import { commentMentioned } from "@/lib/notifications"
import { InboxPage } from "./inbox-page" // your component

let testNotify
let handler

// 3. Wrapper that intercepts fetch and routes to the handler
function TestWrapper({ children }: { children: React.ReactNode }) {
  return (
    <NotifyKitProvider
      options={{
        baseUrl: "/api/notifykit",
        fetch: (input, init) => {
          const url = typeof input === "string"
            ? input
            : input instanceof URL
              ? input.toString()
              : input.url
          return handler(new Request(new URL(url, "http://test"), init))
        },
      }}
    >
      {children}
    </NotifyKitProvider>
  )
}

describe("InboxPage", () => {
  beforeEach(async () => {
    testNotify = createNotifyKit({
      notifications: [commentMentioned] as const,
      database: memoryAdapter(),
      providers: { email: fakeEmailProvider() },
    })
    handler = createHandler(testNotify, {
      identify: async () => ({ recipientId: "test_user" }),
    })
    await testNotify.upsertRecipient({ id: "test_user", email: "test@test.com" })
  })

  it("renders inbox items after send", async () => {
    await testNotify.send({
      recipientId: "test_user",
      notificationId: "comment_mentioned",
      payload: { actorName: "Rey", postUrl: "/p/1" },
    })

    render(<InboxPage />, { wrapper: TestWrapper })

    await waitFor(() => {
      expect(screen.getByText(/Rey mentioned you/)).toBeInTheDocument()
    })
  })

  it("marks item as read on click", async () => {
    await testNotify.send({
      recipientId: "test_user",
      notificationId: "comment_mentioned",
      payload: { actorName: "Sam", postUrl: "/p/2" },
    })

    render(<InboxPage />, { wrapper: TestWrapper })

    await waitFor(() => {
      expect(screen.getByText(/Sam mentioned you/)).toBeInTheDocument()
    })

    await userEvent.click(screen.getByRole("button", { name: /mark.*read/i }))

    await waitFor(() => {
      expect(screen.queryByRole("button", { name: /mark.*read/i })).not.toBeInTheDocument()
    })
  })

  it("shows empty state when inbox is empty", async () => {
    render(<InboxPage />, { wrapper: TestWrapper })

    await waitFor(() => {
      expect(screen.getByText(/no notifications/i)).toBeInTheDocument()
    })
  })
})

Testing preferences toggles

import { PreferencesPage } from "./preferences-page"

it("toggles email off for a notification", async () => {
  render(<PreferencesPage />, { wrapper: TestWrapper })

  await waitFor(() => {
    expect(screen.getByLabelText(/email.*comment/i)).toBeChecked()
  })

  await userEvent.click(screen.getByLabelText(/email.*comment/i))

  await waitFor(() => {
    expect(screen.getByLabelText(/email.*comment/i)).not.toBeChecked()
  })

  // Verify the preference persisted
  const e = await testNotify.explain({
    recipientId: "test_user",
    notificationId: "comment_mentioned",
    payload: { actorName: "Rey", postUrl: "/p/1" },
  })
  expect(e.channels.email.outcome).toBe("disabled")
})

Testing error states

// For testing error UI, use a mock fetch that returns an error
function ErrorWrapper({ children }: { children: React.ReactNode }) {
  return (
    <NotifyKitProvider
      options={{
        baseUrl: "/api/notifykit",
        fetch: () => Promise.resolve(Response.json(
          { error: "Unauthenticated", code: "UNAUTHENTICATED" },
          { status: 401 },
        )),
      }}
    >
      {children}
    </NotifyKitProvider>
  )
}

it("shows error state on 401", async () => {
  render(<InboxPage />, { wrapper: ErrorWrapper })

  await waitFor(() => {
    expect(screen.getByText(/failed to load/i)).toBeInTheDocument()
    expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument()
  })
})
What to testHowCatches
Items render after sendSend via testNotify, assert DOMBroken data mapping, missing fields, template errors
Mark read updates UIClick button, assert item changes visuallyOptimistic update bugs, missing API calls
Empty state showsRender with no items, assert placeholderConditional rendering bugs, flash of wrong state
Error state with retryMock 401 response, click retryMissing error handling, broken retry logic
Preference toggle persistsClick checkbox, verify via explain()Optimistic revert, scope mismatch, wrong API payload
Unread count badgeSend items, assert count in DOMStale count, count not updating after markRead
No HTTP mocking library needed. The fetch option in the provider's options accepts a custom fetch function. Point it at your in-memory handler and your tests exercise the real API contract — serialization, status codes, and all — without network I/O or msw setup.

Troubleshooting

Most hook issues come from provider misconfiguration or timing mismatches between server sends and client state. Match your symptom to the fix:

SymptomCauseFix
useInbox() returns empty items forever<NotifyKitProvider> missing or baseUrl wrongWrap your layout in the provider. Check DevTools Network tab — you should see a request to /api/notifykit/inbox.
Hook returns status: "error" with no detailsidentify() returns null — user isn't authenticatedThe handler returns 401 when identity can't be resolved. Verify session cookies are sent (credentials: "include" is set by the SDK).
Items appear on refresh but not in realtimeNo realtime adapter configured server-sideCheck realtimeStatus — if "disconnected", add a realtime adapter to createNotifyKit(). See Realtime.
SSE reconnects every few seconds in devNext.js HMR or React Strict Mode remounting the componentExpected in development — the hook cleans up and reconnects on remount. Doesn't happen in production builds.
markRead() works visually but reverts after 1 secondServer rejected the mutation (wrong recipient or tenant scope)Check the Network tab for a non-200 response. The identify() scope must match the item's scope.
Preferences toggle doesn't persist across page reloadstenantId mismatch between client and server contextsIf your app uses multi-tenancy, ensure identify() returns the same tenantId as the original send().
Unread count badge doesn't update when useInbox() marks items readUsing useUnreadCount() in a component outside the same provider treeBoth hooks must share the same <NotifyKitProvider> ancestor. One provider per app, usually in the root layout.
TypeScript error: Cannot find module '@notifykitjs/react'Package not installed, or moduleResolution is too restrictiveRun npm install @notifykitjs/react. Ensure tsconfig.json uses "moduleResolution": "bundler" or "node16".
Debug with the Network tab first. Hooks are thin wrappers around HTTP calls to your handler. If the Network tab shows successful responses with data, the issue is in your rendering logic. If it shows errors or empty responses, the issue is server-side (auth, routing, or missing data).

Development vs production behavior

Some behaviors differ between development and production. Know which "bugs" are just dev-mode artifacts:

BehaviorIn developmentIn productionWhy
SSE connects twice on mountYes (React Strict Mode)No — single connectionStrict Mode mounts → unmounts → remounts to catch cleanup bugs
Inbox flashes empty then loadsMore noticeable (slower HMR rebuild)Depends on network, auth, database, and renderingMeasure loading behavior in the deployed application
Console warning about unmounted state updateOccasionally during HMRShould not occurInvestigate cleanup if it appears outside HMR
Data resets between savesYes (if using memoryAdapter())No — persistent databaseIn-memory adapter loses state on server restart. Use SQLite for persistent dev data.