Command Palette

Search for a command to run...

Hectal
PHASE 1Beginner ~29 min· topic 2 of 6

Topic 1.2

Loading, Empty, Success and Error States

In one line

One request moves through four screen states: idle, loading, success and error. Keep them in a single variable, make every state look different, and guard the submit against empty input, very long input and double sends.

Think of it like this

A lift (elevator) panel. It shows exactly one thing at a time: waiting on a floor, doors closing, moving, doors open, or "out of service". It never shows "moving" and "out of service" together, and the buttons do different things in each state. A request on screen should work like the lift: always in exactly one named state, with the controls and the message that fit it.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

State
The stage a request is in: idle, loading, success or error.
Idle
The starting state: nothing has been asked.
Spinner
A small moving icon that says work is in progress, without saying what or how long.
Skeleton
A grey placeholder shaped like the content that is about to appear.
Disabled
A form control that cannot be clicked or typed in. It is greyed out and skipped by the keyboard.
Reducer
A function that takes the current state and an action ("send", "success"…) and returns the next state. Topic 4.2 covers it fully; here it keeps the allowed moves in one place.
Guard
Code at the top of a function that returns early when the action should not happen now, for example because a request is already running.
Ref (useRef)
A value React keeps between renders. Changing it does not cause a re-render, and the change is visible immediately.
aria-live
An attribute that tells screen readers to announce changes inside an element. polite waits until the reader is idle.

Step by step

01Name the states

Draw the machine first. Four states, and only some moves between them. Notice there is no arrow from idle straight to error or success: a request always passes through loading. And "loading → loading" is missing on purpose, which is how a double send is ruled out.

Name the statesdiagram
Rendering diagram…

02Make each state look different

The first live example draws each state differently: a hint in idle, a pulsing skeleton in loading, the answer in success, and an alert with role="alert" in error. It sends three requests: the first works, the second fails with a 500, the third works. Read the console: only the *state changes* are logged, one line each.

The reducer returns the old state unchanged when send arrives while already loading. That one line is the whole "ignore a double send" rule at the state level.

03Check the input before sending

The second example tests one function against ten inputs: a normal question, empty, spaces only, tabs and line breaks only, padded, exactly at the limit, one over, a pasted page, emoji, and a number where text was expected. The trim() call is the part people forget.

The same function would run in the page (to disable the button and show the counter) and on the server (as a real check).

04Two sends in the same moment

The third example is the surprising one. It submits a form twice in the same moment, as a fast double Enter can. With a guard that reads *state* (if (busy) return), both submits see busy === false, because React has not re-rendered yet, and two questions reach the server. With a guard that reads a *ref*, only one gets through.

the guard that workswhole filejsx
const sending = useRef(false);

async function submit(event) {
  event.preventDefault();
  if (sending.current) return;   // changes immediately, even before a re-render
  sending.current = true;
  setBusy(true);                 // this is for the screen: it updates a moment later
  try {
    await ask();
  } finally {
    sending.current = false;     // every way out of the request resets it
    setBusy(false);
  }
}

05What the visitor must be able to tell

At any moment a person should be able to answer three questions from the screen: Did my message go? Is something happening? What do I do next? Idle answers the third, loading the first two, error all three. If one state leaves a question unanswered, it needs more words.

Try it yourself

  1. 1

    Turn the flags into a bug

    Rewrite the first example with three separate useState booleans (loading, error, done) instead of status. Forget to set error back to false when a new request starts, then click Ask three times.

    After the failure, the red message stays on screen next to the next answer, and the first answer is still there too (the page then shows the old answer, the error and the new answer at once). With one status value that bug cannot be written, because a new state replaces the old one completely.

  2. 2

    Make the answer empty

    In the first example, change the first scripted reply from "Orders ship in 1 to 2 working days." to "" (an empty string).

    The console still says loading → success: the request worked, so the state machine is happy, but the success state now draws an empty paragraph. "Empty" has two meanings on a chat screen: nothing asked yet (idle) and an answer that came back with no text (Topic 1.1). The second one needs its own check before you dispatch success.

  3. 3

    Swap the skeleton for a spinner

    In the first example's CSS, replace the .skeleton rule with a small rotating circle, and compare how the page behaves when the answer arrives.

    A skeleton roughly the height of the answer lets the text appear without moving things below it. A spinner is smaller, so the content can push other elements down when it lands. Neither is wrong, but the difference is visible.

  4. 4

    Lower the limit and paste a long text

    In the second example, change MAX to 10 and add a case "a question": "Where is my order?", to the list.

    It reports too long (18 of 10) (and the normal question, 17 characters, now fails too). In a page you would show a counter (18 / 10) in red and keep the button disabled, rather than silently cutting the visitor's text.

