React hooks & components
The @notifykitjs/react package provides hooks and pre-built components for building notification UIs. Everything is typed against your notification definitions.
| Approach | When 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/reactWrap your app in the provider (see Next.js integration):
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()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 field | Type | Description |
|---|---|---|
items | InboxItem[] | Current inbox items |
status | "idle" | "loading" | "ready" | "error" | Fetch state |
unreadCount | number | Count 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 |
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} />
))
}status | Render | Notes |
|---|---|---|
"idle" | Nothing (or skeleton) | Brief initial state before first fetch starts |
"loading" | Skeleton / spinner | First load only — subsequent refreshes don't reset status |
"ready" | Your inbox UI | items is populated |
"error" | Error message + retry button | Usually a 401 (session expired) or network failure |
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 field | Type | Description |
|---|---|---|
items | RecipientPreference[] | All preferences for this user |
status | "idle" | "loading" | "ready" | "error" | Fetch state |
error | string | null | Error message if fetch/update failed |
isEnabled(id, channel) | boolean | Check if a channel is enabled for a notification |
update(input) | Promise<RecipientPreference> | Toggle channels (optimistic) |
refresh() | Promise<RecipientPreference[]> | Re-fetch from server |
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>
)
}| Detail | Why |
|---|---|
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.required | Required 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 |
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 field | Type | Description |
|---|---|---|
unreadCount | number | Current unread notification count |
status | "idle" | "loading" | "ready" | "error" | Fetch state |
error | string | null | Error message if the count request failed |
refresh() | Promise<number> | Manually re-fetch the count |
Options
| Option | Type | Default | Description |
|---|---|---|---|
pollInterval | number | false | false | Milliseconds 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.
useInbox decrements useUnreadCount automatically.Pre-built components
| Component | What it renders | Customization |
|---|---|---|
<NotificationBell /> | Unread count badge | render prop |
<Inbox /> | Full inbox list with actions | renderItem, 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>
)}
/><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)
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.
realtimeStatus | Meaning | User experience |
|---|---|---|
"connected" | SSE stream open | New items appear instantly without refresh |
"connecting" | Handshake in progress | Show a subtle loading indicator |
"disconnected" | No realtime adapter configured | Falls back to polling on refresh() |
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>
)
}
}| Pattern | When to use | Consideration |
|---|---|---|
Switch on notificationId | 3–10 notification types with distinct UIs | Simple and explicit. Add a default case for unknown types to avoid blank cards after deploys. |
| Registry object | 10+ types or dynamic rendering (plugins, third-party) | Map notificationId → Component. Scales without long switch blocks. |
| Feature-based branching | Shared 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} />
}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>
)}
</>
)
}| Pattern | When to use | Consideration |
|---|---|---|
| Tab filtering | Inbox with 20+ items where unread matters | Client-side filter — all items are already loaded by the hook |
| Date grouping | Activity feed or timeline-style UI | Use Object.groupBy() (ES2024) or a polyfill |
| Load more / infinite scroll | Large inboxes (100+ items) | Slice the already-fetched array — for true pagination, pass limit to the API |
| Empty states per tab | Always — avoids confusing blank screens | Different message per tab: "All caught up!" vs "No archived items" |
?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>
)}
</>
)
}| Decision | Why |
|---|---|
Check prevCount > 0 | Prevents a toast on initial page load — only triggers for live arrivals |
Use items[0] as the newest | Items are sorted newest-first by default. If you re-sort, adjust the index. |
| Auto-dismiss after 5 seconds | Toasts 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)) }
}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:
| Pattern | Why | Implementation |
|---|---|---|
| Live region for new items | Screen readers announce new notifications without focus change | aria-live="polite" on the inbox container |
| Badge announces count | Users can't see a visual badge — they need a text equivalent | aria-label=`${unreadCount} unread notifications` |
| Keyboard-navigable list | Arrow keys should move between items, Enter/Space should activate | role="listbox" with role="option" children |
| Action buttons labeled | "Mark read" is ambiguous without context of which item | aria-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>
</>
)
}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.
| Approach | Speed | What it tests | Best for |
|---|---|---|---|
| In-memory handler | Sub-millisecond | Full round-trip: hook → API → database → response | Most component tests — realistic without network |
| Mock fetch | Instant | Component rendering given specific API responses | Edge cases (errors, empty states, malformed data) |
| E2E (Playwright/Cypress) | Seconds | Real browser, real server, real SSE | Smoke 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 test | How | Catches |
|---|---|---|
| Items render after send | Send via testNotify, assert DOM | Broken data mapping, missing fields, template errors |
| Mark read updates UI | Click button, assert item changes visually | Optimistic update bugs, missing API calls |
| Empty state shows | Render with no items, assert placeholder | Conditional rendering bugs, flash of wrong state |
| Error state with retry | Mock 401 response, click retry | Missing error handling, broken retry logic |
| Preference toggle persists | Click checkbox, verify via explain() | Optimistic revert, scope mismatch, wrong API payload |
| Unread count badge | Send items, assert count in DOM | Stale count, count not updating after markRead |
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:
| Symptom | Cause | Fix |
|---|---|---|
useInbox() returns empty items forever | <NotifyKitProvider> missing or baseUrl wrong | Wrap your layout in the provider. Check DevTools Network tab — you should see a request to /api/notifykit/inbox. |
Hook returns status: "error" with no details | identify() returns null — user isn't authenticated | The 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 realtime | No realtime adapter configured server-side | Check realtimeStatus — if "disconnected", add a realtime adapter to createNotifyKit(). See Realtime. |
| SSE reconnects every few seconds in dev | Next.js HMR or React Strict Mode remounting the component | Expected in development — the hook cleans up and reconnects on remount. Doesn't happen in production builds. |
markRead() works visually but reverts after 1 second | Server 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 reloads | tenantId mismatch between client and server contexts | If 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 read | Using useUnreadCount() in a component outside the same provider tree | Both 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 restrictive | Run npm install @notifykitjs/react. Ensure tsconfig.json uses "moduleResolution": "bundler" or "node16". |
Development vs production behavior
Some behaviors differ between development and production. Know which "bugs" are just dev-mode artifacts:
| Behavior | In development | In production | Why |
|---|---|---|---|
| SSE connects twice on mount | Yes (React Strict Mode) | No — single connection | Strict Mode mounts → unmounts → remounts to catch cleanup bugs |
| Inbox flashes empty then loads | More noticeable (slower HMR rebuild) | Depends on network, auth, database, and rendering | Measure loading behavior in the deployed application |
| Console warning about unmounted state update | Occasionally during HMR | Should not occur | Investigate cleanup if it appears outside HMR |
| Data resets between saves | Yes (if using memoryAdapter()) | No — persistent database | In-memory adapter loses state on server restart. Use SQLite for persistent dev data. |