The screen in Nakodo that shows a brand its conversations with creators has five views: Needs you, In progress, Introduced, Closed, All. It is behind the login, so you cannot look at it, which you can confirm in one line:

$ curl -sI https://nakodo.app/campaigns/x/outreach | head -2 HTTP/2 307 location: /login?next=%2Fcampaigns%2Fx%2Foutreach 

What I can show you is the code, and the thing worth showing is not the table. It is the eight different ways this one screen says "there is nothing here", because that turned out to be where most of the thinking went.

Five views over eight states

A conversation has one of eight statuses, and they live in the schema with a comment each:

export const OUTREACH_STATUSES = [ "drafting", // the first email is being written "review", // waiting for the brand to approve the first email "queued", // approved; goes out at nextActionAt "contacted", // sent, no reply yet "talking", // the creator replied and Nakodo answered "needs_you", // the creator asked something only the brand can answer "handed_off", // the creator is interested and was introduced to the brand "closed", ] as const; 

The views are a separate list, in src/lib/outreach/labels.ts, and they are deliberately not one view per status:

export const THREAD_VIEWS = [ { key: "attention", label: "Needs you", statuses: ["needs_you", "review"] }, { key: "active", label: "In progress", statuses: ["drafting", "queued", "contacted", "talking"] }, { key: "introduced", label: "Introduced", statuses: ["handed_off"] }, { key: "closed", label: "Closed", statuses: ["closed"] }, { key: "all", label: "All", statuses: null }, ] as const satisfies readonly { key: string; label: string; statuses: readonly OutreachStatus[] | null }[]; 

Eight statuses, four buckets, one catch-all with statuses: null. The grouping is by who the next move belongs to, not by what happened: Needs you is the two states where the brand has to do something, In progress is the four where Nakodo does, Introduced and Closed are the two where nobody does.

Count the statuses in those four lists and you get eight, each one exactly once. That is a partition, it is load bearing, and nothing enforces it. Add a ninth status tomorrow and it appears in All, in none of the other four views, and no test fails. The fix is a type-level one: a check that the union of the four statuses arrays is OutreachStatus, which TypeScript can express and which I have not written. That is the first of two findings in this post.

The screen opens on the view with work in it

const attention = inView("attention").length; const requested = THREAD_VIEWS.find((v) => v.key === sp.view)?.key; const view: ThreadView = requested ?? (attention > 0 ? "attention" : "all"); 

No stored preference, no "last view" cookie. If the URL asks for a view, you get it; otherwise the screen opens on Needs you when something needs you, and All when nothing does. An unknown ?view= in the URL falls through the same ??, because requested comes from a lookup in the list rather than from the query string, so a typo or a stale link is not an error, it is just the default.

The tab bar shows the count of every view next to its label, and the one that needs the brand gets a mark beside it:

<nav className="..." aria-label="Conversations by state"> {THREAD_VIEWS.map((v) => ( <Link key={v.key} href={href({ view: v.key })} aria-current={view === v.key ? "page" : undefined} scroll={false} ...> {v.key === "attention" && attention > 0 && <AttentionMark label="Waiting on you" />} {v.label} <span className="text-xs tabular-nums opacity-80">{inView(v.key).length}</span> </Link> ))} </nav> 

scroll={false} because switching view should not jump the page to the top, aria-current="page" because the selected tab is a link to the page you are on, and the counts are tabular-nums so they do not shuffle the labels sideways as they change.

One more line in that area is the sort:

const shown = view === "active" ? scheduledFirst(inView(view)) : inView(view); 

Only In progress is reordered, and the comment says why: "In progress leads with the emails waiting to go out, soonest first: the rest of that view is already out of the brand's hands." Four of the five views are a list of things to read. One of them is a timetable.

The list gets key={view}, which remounts it on every view change, with one line of explanation:

<ThreadList // A new view starts again at the first page, with nothing selected. key={view} 

Using key to throw client state away is one of the few React tricks I reach for without hesitating. The alternative is an effect that watches view and resets three pieces of state, and that effect will be wrong the day a fourth piece is added.

Four ways to have no conversations

Here is the first empty state, which is not one empty state:

<EmptyState illustration={<AlcyEnvelope size="m" className="illo-loop" />} title="No conversations yet."> {!automatic ? `Select ${w.many} on the ${w.Many} tab and choose Contact. Nakodo writes the first email and takes it from there.` : !hasOffer ? "Save your proposal first. Nakodo then starts writing to the best fits, and the first emails appear here." : !startsConversations(campaign) ? "Nakodo starts writing to the best fits once the campaign's emails are running." : "Nakodo writes to the best fits as they're found. The first emails appear here within a few minutes."} </EmptyState> 

Same heading, four different bodies, in a deliberate order: what you must do, then what you must do first, then what is switched off, then nothing is wrong and it is coming.

The reason this is four sentences and not one is that "No conversations yet" has four completely different meanings in this product, and three of them are actionable. A brand that chose manual sending is waiting for itself. A brand with no proposal saved has blocked the thing it is waiting for. A paused campaign is not going to produce anything, ever, until it is resumed. And a brand that has done everything right is correctly looking at an empty screen for a couple of minutes.

A single generic sentence there, which is what the first version had, is the app knowing exactly why nothing is happening and refusing to say.

Four more, one per view

Once there are conversations but the current view has none, each view says what would be in it:

View Heading Body
Needs you Nothing needs you right now. First emails to approve and replies Nakodo can't answer show up here.
In progress Nothing in progress right now. First emails being written or waiting to go out, and conversations Nakodo is carrying on, show up here.
Introduced No introductions yet. When a creator says yes, Nakodo introduces you by email, and they're listed here.
Closed Nothing closed yet. When a creator says no, doesn't reply after the follow-ups, or you stop a conversation, it's listed here.

Every one of those bodies is a definition of the view. An empty view is the only moment a reader will ever read the definition of that view, because a view with rows in it explains itself. So the empty state is not an apology, it is documentation that appears exactly when it is useful and never when it is not.

There is no fifth row in that table, and there is a comment saying why:

) : shown.length === 0 ? ( // All is never empty once there are conversations, so only the other views get here. 

Which is true, and is an invariant expressed in a comment rather than in code, and is the second finding: if that ever stops being true the screen renders the Closed empty state for the All view, because the chain of ternaries ends with closed as the fallback. A switch over the four keys with no default would have made that impossible, the same way the rest of the repo does it.

The mascot changes with the state too: an envelope that loops while something is being written, a perched bird when nothing needs you, a ring for introductions, a sleeping bird for closed. There are twelve poses of the mascot in src/components/illo and twenty EmptyState call sites in the app; the one with no illustration at all is the error boundary, which was its own post, because a mascot next to a failure is the app being pleased with itself while the customer is not.

What it adds up to

Eight states, five views, eight empty strings of prose, and two findings worth fixing: a partition that nothing checks, and a ternary chain whose fallback is a lie waiting to happen.

The ratio is the thing I keep noticing. The table in this screen is a few dozen lines. The explanation of what it means when the table has nothing in it is more than that, and it is the part people actually read, because every single user sees the empty version of this screen before they ever see a full one. How it works is the public version of the same sentences, written for somebody who has not signed up yet.