Code & diagrams

One variable, four states, and what each one looks like

The three button presses are scripted. Press Ask yourself to watch the page change: the skeleton, the answer, and the red error. The server is a stand-in whose second call fails with a 500; in a real app this route calls the model provider.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

idle → loading  (send)
loading → success  (success)
success → loading  (send)
loading → error  (failure)
error → loading  (send)
loading → success  (success)
Which questions should be allowed to send?

Ten inputs, ten clear answers. Trimming makes "spaces only" and "newlines and tabs only" count as empty. The last line shows why a limit on `.length` is only approximate: a thumbs-up emoji is one character to a person and 2 to JavaScript.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

normal question          {"ok":true,"length":17}
empty string             {"ok":false,"why":"empty"}
spaces only              {"ok":false,"why":"empty"}
newlines and tabs only   {"ok":false,"why":"empty"}
padded question          {"ok":true,"length":17}
exactly at the limit     {"ok":true,"length":500}
one over the limit       {"ok":false,"why":"too long (501 of 500)"}
a pasted page            {"ok":false,"why":"too long (9999 of 500)"}
emoji                    {"ok":true,"length":3}
not text at all          {"ok":false,"why":"not text"}
'☕'.length = 1 | '👍'.length = 2 | characters a person sees in '👍': 1
Double Enter: a state guard fails, a ref guard holds

requestSubmit() twice in a row is what two quick Enter presses do. The first form has the disabled button too: it does not help, because the browser delivers both events before the page re-renders. Two questions reached the server, and in a real app both would be billed.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

state guard only → questions that reached the server: 2
ref guard → questions that reached the server: 1

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Guard with state only

