Reacting to input with state
Build a form the React way: list its visual states, find what triggers each change, and swap clashing booleans for one status. Live examples to try.
- 01Think declarativelySay why a form that describes each look can't get stuck the way hand-written show and hide commands can.
- 02List looks and triggersDraw a component's visual states with a mock or a living styleguide, and list the human and computer inputs that move between them.
- 03Remove impossible statesReplace booleans that can clash with one
status, and work out anything another piece of state already says.
A coupon box that locks you out
A checkout page often has a box for a coupon code. You type a code, press Apply, and the shop asks its server whether the code is real. While it waits, the form should say so. Then it either shows the discount or explains what went wrong. Here is that box written with plain JavaScript, changing the page by hand. The result is live. Type TEA5 in the box, press Apply, and wait one second:
const form = document.querySelector("#coupon-form");const input = document.querySelector("#code");const button = document.querySelector("#apply");const checking = document.querySelector("#checking");const errorText = document.querySelector("#error");const success = document.querySelector("#success"); async function handleSubmit(e) { e.preventDefault(); input.disabled = true; button.disabled = true; checking.hidden = false; errorText.hidden = true; try { await checkCoupon(input.value); form.hidden = true; success.textContent = "Coupon " + input.value + " applied: 10% off."; success.hidden = false; } catch (err) { checking.hidden = true; errorText.textContent = err.message; errorText.hidden = false; button.disabled = false; }} function handleInput() { button.disabled = input.value.length === 0;} form.addEventListener("submit", handleSubmit);input.addEventListener("input", handleInput);Lines 1 to 6 find the six parts of the page. handleSubmit (lines 8 to 25) runs when you press Apply. Line 9 stops the browser from sending the form and loading a new page. Lines 10 to 13 turn the box and the button off and show "Checking your code…". Line 15 waits for the pretend server in the api.js tab: it answers after one second, accepts TEA10, and refuses anything else. If it accepts, lines 16 to 18 hide the form and show the discount. If it refuses, lines 20 to 23 hide the waiting message, show the error, and turn Apply back on.
Now try to fix your code. You can't: the box is still grey. Nothing on lines 20 to 23 turns the box back on, so the page asks you to check your code and won't let you change it. The bug is on purpose here, and it's the kind that hand-written page updates are full of: each event lists its own changes, and one list missed one.
You met this problem with a shopping cart in Why React?. A form makes it worse, because a form has many looks: empty, being typed in, waiting for the server, done, or showing an error. Here is the same box in React. Try TEA5 again, then change it to TEA10 and press Apply:
import { useState } from "react";import { checkCoupon } from "./api.js"; export default function App() { const [code, setCode] = useState(""); const [error, setError] = useState(null); const [status, setStatus] = useState("typing"); if (status === "success") { return <p>Coupon {code} applied: 10% off.</p>; } async function handleSubmit(e) { e.preventDefault(); setStatus("submitting"); setError(null); try { await checkCoupon(code); setStatus("success"); } catch (err) { setStatus("typing"); setError(err); } } return ( <form onSubmit={handleSubmit}> <label> Coupon code{" "} <input value={code} onChange={(e) => setCode(e.target.value)} disabled={status === "submitting"} /> </label> <button disabled={code.length === 0 || status === "submitting"}>Apply</button> {status === "submitting" && <p>Checking your code…</p>} {error !== null && <p>{error.message}</p>} </form> );}This time you can fix the code after the error. Lines 5 to 7 keep three pieces of state: what's in the box, the last error, and a status that names what the form is doing. The JSX below them doesn't give the page orders. Line 33 describes the box: it's off while status is "submitting", and on the rest of the time. There is no line that turns it back on, because there is nothing to forget. Every render works the box out again from the state.
That's the idea of this lesson. In React you don't change parts of the page in response to events. You describe each look the component can have, and when something happens, you set state to pick a look. The rest of the lesson builds this form step by step, the way you would build any form in React.
A theatre doesn't move twenty sliders by hand at every moment of a play. The lighting team saves each scene on the desk ahead of time. During the show, the operator follows the cue sheet and presses one button, and the desk sets every lamp for that scene. No lamp can be left in the wrong place from an earlier scene.
- In real life: Each scene saved on the desk: which lamps are on, and how bright
- In React: Each look described in JSX for one status
- In real life: Pressing the button for scene 3
- In React: Calling setStatus("submitting")
- In real life: The cue sheet: when to change scenes
- In React: The list of triggers: Apply, the server's answer
- In real life: The desk shows one scene at a time
- In React: The status holds one value at a time
To react to input with state, you describe every look a component can have, and let state choose between them. Find the looks, find what moves the component from one look to another, keep the smallest state that names the look, and set that state in your event handlers. React then updates the page to match.
Commands for each event, or a description of each look
The by-hand box is imperative: each handler gives the page a list of commands for one event. "Turn the box off. Turn Apply off. Show the waiting message." The React box is declarative: each part of the page says what it looks like for any state. To see the difference, follow each part of the form through both versions. Each row is one part of the form; the middle column lists the by-hand lines that change it, and the last column the React line that describes it:
| Part of the form | By hand, changed on | With React, described on |
|---|---|---|
| The coupon box, on or off | Line 10 turns it off. No line turns it back on: the bug. | Line 33: disabled={status === "submitting"}. |
| Apply, on or off | Lines 11, 23, and 28, in two different handlers. | Line 36, for every look. |
| The waiting message | Line 12 shows it, line 20 hides it. | Line 37: shown while submitting. |
| The error message | Lines 13, 21, and 22. | Line 38: shown while there is an error. |
| The form, or the discount | Lines 16 to 18 hide the form and show the discount. | Lines 9 to 11: an early return for success. |
By hand, the box is changed in one place, and needed two. Apply is changed in three places, in two different handlers. Every new event, like a "Remove coupon" button, would need its own list of commands, and each list has to know the state that every other list left behind. With React, each part has one line, and that line covers every look at once. Adding a new event means setting state; the descriptions already handle it.
Both versions end up making the same kind of DOM change. React turns the box off and on for you (it adds or removes the disabled attribute), after it compares the new description with the last one. Render and commit follows those steps.
Step 1: list every look the form can have
Before you write any logic, list the looks a user can see. Designers call them visual states. For the coupon form there are five:
- Empty: the box is empty, so Apply is off.
- Typing: there is a code in the box, so Apply is on.
- Submitting: the box and Apply are off, and the form says it's checking.
- Success: the form is gone, and a message says the coupon is applied.
- Error: like Typing, with the server's error message under the form.
Then draw each look, with no logic yet. In React, a quick way to draw them is a mock: a component that takes the look as a prop and shows it. Here status is a prop with a default value, "empty":
export default function CouponForm({ status = "empty" }) { if (status === "success") { return <p>Coupon TEA10 applied: 10% off.</p>; } return ( <form onSubmit={(e) => e.preventDefault()}> <label> Coupon code <input disabled={status === "submitting"} /> </label> <button disabled={status === "empty" || status === "submitting"}>Apply</button> {status === "submitting" && <p>Checking your code…</p>} {status === "error" && <p>That code isn't valid. Check it and try again.</p>} </form> );}Line 1 takes status and uses "empty" when no one passes it. Lines 2 to 4 return the success message instead of the form (an early return, from Conditional rendering). Line 6 stops the browser from sending the form, so pressing Apply in a mock does nothing. Line 8 turns the box off only while submitting. Line 10 turns Apply off when the box is empty or while submitting. Lines 11 and 12 show a message for two of the looks.
Press Edit & run and change "empty" on line 1 to "success". The result shows Coupon TEA10 applied: 10% off.. Try "error" and "submitting" too. You are checking the design before any of it works.
Or flip between the looks with the buttons below. The form is the same mock, rendered by React on this page; the highlighted lines are the ones whose condition is true for the look you pick, and the table reads what ended up on the page.
export default function CouponForm({ status = "empty" }) { if (status === "success") { return <p>Coupon TEA10 applied: 10% off.</p>; } return ( <form onSubmit={(e) => e.preventDefault()}> <label> Coupon code <input disabled={status === "submitting"} /> </label> <button disabled={status === "empty" || status === "submitting"}>Apply</button> {status === "submitting" && <p>Checking your code…</p>} {status === "error" && <p>That code isn't valid. Check it and try again.</p>} </form> );}<CouponForm status="empty" />The highlighted line is where a condition is true for "empty". Everything else on the page comes from the same lines with their conditions false.
Pick Submitting: lines 8, 10, and 11 light up, so the box and Apply are off and the waiting message shows. Pick Typing: no line lights up. Every condition is false, which is exactly the look you want: both on, no message.
When a component has many looks, it helps to see them all at once. Render the mock once per look on one page. A page like this is called a living styleguide, because it shows the real component, not a picture of it:
import CouponForm from "./CouponForm.jsx"; const statuses = ["empty", "typing", "submitting", "success", "error"]; export default function App() { return ( <> {statuses.map((status) => ( <section key={status}> <h4>Coupon form: {status}</h4> <CouponForm status={status} /> </section> ))} </> );}The mock is now in the CouponForm.jsx tab, and App.jsx imports it on line 1. Line 3 lists the five looks. Lines 8 to 13 use map to draw a heading and a CouponForm for each one, with status as the key. Change line 10 of CouponForm.jsx, say to turn Apply off in the error look too, and all five forms update at once. Tools like Storybook are built on this idea.
Step 2: find what triggers each change
Next, find what moves the form from one look to another. Changes come from two kinds of input:
- Human inputs: the user types, clicks, or presses a key. You handle them with event handlers, from Responding to events.
- Computer inputs: the server answers, a timer ends, an image finishes loading. You handle them in the code that waits for them, like the code after await.
Either way, the screen changes only because the code sets state. For the coupon form, the changes are:
| From | To | Trigger | Kind of input |
|---|---|---|---|
| Empty | Typing | You type a letter. | Human |
| Typing | Empty | You delete the last letter. | Human |
| Typing | Submitting | You press Apply. | Human |
| Submitting | Success | The server accepts the code. | Computer |
| Submitting | Error | The server refuses the code. | Computer |
| Error | Submitting | You press Apply again. | Human |
Read each row as "in this look, this happens, so the form moves to that look". A good way to check the list is to draw it: a card for each look and an arrow for each change.
The drawing also shows what can't happen. No arrow leaves Success: once the coupon is applied, the form is gone. No arrow goes from Empty to Submitting: Apply is off while the box is empty. And typing doesn't move the form out of Error: the message stays until you press Apply again. If you find an arrow that you didn't plan, you found a bug before writing any code.
Now watch the arrows happen. The form below is the overview's React coupon box, running on this page, and the log lists each change of look with the input that caused it:
try TEA5, then TEA10Changes of look
- No changes yet.
Look now: Empty
Type TEA5 in the box and press Apply. Then fix it to TEA10 and press Apply again. Each change of look is logged with what triggered it.
Type TEA5: the first letter logs Empty to Typing, and the other letters log nothing, because the look stays Typing. Press Apply: a human input moves it to Submitting. One second later, a computer input, the server's answer, moves it to Error. Change the code to TEA10 and press Apply again: Error to Submitting, then Submitting to Success. Each line is one arrow in the drawing.
Steps 3 and 4: keep only the state you need
Now hold the looks in state with useState. Each piece of state is something that can change, and something that can go wrong, so you want as few as possible. A first try often has one true-or-false value, a boolean, per look, plus the code and the error. It covers every look, so it works:
const [code, setCode] = useState("");const [error, setError] = useState(null);const [isEmpty, setIsEmpty] = useState(true);const [isTyping, setIsTyping] = useState(false);const [isSubmitting, setIsSubmitting] = useState(false);const [isSuccess, setIsSuccess] = useState(false);const [isError, setIsError] = useState(false);Lines 1 and 2 keep the code and the last error. Lines 3 to 7 keep one boolean per look: isEmpty starts true, and the others start false. That's seven pieces of state, and every handler must set the right ones. Pressing Apply would need setIsTyping(false) and setIsSubmitting(true); an error would need setIsSubmitting(false), setIsError(true), and setError(err). Forget one, and the state says two things at once.
Five booleans can be set in 2 × 2 × 2 × 2 × 2 = 32 ways. Only 5 of them, where exactly one is true, are a look the form really has. The other 27 are impossible states: combinations the state allows, but that no user should ever see. Switch the booleans below and watch the form the first try would draw:
if (isSuccess) { return <p>Coupon TEA10 applied: 10% off.</p>;}return ( <form> <label> Coupon code <input disabled={isSubmitting} /> </label> <button disabled={isEmpty || isSubmitting}>Apply</button> {isSubmitting && <p>Checking your code…</p>} {isError && <p>That code isn't valid. Check it and try again.</p>} </form>);possiblePossible. Only isEmpty is true, so this is the empty look.
Turn on isError while isSubmitting is on: the page shows the error and turns the box off, the same trap as the by-hand bug. Turn on isEmpty and isTyping together: Apply stays off while the box has a code. Notice too that isTyping changes nothing on the page: the drawing never reads it.
status holds one value at a time, so each of its values is a state the form can really be in.To remove the impossible states, ask three questions about each piece of state. Each row is one question, what it finds in the first try, and what to do about it:
| Question | In the first try | What to do |
|---|---|---|
| Can two of them be true when they shouldn't be? | isTyping, isSubmitting, and isSuccess can all be true at once. | Replace the three with one status. |
| Does another piece of state already say it? | isEmpty is true exactly when code.length === 0. | Remove it, and check code.length during render. |
| Is it the opposite of another piece of state? | isError is true exactly when error isn't null. | Remove it, and check error !== null. |
After the clean-up, three pieces of state are left, down from seven:
const [code, setCode] = useState("");const [error, setError] = useState(null);const [status, setStatus] = useState("typing"); // "typing", "submitting", or "success"Line 1 keeps the code and line 2 the last error, as before. Line 3 replaces three booleans with one status that holds one of three strings. It can't be "typing" and "submitting" at once, because a variable holds one value. A set of names like this, one of which is chosen, is often called an enum. JavaScript has no enum type, so a string does the job; the comment lists the three allowed values.
The five looks are all still there. Each one is worked out from the three pieces:
- Empty:
statusis"typing"andcode.length === 0. - Typing:
statusis"typing", the box has a code, anderrorisnull. - Submitting and Success:
statussays so. - Error:
statusis"typing"anderror !== null.
Could error go into status as a fourth value, "error"? Yes, and that works well too. The message still needs a place, though, and it only makes sense in the error look. TypeScript can say exactly that with a discriminated union: a type with one shape per status, where only the error shape has a message. This one has an error on purpose:
type CouponState = | { status: "typing" } | { status: "submitting" } | { status: "success" } | { status: "error"; message: string }; function messageFor(state: CouponState) { if (state.status === "error") { return state.message; } return state.message;}Lines 1 to 5 list the four shapes the state can have. Inside the if on line 8, TypeScript knows the status is "error", so line 9 may read message. Line 11 is outside it, where the state could be any of the other three, and TypeScript stops it: Property 'message' does not exist on type '{ status: "typing"; }'. That means the typing shape has no message to read. The impossible state, a message with no error, can't even be written. Typing state and hooks shows how to give useState a type like this.
Step 5: connect the handlers to the state
Last, make each trigger set state. Typing is one line: the box's onChange on line 32 of the overview's form calls setCode(e.target.value). Apply and the server's answer both live in handleSubmit, from lines 13 to 24 of that form:
async function handleSubmit(e) { e.preventDefault(); setStatus("submitting"); setError(null); try { await checkCoupon(code); setStatus("success"); } catch (err) { setStatus("typing"); setError(err); }}Line 1 is the form's onSubmit handler. It's async, because it waits for the server. Line 2 stops the browser's own submit. Lines 3 and 4 handle the human input, Apply: the form moves to Submitting, and any old error goes. Line 6 waits. If the server accepts, line 7 sets "success". If it refuses, checkCoupon throws, and the catch block moves the form back to typing on line 9 and keeps the error on line 10.
Compare it with the by-hand handleSubmit. Neither branch mentions the box, the button, or the messages. Each one says which look comes next, and the JSX already knows what every look shows. Lines 9 and 10 can't forget to turn the box back on, because they don't turn anything on.
Lines 3 and 4 run together, before the await on line 6, so React renders them as one change. Lines 9 and 10 run together after it, and React batches them the same way, as Queueing a series of state updates showed. So the page never shows a half-set state, like Submitting with the old error still under it.
React 19 can also run a function as a form's action, and its useActionState and useFormStatus Hooks keep track of the waiting look for you. They don't change this lesson's way of thinking: you still list the looks and what moves between them. The Form actions lesson covers them. onSubmit with preventDefault, as here, keeps working.
Reviews that load, built with the five steps
The same five steps work for anything that waits, not just forms. Here is a product page's reviews panel. You press a button, the reviews load from a server, and the panel shows them. The pretend server in the api.js tab fails the first time on purpose, as networks sometimes do, and works after that. Press Show reviews, wait, then press Try again:
import { useState } from "react";import { loadReviews } from "./api.js"; export default function App() { const [reviews, setReviews] = useState([]); const [error, setError] = useState(null); const [status, setStatus] = useState("closed"); // "closed", "loading", or "loaded" async function handleShow() { setStatus("loading"); setError(null); try { setReviews(await loadReviews()); setStatus("loaded"); } catch (err) { setStatus("closed"); setError(err); } } if (status === "loading") { return <p>Loading reviews…</p>; } if (status === "loaded") { return reviews.length === 0 ? ( <p>No reviews yet.</p> ) : ( <ul> {reviews.map((review) => ( <li key={review}>{review}</li> ))} </ul> ); } return ( <> <button onClick={handleShow}>{error === null ? "Show reviews" : "Try again"}</button> {error !== null && <p>{error.message}</p>} </> );}Here are the five steps, applied to the panel:
- The looks: closed (a button), loading, loaded with reviews, loaded with none, and error (a Try again button and the message).
- The triggers: a click on the button (human), and the server's answer or failure (computer).
- The state: the reviews, the last error, and a status on line 7:
"closed","loading", or"loaded". - What's removed: there is no
isEmpty. Line 25 works it out fromreviews.length. There is noisError. Lines 37 and 38 checkerror. - The handler:
handleShowon lines 9 to 19 sets"loading", waits, then sets"loaded", or sets"closed"and keeps the error.
Lines 21 to 23 return the loading look, lines 24 to 34 the loaded looks, and lines 35 to 40 the closed and error looks, which share a button whose label depends on error. To see the "no reviews" look, press Edit & run, open the api.js tab, and change line 11 to resolve([]);. Show reviews, then Try again, now ends with No reviews yet..
Each change in these forms has a trigger. Sort these: does a person cause it, or the computer?
- The shopper types a letter in the coupon box
- The shopper presses Apply
- The server answers that the coupon is valid
- The shopper presses Enter in the box
- The network drops while the reviews load
- A two-second timer ends and hides a Copied note
- The product photo finishes loading
- The shopper clicks Try again
Sort each trigger on a shop page: does a person cause it, or does it come from the computer?
Common mix-ups
- "In React, you show and hide elements in event handlers." No. Handlers set state. The JSX describes what each state shows, and React changes the page to match.
- "A visual state and a state variable are the same thing." No. The coupon form has five looks and three state variables. Empty and Error are worked out from
codeanderror. - "More booleans make a form clearer." No. Each boolean doubles the combinations. Five allow 32, and 27 of them are impossible states that a missed setter can reach.
- "Only clicks and typing change state." No. Computer inputs, like a server's answer or a timer, change it too. They set state in the code that waits for them.
- "A status needs a library or an enum type." No. A string like
"submitting"inuseStateis enough. TypeScript can list the allowed strings, but plain JavaScript works the same way. - "A mock is wasted work." No. The mock's JSX becomes the real component's JSX. You only swap the
statusprop for state and add the handlers.
Practice exercises
Each exercise shows its code's result under it, or when you press Edit & run. The pretend servers answer after one second. Try a guess first, then use the hints.
This mock is rendered with no props. What does its button say? Answer first, then press Edit & run to check.
export default function SaveButton({ status = "saving" }) {
return (
<button disabled={status === "saving" || status === "saved"}>
{status === "saving" ? "Saving…" : status === "saved" ? "Saved" : "Save"}
</button>
);
}export default function SaveButton({ status = "saving" }) { return ( <button disabled={status === "saving" || status === "saved"}> {status === "saving" ? "Saving…" : status === "saved" ? "Saved" : "Save"} </button> );}No one passes status, so line 1 uses "saving". Line 4 picks Saving…, and line 3 turns the button off, so the user can't click it twice.
The styleguide shows two of the save button's looks. Add "saved" and "error". How many buttons does the page show now?
import SaveButton from "./SaveButton.jsx"; const statuses = ["idle", "saving"]; export default function App() { return ( <> {statuses.map((status) => ( <section key={status}> <h4>{status}</h4> <SaveButton status={status} /> </section> ))} </> );}import SaveButton from "./SaveButton.jsx"; const statuses = ["idle", "saving", "saved", "error"]; export default function App() { return ( <> {statuses.map((status) => ( <section key={status}> <h4>{status}</h4> <SaveButton status={status} /> </section> ))} </> );}With four statuses on line 3, map draws four sections. saved shows a turned-off Saved button, and error shows Save, because no branch of line 4 names it: you just found a look the mock doesn't draw yet.
Fix the by-hand coupon box so that you can change your code after an error. Test it with TEA5. What line did you add?
const form = document.querySelector("#coupon-form");const input = document.querySelector("#code");const button = document.querySelector("#apply");const checking = document.querySelector("#checking");const errorText = document.querySelector("#error");const success = document.querySelector("#success"); async function handleSubmit(e) { e.preventDefault(); input.disabled = true; button.disabled = true; checking.hidden = false; errorText.hidden = true; try { await checkCoupon(input.value); form.hidden = true; success.textContent = "Coupon " + input.value + " applied: 10% off."; success.hidden = false; } catch (err) { checking.hidden = true; errorText.textContent = err.message; errorText.hidden = false; button.disabled = false; }} function handleInput() { button.disabled = input.value.length === 0;} form.addEventListener("submit", handleSubmit);input.addEventListener("input", handleInput);const form = document.querySelector("#coupon-form");const input = document.querySelector("#code");const button = document.querySelector("#apply");const checking = document.querySelector("#checking");const errorText = document.querySelector("#error");const success = document.querySelector("#success"); async function handleSubmit(e) { e.preventDefault(); input.disabled = true; button.disabled = true; checking.hidden = false; errorText.hidden = true; try { await checkCoupon(input.value); form.hidden = true; success.textContent = "Coupon " + input.value + " applied: 10% off."; success.hidden = false; } catch (err) { checking.hidden = true; errorText.textContent = err.message; errorText.hidden = false; button.disabled = false; input.disabled = false; }} function handleInput() { button.disabled = input.value.length === 0;} form.addEventListener("submit", handleSubmit);input.addEventListener("input", handleInput);The new line 24, input.disabled = false;, turns the box back on after an error, so you can fix the code. By hand, every branch has to undo what the start of the handler did; one missed line is a stuck form.
Press Subscribe and wait a second: the page says it's subscribing and subscribed at once. Replace the two booleans with one status. After your fix, what does the page show one second after the click?
import { useState } from "react";import { subscribe } from "./api.js"; export default function App() { const [isSubscribing, setIsSubscribing] = useState(false); const [isSubscribed, setIsSubscribed] = useState(false); async function handleClick() { setIsSubscribing(true); await subscribe("asha@shop.com"); setIsSubscribed(true); } return ( <> <button onClick={handleClick} disabled={isSubscribing}>Subscribe</button> {isSubscribing && <p>Subscribing…</p>} {isSubscribed && <p>You're subscribed.</p>} </> );}import { useState } from "react";import { subscribe } from "./api.js"; export default function App() { const [status, setStatus] = useState("idle"); // "idle", "subscribing", or "subscribed" async function handleClick() { setStatus("subscribing"); await subscribe("asha@shop.com"); setStatus("subscribed"); } if (status === "subscribed") { return <p>You're subscribed.</p>; } return ( <> <button onClick={handleClick} disabled={status === "subscribing"}>Subscribe</button> {status === "subscribing" && <p>Subscribing…</p>} </> );}One status holds one value, so the page can't be subscribing and subscribed at once. Line 10 replaces "subscribing" with "subscribed", and lines 13 to 15 show only You're subscribed..
The pretend server refuses taken@shop.com, but this form has no error look: it waits forever. Add one, so that you can change the email and try again. What does your form say after you press Sign up?
import { useState } from "react";import { subscribe } from "./api.js"; export default function App() { const [email, setEmail] = useState("taken@shop.com"); const [status, setStatus] = useState("typing"); // "typing", "sending", or "sent" async function handleSubmit(e) { e.preventDefault(); setStatus("sending"); await subscribe(email); setStatus("sent"); } if (status === "sent") { return <p>Thanks! Check {email} for a welcome note.</p>; } return ( <form onSubmit={handleSubmit}> <label> Email <input value={email} onChange={(e) => setEmail(e.target.value)} disabled={status === "sending"} /> </label> <button disabled={email.length === 0 || status === "sending"}>Sign up</button> {status === "sending" && <p>Signing you up…</p>} </form> );}import { useState } from "react";import { subscribe } from "./api.js"; export default function App() { const [email, setEmail] = useState("taken@shop.com"); const [error, setError] = useState(null); const [status, setStatus] = useState("typing"); // "typing", "sending", or "sent" async function handleSubmit(e) { e.preventDefault(); setStatus("sending"); setError(null); try { await subscribe(email); setStatus("sent"); } catch (err) { setStatus("typing"); setError(err); } } if (status === "sent") { return <p>Thanks! Check {email} for a welcome note.</p>; } return ( <form onSubmit={handleSubmit}> <label> Email <input value={email} onChange={(e) => setEmail(e.target.value)} disabled={status === "sending"} /> </label> <button disabled={email.length === 0 || status === "sending"}>Sign up</button> {status === "sending" && <p>Signing you up…</p>} {error !== null && <p>{error.message}</p>} </form> );}Line 6 adds error. When subscribe refuses, the catch on lines 16 to 19 moves the form back to typing and keeps the error, and line 32 shows That email is already signed up.. Line 12 clears the old error when you try again.
This form keeps four pieces of state. Two of them can be worked out from the password. Remove them, and keep the form working. How many useState calls are left?
import { useState } from "react"; export default function App() { const [password, setPassword] = useState(""); const [isEmpty, setIsEmpty] = useState(true); const [isTooShort, setIsTooShort] = useState(false); const [status, setStatus] = useState("typing"); // "typing" or "saved" function handleChange(e) { setPassword(e.target.value); setIsEmpty(e.target.value.length === 0); setIsTooShort(e.target.value.length > 0 && e.target.value.length < 8); } if (status === "saved") { return <p>Password saved.</p>; } return ( <> <label> New password <input value={password} onChange={handleChange} /> </label> <button disabled={isEmpty || isTooShort} onClick={() => setStatus("saved")}>Save</button> {isTooShort && <p>Use at least 8 characters.</p>} </> );}import { useState } from "react"; export default function App() { const [password, setPassword] = useState(""); const [status, setStatus] = useState("typing"); // "typing" or "saved" const isEmpty = password.length === 0; const isTooShort = password.length > 0 && password.length < 8; if (status === "saved") { return <p>Password saved.</p>; } return ( <> <label> New password <input value={password} onChange={(e) => setPassword(e.target.value)} /> </label> <button disabled={isEmpty || isTooShort} onClick={() => setStatus("saved")}>Save</button> {isTooShort && <p>Use at least 8 characters.</p>} </> );}Lines 7 and 8 work out isEmpty and isTooShort from password on every render, so they can never disagree with it, and onChange only sets the password. Two useState calls are left: the password and the status.
Check your understanding
For each code question, find the status first, then read which conditions are true for it. Press Edit & run on a question's code to check your answer.
Question 1 of 7Which line describes the coupon box declaratively?
Choose an answer to see the explanation.
Question 2 of 7With
"error"as its default status, what does this mock show?Read the code, then predictexport default function CouponForm({ status = "error" }) { if (status === "success") { return <p>Coupon TEA10 applied: 10% off.</p>; } return ( <form onSubmit={(e) => e.preventDefault()}> <label> Coupon code <input disabled={status === "submitting"} /> </label> <button disabled={status === "empty" || status === "submitting"}>Apply</button> {status === "submitting" && <p>Checking your code…</p>} {status === "error" && <p>That code isn't valid. Check it and try again.</p>} </form> ); }Choose an answer to see the explanation.
Question 3 of 7How many combinations can four boolean state variables be set to?
Choose an answer to see the explanation.
Question 4 of 7Which of these is a computer input?
Choose an answer to see the explanation.
Question 5 of 7A like button keeps this state. What's wrong with it?
Read the code, then predictJavaScriptconst [isLiking, setIsLiking] = useState(false); const [isLiked, setIsLiked] = useState(false); const [isFailed, setIsFailed] = useState(false);Choose an answer to see the explanation.
Question 6 of 7The coupon form's first try kept
isEmptynext tocode. Why remove it?Choose an answer to see the explanation.
Question 7 of 7In the final coupon form, what moves it from Submitting to Error?
Choose an answer to see the explanation.
Key takeaways
- Imperative UI code gives the page commands for each event, and every list of commands can miss a part. Declarative code describes each look once, and React makes the changes.
- Step 1: list every look the user can see. Draw them with a mock that takes a status prop, or all at once in a living styleguide.
- Step 2: find the triggers. Human inputs come through event handlers; computer inputs, like a server's answer, come in the code that waits for them. Both set state.
- Steps 3 and 4: hold the looks in the fewest pieces of state. Replace booleans that clash with one status, and work out anything another piece of state already says.
- Step 5: make each handler set state to the next look, and nothing else. In TypeScript, a discriminated union can make impossible states impossible to write.
Remember the one-liner.
Describe every look once, and let one status choose which the user sees.
Coming next: Choosing the state structure. It turns the questions from Step 4 into five principles for any state: group it, don't let it contradict itself, and don't repeat, copy, or deeply nest it.