Technical
Timothy Yang

Your loading state is part of the design, not a placeholder

AI coding tools default to a centred spinner because that's the median of every React tutorial ever written. Here's how to fix the four ways that breaks a real table, and how to make your agent stop generating it.


You asked Claude Code for a dashboard and got one, and buried in it is a line that reads {isLoading && <Spinner />}. Nobody designed that. It's the default the model reaches for when you don't say otherwise, and it quietly decides what your app feels like for the two seconds a user spends waiting on every single screen.

That's the whole argument here. The loading state isn't a placeholder you swap out later. It's the state your users spend a meaningful chunk of their session looking at, and on a slow connection it might be the only state they see. Treat it like part of the design or accept whatever your agent invented at 2am.

The spinner is a decision, not a placeholder

Here's what a centred spinner actually communicates: something is happening, I won't tell you what, I've removed everything you were looking at, and I have no idea how long this will take. Every one of those is a design choice. You just made it by not making it.

The reason this shows up so consistently in AI-generated code is boring. Spinners are the statistical centre of every React tutorial ever written. Ask for "a table that fetches data" and you get the median implementation of that phrase from the training data. The model isn't wrong, it's just unopinionated, and unopinionated UI is bad UI.

Which means the fix isn't "be a better designer". It's "have an opinion, write it down, and make it the default your tools generate."

Three numbers worth memorising

Jakob Nielsen's response-time limits have been stable since 1968 and still do most of the work here. NN/g's summary gives three thresholds: 0.1 seconds is roughly the limit for something to feel instantaneous, 1 second is about the limit for a user's flow of thought to stay uninterrupted even though they notice the delay, and 10 seconds is about the limit for keeping their attention on the task at all. Below one second, NN/g notes, no special feedback is normally required.

The modern browser-metrics version agrees. Google's Interaction to Next Paint treats 200ms or less at the 75th percentile as good responsiveness. Their write-up on how those thresholds were chosen is more interesting than the number itself: the research pointed to 100ms as the ideal "good" line and 300ms as the point where users start reporting poor quality, but 200ms was picked partly because it was actually achievable for most origins on real hardware.

So the design rule falls out of that:

  • Under ~100ms: show nothing. A spinner here is worse than silence — it's a flash of anxiety for a request that already finished.
  • 100ms to 1s: keep the existing content on screen. Add a small, local cue. Do not unmount anything.
  • 1s to 10s: show structure. Skeleton, streamed sections, progressive reveal. The user should be able to keep orienting themselves.
  • Over 10s: you now owe them progress, an estimate, and a way out. A spinner at this duration is a hostage situation.

Notice that three of those four rules are not "render a spinner". That's the point.

One example, four ways it breaks

Let's take something real. A runs table for a job-running app: a searchable list, a star toggle on each row, data from an API that takes 300–800ms on a good day.

Here's what you get out of the box:

export function RunsTable() {
  const [query, setQuery] = useState('')
  const { data, isLoading } = useQuery({
    queryKey: ['runs', query],
    queryFn: () => fetchRuns(query),
  })

  if (isLoading) return <Spinner />

  return (
    <table>
      {data.map((run) => (
        <RunRow key={run.id} run={run} />
      ))}
    </table>
  )
}

Four separate failures in eleven lines.

One: typing destroys the table. The query key includes query, so every keystroke is a brand new key with no cached data, so isLoading flips true, so the early return nukes the entire table and replaces it with a spinner. Type "deploy" and the screen strobes six times. Users describe this as "the app is broken", not "the app is slow".

Two: layout collapse. <Spinner /> is maybe 40px tall. The table was 900px. The page height snaps in and out, the scroll position jumps, and anything below the table pogos up and down.

Three: the fast path is the worst path. A cached 80ms response now produces a spinner that appears and disappears within a single frame or two. Nielsen's 0.1s threshold says this should have felt instant. Instead it feels glitchy.

Four: the star button has no loading state at all. RunRow fires a mutation and waits for a refetch. In the ~400ms gap, nothing changes, so people click again. Now you've got duplicate writes.

Fix one: stop destroying the screen

The single highest-value change is a mindset one — stale content beats no content. If you already have data on screen and you're fetching newer data, show the old data.

import { useQuery, keepPreviousData } from '@tanstack/react-query'

