The symptom

The spinner appears and stays. No error, no content, no end.

What's happening

A loading flag was set to true and never set back. Something between those two points either never finished or threw, and the code that clears the flag never ran.

The four causes

The request failed and the error path doesn't clear the flag. Overwhelmingly the most common. The success path sets loading = false; the catch block logs and returns.

The request never completes. No timeout set, server never responds, and the promise hangs indefinitely. Without a timeout it will wait forever.

The state update didn't trigger a re-render. Common in frameworks when you mutate rather than replace, or update after the component is gone.

A race between two requests. The second resolves first, the first overwrites its result, and the flags end up crossed.

The fix

Clear the flag in a finally, so it runs whether things succeeded or failed:

loading-fix.txt
This loading state never resolves: <paste the fetch and the state
handling>

Fix it properly:
- Clear the loading flag in a finally block, not only on success.
- Add a request timeout with AbortController so it can't hang forever.
  Say what timeout you chose.
- Add an error state, so a failure shows a message and a retry rather
  than an eternal spinner.
- If two requests can overlap, make sure a stale response can't
  overwrite a newer one.

Then show me the three states rendering: loading, error, and loaded.

Check the Network tab

It tells you immediately which of these it is. Request still pending — it's hanging, add a timeout. Request completed with an error status — your error path is broken. Request never sent — the problem is before the fetch, in whatever should have triggered it.

Every loading state needs a timeout and an error state. A spinner with no way to fail is a bug waiting for a bad network.