In the third example, change if (guarded) { inside submit to if (false) {, so the ref guard is never used by either form. Run.

terminal
$ Console
── what you'll see ──
state guard only → questions that reached the server: 2
ref guard → questions that reached the server: 2

Break #2

Forget to trim

In the second example, change const text = raw.trim(); to const text = raw;.

terminal
$ Console
── what you'll see ──
spaces only {"ok":true,"length":5}
newlines and tabs only {"ok":true,"length":4}
padded question {"ok":true,"length":23}

Myth vs fact

Myth

A disabled button stops double submits.

Fact

It stops clicks after the next render. Two events in the same moment, or Enter in the input, can get through first. The submit function needs its own guard.

Myth

Several boolean flags are easier than one status.

Fact

They multiply: three flags give eight combinations and only four are valid. One status variable makes the invalid ones impossible.

Myth

A spinner is always enough for loading.

Fact

A spinner says nothing about what is loading or how long. For content with a known shape, a skeleton prevents layout jumps; for long waits, words help more than motion.

Myth

A character limit in the page is enough.

Fact

The page can be bypassed with any HTTP tool. The limit that counts is the server's. The page's limit is a courtesy so the visitor sees it before sending.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Reset on the way in, not on the way out. A new request should replace the whole state in one step ({ status: "loading", answer: "", error: "" }) rather than clear fields one at a time at the end of the previous request. Reset-on-entry survives a forgotten finally; reset-on-exit does not.

  • ▸

    Do not remove the old answer the instant the visitor sends a new question if the wait may be long and the old text is still useful. Chat apps usually keep the previous exchange visible above the new one. The single-answer layout used in this topic is a simplification; Phase 4 gives you a list of messages.

  • ▸

    Screen-reader users hear nothing from a visual spinner. A visible "Mitra is thinking…" line inside an aria-live="polite" region is read out once. Avoid putting every streamed token into a live region (Topic 10.6 explains why), but a status line is exactly the right use.

Remember this

  1. 1

    The problem this solves: Real screens hold a handful of true/false flags such as isLoading, hasError and hasAnswer, and after one missed reset they say two things at once: a spinner on top of an error, or an old answer next to a new question. This topic replaces the flags with one variable, and shows what each state must look like so a person is never left guessing.

  2. 2

    The four states. *Idle*: nothing has been asked yet. Show a hint, the input and an enabled button. *Loading*: the request is out and nothing has arrived. Show that work is happening, and stop the visitor from sending again. *Success*: the answer is here. Show it. *Error*: something failed. Say what and offer a way forward. A request is always in exactly one. Store that one name, and derive everything else from it (for example const busy = status === "loading").

  3. 3

    Why one variable beats several flags. With three flags there are eight combinations and only four make sense. Every try or catch you write must remember to set all three correctly, and the first forgotten reset leaves the screen in a state that cannot happen. With a single status, an impossible combination cannot even be written down. A reducer (Topic 4.2) goes one step further: it also lists which moves between states are allowed, so "success → loading" is a decision made in one place.

  4. 4

    Spinner or skeleton? A spinner says "working" and nothing else; it fits short, unpredictable waits. A skeleton is a grey placeholder in the shape of the thing that will appear; it fits waits where you know the layout, and it stops the page jumping when the content arrives. For a chat answer of unknown length, a short "Mitra is thinking…" line or a few pulsing dots next to where the answer will appear is usually right. Whatever you choose, announce it to assistive technology: put the area in a region with aria-live="polite" so screen readers hear changes.

  5. 5

    Disable the submit while loading. If the button stays active, a second click sends a second request, and both answers fight for the same spot on screen. Both also cost money. But a disabled button is not enough on its own: two events can arrive before the screen has re-rendered, so the check must also live inside the submit function, backed by a value that changes at once (a ref), not by state that updates on the next render. The third live example proves it.

  6. 6

    Bad input is part of the design. *Empty*: nothing typed. *Whitespace-only*: spaces, tabs, line breaks look empty to a person but are not "", so trim before checking. *Very long*: someone pastes a whole page, which is slow and expensive, so cap it with a visible counter. Characters are not what the screen shows: "👍".length is 2, so a limit in JavaScript string length is approximate for emoji and some scripts. And the same rules must run on the server, because the page can be skipped entirely (Topic 1.5).

  7. 7

    Double submit has two doors. Clicking the button twice, and pressing Enter twice. A <form> with one onSubmit handler covers both doors with one code path, which is why chat inputs should be forms and not a bare button with a click handler. Pressing Enter in an input inside a form submits it, so the single guard in that handler protects both.

Explain it without notes

01

Why store one status instead of separate isLoading, hasError and hasAnswer flags?

02

A visitor presses Enter twice quickly. Why can two requests still be sent when the button becomes disabled after the first?

03

What input cases should a chat box handle before sending, and where must the checks run?

04

When would you choose a skeleton over a spinner?

Practice

01

Add a character counter under the input in a form, showing n / 500, turning red above 500 and disabling the button.

02

Make the third example's guarded form show "Please wait…" in place of the button text while sending, and make sure it returns to "Send" after a failed request.

Trade-offs

  • ↔

    A reducer with named states costs a few more lines than two or three useState calls, and removes a whole class of impossible-screen bugs. For anything beyond a toy, it pays back the first time a request fails.

  • ↔

    Disabling the control prevents duplicates but also prevents the visitor from correcting a typo while waiting. Some products allow typing the next question during the wait and only block sending. That is friendlier, and needs the ref guard to be right.

Done when you can

  • I can name the four request states and draw what the screen shows in each.

  • I can explain why one status variable is safer than several boolean flags.

  • I can reject empty, whitespace-only and very long input before sending.

  • I can guard against a double click and a double Enter using a ref.

  • I can choose between a spinner and a skeleton and give a reason.