const { data, isPending, isFetching } = useQuery({
  queryKey: ['runs', query],
  queryFn: () => fetchRuns(query),
  placeholderData: keepPreviousData,
})

isPending means we have literally nothing to show. isFetching means we have something, and something newer is on the way. Those deserve completely different UI, and conflating them into isLoading is where most of this goes wrong. (That's the TanStack Query v5 spelling — option names move between majors, so check the docs for whatever you're actually on. SWR and RTK Query have equivalents.)

The rendering split:

if (isPending) return <RunsTableSkeleton rows={8} />

return (
  <div className="relative" aria-busy={isFetching}>
    <table className={isFetching ? 'opacity-60 transition-opacity' : undefined}>
      {data.map((run) => <RunRow key={run.id} run={run} />)}
    </table>
    <p role="status" aria-live="polite" className="sr-only">
      {isFetching ? 'Updating results' : `${data.length} results`}
    </p>
  </div>
)

Empty state gets a skeleton. Refresh state gets a dim. Nothing unmounts, nothing reflows, and the screen reader gets told what's happening instead of watching content silently swap underneath it.

If you're on the Next.js App Router, the server-side version of this idea is Suspense boundaries. Their streaming guide makes the useful point that instead of one full-page skeleton, you push fallbacks down into specific sections so the static shell contains more real content, and each boundary streams independently as its own async work finishes. A page where the header, nav and filters are instantly real and only the table is skeletal feels dramatically faster than one that withholds everything until the slowest query returns. Same total wait. Different experience.

Fix two: kill the flash

Two thresholds solve the strobing problem. Don't show a loading state until the request has been slow for ~300ms, and once you've shown it, keep it up for ~400ms minimum.

import { useEffect, useRef, useState } from 'react'

export function useDelayedFlag(active: boolean, delay = 300, minVisible = 400) {
  const [visible, setVisible] = useState(false)
  const shownAt = useRef<number | null>(null)

  useEffect(() => {
    let timer: ReturnType<typeof setTimeout>

    if (active && !visible) {
      timer = setTimeout(() => {
        shownAt.current = Date.now()
        setVisible(true)
      }, delay)
    } else if (!active && visible) {
      const elapsed = Date.now() - (shownAt.current ?? 0)
      timer = setTimeout(() => {
        setVisible(false)
        shownAt.current = null
      }, Math.max(0, minVisible - elapsed))
    }

    return () => clearTimeout(timer)
  }, [active, visible, delay, minVisible])

  return visible
}

Then const showSkeleton = useDelayedFlag(isPending) and render off that.

Yes, the second half deliberately makes a fast request slower. That's the tradeoff and I'm taking it on purpose: a spinner that lives for 60ms reads as a rendering bug, and a UI that flickers reads as unreliable even when it's quick. Perceived stability is worth 300ms of real latency. If you disagree for a specific interaction, fine — but disagree deliberately, which is the entire thesis of this post.

Fix three: optimistic updates, and the bit everyone skips

The star button doesn't need a loading state. It needs to just work, immediately, because we can predict the outcome with near-certainty.

'use client'
import { useOptimistic, startTransition } from 'react'
import { toast } from 'sonner'

export function StarButton({ run, toggleStar }) {
  const [starred, setStarred] = useOptimistic(
    run.starred,
    (_current, next: boolean) => next,
  )

  function onClick() {
    startTransition(async () => {
      setStarred(!run.starred)
      try {
        await toggleStar(run.id)
      } catch {
        toast.error('Could not save that star. Try again?')
      }
    })
  }

  return (
    <button onClick={onClick} aria-pressed={starred} aria-label="Star run">
      {starred ? '★' : '☆'}
    </button>
  )
}

React's useOptimistic discards the optimistic value once the transition settles, so the revert is free. That's exactly why the catch matters. Without it, a failed request produces a star that lights up, sits there for 400ms, then silently un-lights. The user sees a UI that undoes their action for no reason. Optimistic UI without an error path isn't optimistic, it's dishonest — and this is the single most common thing missing from AI-generated optimistic code, because the happy path is what tutorials demonstrate.

The rule I'd apply: go optimistic when the action is high-frequency, low-stakes, and reversible. Stars, likes, reorders, checkbox toggles, adding a row. Don't go optimistic on payments, deletions, or anything where being wrong for 500ms costs the user real money or real data.

