React hooks and components
Build an inbox, a bell with an unread count, and a preferences page with typed hooks and components.
Provider
@notifykitjs/react works with React 18 and later. It talks to the handler routes, so mount those first (in Next.js, see the route handler), then wrap the signed-in part of your app:
npm install @notifykitjs/reactimport { NotifyKitProvider } from "@notifykitjs/react"
<NotifyKitProvider options={{ baseUrl: "/api/notifykit", realtime: true }}>
{children}
</NotifyKitProvider>| Option | Default | Does |
|---|---|---|
baseUrl | "/api/notifykit" | Where the handler is mounted |
realtime | false | Open a live stream for inbox updates. Needs a realtime adapter on the server |
credentials | "same-origin" | Passed to every fetch; use "include" for another origin |
headers | none | Added to every request, such as an Authorization token |
fetch | globalThis.fetch | A custom fetch, for tests or SSR |
onRealtimeError | none | Called when the stream fails, for logging |
The client is created once, on mount; changing options later has no effect. To share one client or build it yourself, pass client={createNotifyKitClient(...)} instead. Every hook under the same provider shares one store, so marking an item read in the inbox updates the bell’s count too.
useInbox()
"use client"
import { useInbox } from "@notifykitjs/react"
export function InboxList() {
const { items, status, markRead, refresh } = useInbox()
if (status === "error") return <button onClick={refresh}>Retry</button>
if (status === "loading" && items.length === 0) return <Skeleton />
if (items.length === 0) return <p>You're all caught up.</p>
return items.map((item) => (
<button
key={item.id}
onClick={() => markRead(item.id)}
data-read={!!item.readAt}
>
{item.title}
</button>
))
}| Returns | Type |
|---|---|
items | InboxItem[], newest first |
status | "idle" | "loading" | "ready" | "error" |
error | string | null |
unreadCount | number |
realtimeStatus | "disconnected" | "connecting" | "connected" |
refresh() | Reload the list |
markRead(id), alias markAsRead | Mark one item read |
markAllRead() | Mark everything read; resolves to the count |
archive(id), unarchive(id) | Archiving removes the item from the list |
deleteItem(id), alias delete | Delete permanently |
| Option | Default | Does |
|---|---|---|
autoLoad | true | Fetch on mount |
pollInterval | false | Refetch every N milliseconds instead of using the realtime stream |
onNewItems | none | Called with items that appeared since the last load, from a refresh, a poll, or the stream |
status goes back to "loading" on every refresh, so check items.length before showing a skeleton, as above, or the list flickers on each poll.
Optimistic updates
Mark read, mark all read, archive, and delete change the list and the unread count at once, then call the server. If the server refuses, the change is rolled back, error is set, and the promise rejects, so catch it where you want to tell the user.
useUnreadCount()
For a badge when you are not rendering the list: it asks for the count alone, not the items.
const { unreadCount } = useUnreadCount()
if (unreadCount === 0) return null
return <span className="badge">{unreadCount > 99 ? "99+" : unreadCount}</span>It returns unreadCount, status, error, and refresh(), and takes the same autoLoad and pollInterval options. If useInbox() is already on the page, its unreadCount costs nothing extra.
usePreferences()
const { isEnabled, update } = usePreferences()
<input
type="checkbox"
checked={isEnabled("comment_mentioned", "email")}
onChange={(e) =>
update({
notificationId: "comment_mentioned",
channels: { email: e.target.checked },
})
}
/>| Returns | Does |
|---|---|
items | The recipient’s saved per-notification preferences |
status, error | As in useInbox() |
isEnabled(notificationId, channel) | False only if a saved preference turns that channel off |
update({ notificationId, channels }) | Save channels for one notification, optimistically, rolling back on error |
refresh() | Reload |
A full settings page
client.notifications.list() returns every notification’s id, channels, category, description, and required flag, which is what a settings page needs to lay itself out:
const client = useNotifyKitClient()
const { isEnabled, update } = usePreferences()
const [defs, setDefs] = useState<NotificationMetadata[]>([])
useEffect(() => { client.notifications.list().then(setDefs) }, [client])
return defs.map((n) => (
<fieldset key={n.id}>
<legend>{n.description ?? n.id}</legend>
{n.channels.map((ch) => (
<label key={ch}>
<input
type="checkbox"
disabled={n.required}
checked={isEnabled(n.id, ch as ChannelType)}
onChange={(e) =>
update({
notificationId: n.id,
channels: { [ch]: e.target.checked },
})
}
/>
{ch}
</label>
))}
</fieldset>
))Group by n.category for sections. Required notifications cannot be turned off, so disable their toggles.
Components
Two unstyled components cover the common case; both take render props for your own markup.
import { Inbox, NotificationBell } from "@notifykitjs/react"
<NotificationBell render={({ unreadCount }) => <BellIcon count={unreadCount} />} />
<Inbox
emptyState={<p>You're all caught up.</p>}
renderItem={({ item, markRead }) => (
<Row item={item} onOpen={() => markRead(item.id)} />
)}
/><NotificationBell />renders(3)by default, with anaria-labelgiving the count. It usesuseInbox(), so it loads the list.<Inbox />renders a list with each item’s title, body, an Open link for safehttp(s)action URLs, and a Mark read button, plus loading, error, and empty states.
Recipes
Toasts for new items
onNewItems fires only for items that arrive after the first load, so it will not toast the whole inbox on page load:
useInbox({
onNewItems: (items) => items.forEach((item) => toast(item.title)),
})A card per notification type
const CARDS: Record<string, React.ComponentType<{ item: InboxItem }>> = {
team_invite: InviteCard,
comment_mentioned: MentionCard,
}
function Card({ item }: { item: InboxItem }) {
const Render = CARDS[item.notificationId] ?? GenericCard
return <Render item={item} />
}Keep the fallback: a browser still running last week’s bundle will receive notification types it has never seen.
Tabs and grouping
Filter items in memory for Unread (!item.readAt), or group by item.createdAt. Archived items are not in the default list; client.inbox.list({ archived: true }) loads them.
Accessibility
- Give the bell a label with the count, such as
`Notifications, ${unreadCount} unread`. - Put
aria-live="polite"on the list or toast region so new items are announced. - Label each action with its item:
Mark “Rey mentioned you” as read, not just Mark read.
Client Without React
createNotifyKitClient() is the same client the hooks use, for Vue, Svelte, or plain scripts. It takes the provider’s options.
import { createNotifyKitClient } from "@notifykitjs/react"
const client = createNotifyKitClient({ baseUrl: "/api/notifykit", realtime: true })
const unsubscribe = client.subscribe(() => render(client.getState()))
await client.inbox.list()
client.connect() // open the realtime stream; disconnect() closes itIt has inbox (list, unreadCount, markRead, markAllRead, archive, unarchive, deleteItem), preferences (list, update, and the global and category methods), and notifications.list(). Inside a provider, useNotifyKitClient() returns it.
Testing
Point the provider’s fetch at a handler over an in-memory NotifyKit, and component tests run the real routes with no server or mocks:
const notify = createNotifyKit({
notifications: [commentMentioned] as const,
database: memoryAdapter(),
providers: { email: fakeEmailProvider() },
})
const handler = createHandler(notify, { identify: () => "test_user" })
function Wrapper({ children }: { children: React.ReactNode }) {
return (
<NotifyKitProvider
options={{
fetch: (input, init) =>
handler(new Request(new URL(String(input), "http://t"), init)),
}}
>
{children}
</NotifyKitProvider>
)
}Send a notification with notify.send(), render your component inside Wrapper, and assert on what appears. Testing and dev mode covers the server side.