Reacting to Input with State
React wants you to build UI declaratively. Instead of reaching in and flipping individual pieces of the screen on and off, you describe the states your component can be in, and let React switch between them as input arrives. This page is the shift in mindset that makes everything else in state management feel natural.
How declarative UI compares to imperative
In an imperative approach, you write the exact step-by-step instructions to change the UI after each event: disable this button, show that spinner, hide the error. You are personally responsible for touching every element at the right moment.
async function handleFormSubmit(e) {
e.preventDefault();
disable(textarea);
disable(button);
show(loadingMessage);
hide(errorMessage);
try {
await submitForm(textarea.value);
show(successMessage);
hide(form);
} catch (err) {
show(errorMessage);
errorMessage.textContent = err.message;
} finally {
hide(loadingMessage);
enable(textarea);
enable(button);
}
}
This works for a tiny example, but it scales badly. Every new element or interaction means carefully rechecking all the existing show/hide/enable/disable calls so you don't forget one and introduce a bug.
The declarative approach flips it around. You don't enable, disable, show, or hide anything yourself. You declare what the UI should look like for each state, and React works out how to get there. You stop describing how to update the screen and start describing what it should be.
Thinking about UI declaratively
React's docs lay this out as five steps, and they're a genuinely useful checklist for any interactive component.
Step 1: Identify the different visual states
Before any logic, list every distinct thing the user could see. For a quiz form, that's five states. Empty has a disabled submit button. Typing has it enabled. Submitting disables the whole form and shows a spinner. Success replaces the form with a thank-you message. Error looks like Typing but adds an error line.
A good move here is to mock these up first, driven by a prop, so you can eyeball each one:
export default function Form({ status = "empty" }) {
if (status === "success") {
return <h1>That's right!</h1>;
}
return (
<form>
<textarea disabled={status === "submitting"} />
<button disabled={status === "empty" || status === "submitting"}>
Submit
</button>
{status === "error" && <p className="Error">Wrong answer, try again!</p>}
</form>
);
}
Deep dive: show many states at once. You can render the component in every state side by side by mapping over the list of statuses. Pages like this are called "living styleguides" or "storybooks", and they're great for reviewing all states without clicking through them.
let statuses = ["empty", "typing", "submitting", "success", "error"];
export default function App() {
return statuses.map((status) => (
<section key={status}>
<h4>Form ({status}):</h4>
<Form status={status} />
</section>
));
}
Step 2: Determine what triggers the state changes
State changes come from two sources. Human inputs are things like clicking a button, typing in a field, or following a link. Computer inputs are things like a network response arriving, a timeout firing, or an image loading.
For the quiz form: typing in the box moves it between Empty and Typing, clicking Submit moves it to Submitting, a successful response moves it to Success, and a failed one moves it to Error with a message.
Note. Human inputs almost always mean wiring up event handlers.
Step 3: Represent the state in memory with useState
Now turn those states into useState. Start with the values that absolutely must exist. Here you clearly need the current answer and any error:
const [answer, setAnswer] = useState("");
const [error, setError] = useState(null);
Then the trickier part, how to represent which visual state you're in. The naive first attempt is a boolean per state, which you'll clean up next:
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);
Step 4: Remove any non-essential state
Too much state is a bug factory, because separate variables can drift out of sync. Trim it by asking three questions of each variable.
First, does this state cause a paradox? isTyping and isSubmitting can't both be true, yet two booleans allow that impossible combination. Collapse them into a single status that is one of 'typing', 'submitting', or 'success'.
Second, is this information already in another variable? isEmpty duplicates something you can just compute, so drop it and check answer.length === 0 instead.
Third, can you get it from the inverse of another variable? isError is unnecessary because error !== null tells you the same thing.
After this, seven variables become three essential ones:
const [answer, setAnswer] = useState("");
const [error, setError] = useState(null);
const [status, setStatus] = useState("typing"); // 'typing' | 'submitting' | 'success'
Deep dive: eliminating impossible states with a reducer. Even these three allow a few combinations that don't quite make sense, like a non-null error while status is 'success'. To model state more precisely, you can extract it into a reducer, which unifies related state into one object and keeps the logic together.
Step 5: Connect the event handlers to set state
Finally, wire handlers that set the state, and let the JSX read from it. Notice there's no manual show/hide anywhere, the JSX simply reflects status, answer, and error.
import { useState } from "react";
export default function Form() {
const [answer, setAnswer] = useState("");
const [error, setError] = useState(null);
const [status, setStatus] = useState("typing");
if (status === "success") {
return <h1>That's right!</h1>;
}
async function handleSubmit(e) {
e.preventDefault();
setStatus("submitting");
try {
await submitForm(answer);
setStatus("success");
} catch (err) {
setStatus("typing");
setError(err);
}
}
return (
<form onSubmit={handleSubmit}>
<textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
disabled={status === "submitting"}
/>
<button disabled={answer.length === 0 || status === "submitting"}>
Submit
</button>
{error !== null && <p className="Error">{error.message}</p>}
</form>
);
}
Recap
Declarative UI means describing what the screen should look like for each visual state, rather than micromanaging elements the imperative way. To build an interactive component, work through the five steps: identify all its visual states, figure out the human and computer triggers that move between them, model those with useState, remove non-essential state so variables can't drift or contradict each other, and finally connect event handlers that set the state while the JSX just reflects it.