Headless hook
useInstaFixInbox — all the inbox logic, none of the UI. Build your own triage view.
Everything InstaFixInbox does — fetching, filters, optimistic mutations, undo, pagination — lives in one hook you can drive from your own components:
import { useInstaFixInbox } from "@instafix/dashboard";
const inbox = useInstaFixInbox({
projects: "my-project",
endpoint: "/api/instafix",
});Options
The options are a union over the three source modes: supply exactly one of source, store or endpoint. Supplying none, supplying two, or pairing endpoint-only options (apiKey, headers) with store/source is a compile error — not a runtime throw and not a silently ignored option.
| Option | Type | Default | Notes |
|---|---|---|---|
projects | string or string[] | required | An empty array throws at render. The first entry is selected initially |
endpoint | string | — | HTTP mode |
store | InstaFixStore | — | Store mode |
source | InboxSource | — | Fully custom data source; see Data sources |
apiKey | string | — | Endpoint mode only. Authorization: Bearer on every request |
headers | object or function | — | Endpoint mode only. An explicit Authorization beats apiKey. Read live — changing it doesn't rebuild the source |
pageSize | number | 50 | Clamped to 1–100 |
onStatusChange | (feedback, previousStatus) => void | — | After the server confirms |
onDelete | (feedback) => void | — | After the server confirms |
onError | (error) => void | — | Every failed load or mutation |
Each mode is exported on its own — InboxEndpointOptions, InboxStoreOptions, InboxCustomSourceOptions, the shared InboxSharedOptions, and the UseInstaFixInboxOptions union — so a wrapper component can accept just the mode it supports. Plain-JavaScript callers get the old behavior: no source at all still throws at render.
What you get back
Data — items (all loaded pages), total (null until the first page lands), counts (per-status totals, best-effort), loading, loadingMore, error, hasMore, loadMore(), refresh().
Filters — project/setProject, status/setStatus (starts on "open"), type/setType, search/setSearch. Search updates state instantly and debounces the refetch by 250 ms; queries are trimmed and capped at 200 characters.
Focus & drawer — focusedId, focus(id), focusNext(), focusPrev(), openedId, opened (the open record — it stays available even if a filter removes its row), openFeedback(id), closeFeedback().
Mutations — changeStatus(id, status), deleteFeedback(id), pendingUndo, undo().
view — skip the flag algebra
Deciding between a skeleton, an error state, an empty state and the list from loading / error / items.length is fiddly, and getting it subtly wrong is how a refetch ends up blanking the screen. view is that decision, already made — the exact value the shipped component renders from:
view | Meaning |
|---|---|
"loading" | The first page is loading and there is nothing displayable yet |
"error" | The load failed and there is nothing displayable |
"empty" | The load succeeded and no rows match the current filters |
"ready" | Rows are displayable |
if (inbox.view === "loading") return <Skeleton />;
if (inbox.view === "error") return <ErrorState error={inbox.error} onRetry={inbox.refresh} />;
if (inbox.view === "empty") return <EmptyState />;
return <List items={inbox.items} />;Rows win over every other signal: during a refetch the already-loaded rows stay on screen and view stays "ready", so the list never flashes back to a skeleton. Read loading when you want a granular spinner on top of visible rows.
Mutations are optimistic — and they re-throw
The UI updates immediately; if the server rejects, the hook rolls everything back (items, counts, focus, the open record) and then re-throws. Always attach a catch:
<button
onClick={() => {
inbox.changeStatus(item.id, "resolved").catch(() => {
// state is already rolled back — surface a toast here
});
}}
>
Resolve
</button>Only the last status change is undoable (pendingUndo + undo()); delete is permanent, so gate it behind your own confirmation UI.
Pagination
loadMore() is manual — the bundled component renders a "Load more" button, not infinite scroll. Pages are deduplicated by id, and the next page number is derived from items.length, so optimistic removals never skip rows.