← Back to Journal

Engineering

Deleting one email shouldn’t reload your entire inbox

I clicked Delete. The interface waited, cleared the inbox and loaded the whole list again. The provider needed to confirm the operation. The screen did not need to behave as if I had just arrived. That small interruption exposed a larger mistake in how I was handling state.

The provider can stay authoritative without freezing the interface

An inbox has at least two relevant versions of reality during a mutation. There’s the last list confirmed by the provider, and there’s what the user has just asked the interface to do.

Those versions can briefly differ without the application becoming dishonest. When someone clicks Delete, the message can leave the visible list immediately while the provider request remains pending. If the request succeeds, the local view was already right. If it fails, the application restores the message and explains what happened.

This pattern is usually called an optimistic update. TanStack Query’s documentation demonstrates the important mechanics: cancel a conflicting refetch, snapshot or otherwise preserve the previous state, update the cache locally and roll back when the mutation fails. It then invalidates the relevant query to reconcile with the server. That documentation is evidence for the pattern, not a claim that OhFellow depends on a particular library. [S1]

Optimistic should not mean fictional. The interface shouldn’t announce permanent success while hiding the fact that the operation is still pending. It should make a reversible local change, retain enough information to recover and handle failure visibly.

The shape of the change

The work changed towards local list updates, cache preservation, rollback on failure and refreshes with a limited scope. The example below shows the shape of that solution. It’s illustrative TypeScript, not OhFellow’s production code.

There are several deliberate details here. The message is removed from the cached list before the provider answers. The removed item and its position are retained. A failure restores that item rather than replacing the entire cache with an old snapshot. A successful request schedules reconciliation instead of immediately blanking the screen.

The status check matters. The browser’s fetch() promise can fulfil even when a server returns an HTTP error such as 404, so code must inspect Response.ok or the status rather than treating every resolved promise as success. [S3]

typescript · illustrative example
type Message = {
  id: string
  subject: string
}

async function removeMessage(messageId: string) {
  const key = currentInboxQueryKey()

  // Stop an older list request from overwriting this local change.
  await cache.cancel(key)

  const messages = cache.get<Message[]>(key) ?? []
  const index = messages.findIndex((message) => message.id === messageId)
  const removed = messages[index]

  if (!removed) return

  cache.set(
    key,
    messages.filter((message) => message.id !== messageId),
  )

  markPending(messageId)

  try {
    const response = await sendDeleteToProvider(messageId)

    if (!response.ok) {
      throw new Error(`Delete failed with status ${response.status}`)
    }

    clearPending(messageId)
    scheduleNarrowRefresh(key)
  } catch (error) {
    clearPending(messageId)

    // Restore this message without replacing unrelated newer changes.
    cache.update<Message[]>(key, (current = []) =>
      restoreAtIndexIfMissing(current, removed, index),
    )

    showError("I couldn't delete that message. It has been restored.")
  }
}

An old request can bring the deleted message back

The awkward bugs live between those steps.

Suppose an inbox refresh starts, then the user deletes a message. The local list removes it. A moment later, the older refresh finishes with data captured before the deletion and puts the message back. From the user’s perspective, Delete worked and then quietly changed its mind.

Cancelling the outgoing query before applying the local update is one defence. TanStack Query specifically documents cancellation here so a refetch cannot overwrite an optimistic cache update. [S1] At the browser level, an AbortController signal can make a fetch request cancelable. Calling abort() causes the fetch promise to reject with an AbortError. [S3]

Cancellation isn’t the whole answer. A response may already be too far along to stop, or the application may use a client that doesn’t expose cancellation cleanly. Sequence numbers, operation identifiers or a latest-response-wins rule can prevent an older result from replacing newer state.

The rule I care about is simple: a response should not be allowed to overwrite changes that happened after its request began.

Rollback the operation, not yesterday’s inbox

A single full-list snapshot is convenient until several operations overlap. Delete message A, delete message B, then let A fail. Restoring the whole snapshot taken before A can resurrect B as well, even if B was deleted successfully.

That’s why the example restores one missing item instead of replacing the entire list. Another valid design is an operation log: keep the provider-confirmed list, apply pending local patches over it and remove each patch when its request settles. It’s more machinery, but the state transitions become explicit.

Moving a message creates the same class of problem across two lists. It may need to disappear from one folder, appear in another and be rolled back in both places if the request fails. The mutation boundary is larger, but the principle is unchanged: preserve the known state, record the intended change and retain a path back.

Refresh to reconcile, not to erase

Avoiding a full reload doesn’t mean never asking the provider for fresh data. Local state can drift. Another device may have changed the mailbox. A provider may accept a mutation before its next list response reflects it.

The better question is what needs refreshing, and when.

Query caches commonly support targeted invalidation. TanStack Query’s invalidateQueries accepts filters and can mark matching cached data as stale while retaining it in the cache; active matches can then refetch in the background. [S2] For an inbox, the relevant boundary might be one account, folder, search or page—not every cached mailbox and every visible panel.

Several rapid deletes can also share one delayed reconciliation instead of each causing its own full refresh. That’s what I mean by a bounded refresh: it has a reason, a scope and a limit. It isn’t a nervous reflex after every click.

Keep authority and continuity separate

The visible list is not disposable just because a server mutation has started. It contains useful context, and preserving that context is part of making the interface trustworthy.

I still want the provider to decide whether the message was actually deleted. I just don’t need to make the user watch the whole inbox disappear while it decides.

The useful split is local intent, provider confirmation and narrow reconciliation. Once those jobs are separate, deleting one email can behave like deleting one email.

Sources & further reading

  1. Optimistic Updates | TanStack Query React Docs ↗
  2. QueryClient | TanStack Query React Docs ↗
  3. Using the Fetch API | MDN Web Docs ↗

Written by Ilhan Irem Yuce, founder of OhFellow.

Notes from the work, with enough detail to be useful.

More about Ilhan ↗

You can change your choice anytime using the link in the footer.