Skeletons aren't automatically better

There's a cargo cult here worth puncturing. The research on skeleton screens is genuinely mixed. A 2018 ECCE study found the skeleton version of a page scored higher on perceived speed and ease of navigation — but participants using the spinner version were actually faster at finding an article on first visit. And as this UX Collective overview notes, a 2017 Viget study went the other way entirely, with skeletons coming out worst on perceived duration against spinners and a blank screen.

So skeletons aren't magic. What they're good at is one specific job: preserving layout and telling you what's about to arrive. Which means a skeleton only earns its place if it actually matches the thing it's standing in for. Three grey pills where a 900px table is about to render is worse than nothing — you've promised one shape and delivered another, and the reflow undoes any benefit.

Practical version: derive the skeleton from the real component. Same wrapper, same row height, same column widths, same padding.

export function RunsTableSkeleton({ rows = 8 }) {
  return (
    <table aria-hidden="true">
      <tbody>
        {Array.from({ length: rows }, (_, i) => (
          <tr key={i} className="h-14 border-b">
            <td className="w-8"><Shimmer className="h-4 w-4 rounded-full" /></td>
            <td><Shimmer className="h-4 w-48" /></td>
            <td className="w-32"><Shimmer className="h-4 w-20" /></td>
            <td className="w-24"><Shimmer className="h-4 w-16" /></td>
          </tr>
        ))}
      </tbody>
    </table>
  )
}

aria-hidden because a screen reader has zero use for fake rows — put the real announcement in a role="status" region instead. And gate the shimmer animation behind prefers-reduced-motion, because an infinite pulse across twelve rows is exactly the kind of thing that setting exists for.

Make your agent do this by default

None of the above is hard. It's just tedious to re-specify on every feature, and if you don't specify it you get the median-of-the-internet spinner again. So write it once, in your CLAUDE.md or .cursorrules or whatever your tool reads:

## Loading and pending states

- Never unmount existing content to show a loading state. Dim it, don't destroy it.
- Distinguish "no data yet" from "refetching". Skeleton for the first, subtle
  in-place indicator for the second.
- Don't render any loading UI for waits under ~300ms. Use useDelayedFlag from
  @/hooks/use-delayed-flag.
- Skeletons must match the real component's dimensions. Import shared layout
  primitives rather than hand-rolling boxes.
- Every mutation disables its trigger while in flight and shows a result — success
  or a specific error message. No silent failures.
- Optimistic updates need an explicit error path with user-visible feedback.
- Loading regions get role="status" and aria-live="polite". Decorative skeletons
  get aria-hidden="true".
- Animations respect prefers-reduced-motion.

That's ~120 words that will change hundreds of generated components. It's the highest-leverage thing in this whole post, and it costs you one file.

"This is polish. Ship first."

I'd push back on the framing. Polish is rounded corners and a nicer easing curve. This is the difference between an app that reads as slow and one that reads as broken, and those get very different reactions from the person deciding whether to keep using it.

It's also cheap in a way real performance work isn't. Shaving 400ms off a database query is a day. Making that 400ms feel deliberate is an afternoon, once, at the component layer — and it keeps paying out on every feature after that.

The honest cost: you're adding state, timers and error branches to components that used to be four lines. More surface area, more to test. Worth it for anything a user touches repeatedly. Probably not worth it for your internal admin page. Make that call knowingly.

Do this one thing this week

Open your app, throttle the network to Slow 4G in devtools, and click through your three most-used flows. Screenshot every loading state you hit. Then ask, for each one: which of Nielsen's buckets is this in, and does the UI match?

You'll find at least one full-page spinner over something that should have stayed on screen. Fix that one. Then add the rules block to your CLAUDE.md so the next fifty components come out right without you thinking about it.

If you want a second pair of eyes on those screenshots, drop them in the Discord — a few of us have opinions about isPending versus isFetching that we're unreasonably keen to share: discord.gg/3scUHe7B

Timothy Yang

Timothy Yang

Founder & CEO, DrillCall

Four businesses built and exited, including a micro-task marketplace with 170,000+ users. Now building DrillCall and running Vibe Coding Club from Sydney.

Build with us

Vibe Coding Club is where people who ship with AI tools compare notes. Bring what you're building.

JOIN THE DISCORD →