Skip to content
aviral gupta

// B5.1 · ~30 min · Beginner

Reacting to input with state

After this lesson you can build a form the declarative way: describe each visual state, keep a status string in state, and let handlers switch between the states.

Lesson 1 of 6 in B5 Managing state

Start of the module

You will be able to

  • Describe a UI declaratively: list its visual states and the human and computer inputs that trigger the changes
  • Represent the visual state with one status string and remove state that is not essential
  • Connect handlers that set the status, disable the form while it submits and show an error
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B3.5: copying objects. The handler copies form twice, changing one field each time. You click Send once. What does the <p> show (spaces at the ends left out)?

    function Survey() {
      const [form, setForm] = useState({ answer: 'Lima', status: 'typing' });
    
      function handleClick() {
        setForm({ ...form, status: 'sent' });
        setForm({ ...form, answer: '' });
      }
    
      return (
        <>
          <p>{form.answer} ({form.status})</p>
          <button onClick={handleClick}>Send</button>
        </>
      );
    }
  2. Predict · Activity 2 of 7

    Predict before you read on. Quiz keeps one boolean per visual state. You click Submit once. What do the <p> elements show?

    function Quiz() {
      const [isTyping, setIsTyping] = useState(true);
      const [isSubmitting, setIsSubmitting] = useState(false);
    
      return (
        <>
          {isTyping && <p>Typing</p>}
          {isSubmitting && <p>Submitting</p>}
          <button onClick={() => setIsSubmitting(true)}>Submit</button>
        </>
      );
    }
  3. Practice · Activity 3 of 7

    Fill in the status, so that the textarea is disabled while the answer is being checked and enabled again afterwards.

    <textarea
      value={answer}
      onChange={e => setAnswer(e.target.value)}
      disabled={status === '____'}
    />
    disabled={status === ''}
  4. Practice · Activity 4 of 7

    Match each input, human or computer, with the visual state the quiz form should switch to.

  5. Practice · Activity 5 of 7

    This catch block forgets one line. You type Rome, click Submit and wait for the check. What do the <p> and the textarea show? (<p> / textarea)

    // Pretend to ask a server: 50 ms, then a verdict.
    function check(answer) {
      return new Promise((resolve, reject) => {
        setTimeout(() => answer === 'Lima' ? resolve() : reject(new Error('Wrong answer')), 50);
      });
    }
    
    function Quiz() {
      const [answer, setAnswer] = useState('');
      const [error, setError] = useState(null);
      const [status, setStatus] = useState('typing');
    
      if (status === 'success') {
        return <h1>Right!</h1>;
      }
    
      async function handleSubmit(e) {
        e.preventDefault();
        setStatus('submitting');
        try {
          await check(answer);
          setStatus('success');
        } catch (err) {
          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>{error.message}</p>}
        </form>
      );
    }
  6. Brain teaser · Activity 6 of 7

    Brain teaser. isEmpty is kept in state next to answer. You type L, then delete it again. Is the button disabled after each step? (after L / after deleting)

    function Quiz() {
      const [answer, setAnswer] = useState('');
      const [isEmpty, setIsEmpty] = useState(true);
    
      function handleChange(e) {
        setAnswer(e.target.value);
        setIsEmpty(answer.length === 0);
      }
    
      return (
        <>
          <textarea value={answer} onChange={handleChange} />
          <button disabled={isEmpty}>Submit</button>
        </>
      );
    }
  7. Apply · Activity 7 of 7

    Mini-task: a newsletter form. In main.jsx, write a Newsletter component with an email input and a Subscribe button. Pretend to ask a server with a subscribe(email) function that waits 50 ms, then resolves when the address contains @ and rejects with an error otherwise. Keep email, error and a status string in state. Disable the input and the button while submitting, and the button while the input is empty. Show the error's message after a failure and Thanks for subscribing! after a success. Run it with ada, then with ada@example.com.

    Check your work against this list

Build it yourself

Read the worked example, then write the exercises. Your code runs in your browser or on your computer and is never uploaded.

Worked example

The city quiz, wired up declaratively

This is react.dev's quiz form with a 50 ms check instead of 1.5 s. Its state is answer, error and status. The JSX describes every visual state: the textarea is disabled while submitting, the button also while the answer is empty, the error shows when there is one, and success returns a heading instead of the form. A run only mounts, so timers at the bottom of main.jsx type Istanbul, submit, report twice, then type Lima and submit. In the browser the page stays live: try it yourself.

main.jsx

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);
    }
  }

  function handleTextareaChange(e) {
    setAnswer(e.target.value);
  }

  return (
    <>
      <h2>City quiz</h2>
      <p>
        In which city is there a billboard that turns air into drinkable water?
      </p>
      <form onSubmit={handleSubmit}>
        <textarea
          value={answer}
          onChange={handleTextareaChange}
          disabled={status === 'submitting'}
        />
        <br />
        <button disabled={answer.length === 0 || status === 'submitting'}>
          Submit
        </button>
        {error !== null && <p className="Error">{error.message}</p>}
      </form>
    </>
  );
}

function submitForm(answer) {
  // Pretend it's hitting the network: 50 ms, then a verdict.
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      let shouldError = answer.toLowerCase() !== 'lima';
      if (shouldError) {
        reject(new Error('Good guess but a wrong answer. Try again!'));
      } else {
        resolve();
      }
    }, 50);
  });
}

// A run only mounts. Act like a user: answer Istanbul, then Lima.
function typeAnswer(text) {
  const textarea = document.querySelector('textarea');
  const setValue = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(textarea), 'value').set;
  setValue.call(textarea, text);
  textarea.dispatchEvent(new window.Event('input', { bubbles: true }));
}

function report(moment) {
  const textarea = document.querySelector('textarea');
  const error = document.querySelector('.Error');
  console.log(moment + ': textarea ' + (textarea.disabled ? 'disabled' : 'enabled') + ', ' + (error ? error.textContent : 'no error'));
}

setTimeout(() => {
  typeAnswer('Istanbul');
  setTimeout(() => {
    document.querySelector('button').click();
    setTimeout(() => {
      report('Submitting');
      setTimeout(() => {
        report('After the check');
        typeAnswer('Lima');
        setTimeout(() => document.querySelector('button').click(), 20);
      }, 100);
    }, 10);
  }, 20);
}, 20);

Run it with

npm run dev

Output

Submitting: textarea disabled, no error
After the check: textarea enabled, Good guess but a wrong answer. Try again!
<!-- #root -->
<div id="root"><h1>That's right!</h1></div>
  • No handler disables or enables anything: one setStatus call changes the status, and the JSX decides what that means.
  • After a wrong answer the status goes back to typing, so the user can correct the answer while the error stays visible.
  • Success is an early return: the form is replaced by the heading.
  • The submit is a human input, handled by onSubmit; the check's answer is a computer input, which arrives after await.
Change it and run it

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Exercises

Exercise 1 of 2

A capital quiz with a status

CapitalQuiz asks for the capital of Australia, and checkAnswer(answer) pretends to ask a server: after 50 ms it resolves for Canberra and rejects with an error otherwise. Wire it up declaratively. Add error and status state, 'typing', 'submitting' or 'success'. Submit is disabled while the answer is empty; the textarea and Submit are disabled while submitting. A wrong answer shows the error's message and enables the form again; the right one replaces the form with a heading Correct!.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Start with const [status, setStatus] = useState('typing'); and const [error, setError] = useState(null);.

  2. Hint 2

    In handleSubmit: setStatus('submitting'), then try { await checkAnswer(answer); setStatus('success'); } catch (err) { setStatus('typing'); setError(err); }.

  3. Hint 3

    Before the return: if (status === 'success') return <h1>Correct!</h1>;. Show the message with {error !== null && <p>{error.message}</p>}.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import { useState } from 'react';

export function checkAnswer(answer) {
  // Pretend it's asking a server: 50 ms, then a verdict.
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (answer.trim().toLowerCase() === 'canberra') {
        resolve();
      } else {
        reject(new Error('Not quite. Try again!'));
      }
    }, 50);
  });
}

export default function CapitalQuiz() {
  const [answer, setAnswer] = useState('');
  const [error, setError] = useState(null);
  const [status, setStatus] = useState('typing'); // 'typing', 'submitting' or 'success'

  if (status === 'success') {
    return <h1>Correct!</h1>;
  }

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('submitting');
    try {
      await checkAnswer(answer);
      setStatus('success');
    } catch (err) {
      setStatus('typing');
      setError(err);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        What is the capital of Australia?
        <textarea
          value={answer}
          onChange={e => setAnswer(e.target.value)}
          disabled={status === 'submitting'}
        />
      </label>
      <button disabled={answer.length === 0 || status === 'submitting'}>
        Submit
      </button>
      {error !== null && <p className="error">{error.message}</p>}
    </form>
  );
}
Run it on your computer

Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.jsx

import { useState } from 'react';

export function checkAnswer(answer) {
  // Pretend it's asking a server: 50 ms, then a verdict.
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (answer.trim().toLowerCase() === 'canberra') {
        resolve();
      } else {
        reject(new Error('Not quite. Try again!'));
      }
    }, 50);
  });
}

export default function CapitalQuiz() {
  const [answer, setAnswer] = useState('');
  // TODO: add error and status state, and use them below.

  async function handleSubmit(e) {
    e.preventDefault();
    // TODO: submitting, then success, or typing with an error.
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        What is the capital of Australia?
        <textarea value={answer} onChange={e => setAnswer(e.target.value)} />
      </label>
      <button>Submit</button>
    </form>
  );
}

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen, fireEvent} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CapitalQuiz from './main.jsx';

const submit = () => screen.getByRole('button', {name: 'Submit'});

test('Submit is disabled until something is typed', async () => {
  const user = userEvent.setup();
  render(<CapitalQuiz />);
  assert.equal(submit().disabled, true, 'with an empty answer, Submit is disabled: ' + submit().disabled);
  await user.type(screen.getByRole('textbox'), 'Sydney');
  assert.equal(submit().disabled, false, 'after typing, Submit is disabled: ' + submit().disabled);
});

test('While checking, the form is disabled; a wrong answer shows the error', async () => {
  const user = userEvent.setup();
  render(<CapitalQuiz />);
  await user.type(screen.getByRole('textbox'), 'Sydney');
  // fireEvent clicks synchronously, so the 50 ms check cannot have finished yet.
  fireEvent.click(submit());
  assert.equal(screen.getByRole('textbox').disabled, true, 'right after Submit, the textarea is disabled: ' + screen.getByRole('textbox').disabled);
  assert.equal(submit().disabled, true, 'right after Submit, the button is disabled: ' + submit().disabled);
  const message = await screen.findByText('Not quite. Try again!');
  assert.ok(message, 'the error message appears after a wrong answer');
  assert.equal(screen.getByRole('textbox').disabled, false, 'after a wrong answer, the textarea is disabled: ' + screen.getByRole('textbox').disabled);
});

test('A right answer replaces the form with Correct!', async () => {
  const user = userEvent.setup();
  render(<CapitalQuiz />);
  await user.type(screen.getByRole('textbox'), 'Canberra');
  await user.click(submit());
  const heading = await screen.findByRole('heading', {name: 'Correct!'});
  assert.ok(heading, 'a heading Correct! appears after the right answer');
  assert.equal(screen.queryAllByRole('textbox').length, 0, 'after the right answer, textboxes left: ' + screen.queryAllByRole('textbox').length);
});

In a Vite React project (npm create vite@latest, template react), put main.jsx in src/ as App.jsx and start it with npm run dev. React renders it inside <StrictMode>, as here.

Run the program:

npm run dev

There is no command for the checks on your computer yet. They are in main.test.jsx, written with node:test and React Testing Library.

Exercise 2 of 2

A profile editor with two states

EditProfile always shows its two fields, and its button does nothing. Give it two visual states. Viewing: each name shows as bold text, <b>, and the button says Edit Profile. Editing: each name is an input and the button says Save Profile. A submit switches between them. Do not change elements by hand; keep one boolean, isEditing, in state and let the JSX describe both states. The greeting below always shows the current names.

Tab indents and Shift+Tab outdents. To leave the editor with the keyboard, press Esc, then Tab.

The first run downloads React and the compiler that turns JSX into JavaScript (up to 2 MB) and keeps them cached. Your components run in your browser and stay on your device.

Your page

Your page runs in an isolated frame. It has no access to this site, and its localStorage is kept in memory for this run only: the next run starts empty.

Run the program to see its page here.

Hints
  1. Hint 1

    Add const [isEditing, setIsEditing] = useState(false); and flip it in handleSubmit: setIsEditing(!isEditing).

  2. Hint 2

    For each name: {isEditing ? <input … /> : <b>{firstName}</b>}.

  3. Hint 3

    The button text: {isEditing ? 'Save' : 'Edit'} Profile.

Show a solution

One way to solve it. Yours can look different and still pass the checks.

import { useState } from 'react';

export default function EditProfile() {
  const [firstName, setFirstName] = useState('Jane');
  const [lastName, setLastName] = useState('Jacobs');
  const [isEditing, setIsEditing] = useState(false);

  function handleSubmit(e) {
    e.preventDefault();
    setIsEditing(!isEditing);
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        First name:{' '}
        {isEditing ? (
          <input value={firstName} onChange={e => setFirstName(e.target.value)} />
        ) : (
          <b>{firstName}</b>
        )}
      </label>
      <label>
        Last name:{' '}
        {isEditing ? (
          <input value={lastName} onChange={e => setLastName(e.target.value)} />
        ) : (
          <b>{lastName}</b>
        )}
      </label>
      <button type="submit">{isEditing ? 'Save' : 'Edit'} Profile</button>
      <p><i>Hello, {firstName} {lastName}!</i></p>
    </form>
  );
}
Run it on your computer

Install React 19.3 or newer. Save these files in one folder, open a terminal in that folder, and run the commands below.

main.jsx

import { useState } from 'react';

export default function EditProfile() {
  const [firstName, setFirstName] = useState('Jane');
  const [lastName, setLastName] = useState('Jacobs');
  // TODO: the fields are always shown, and the button does nothing.

  function handleSubmit(e) {
    e.preventDefault();
  }

  return (
    <form onSubmit={handleSubmit}>
      <label>
        First name:{' '}
        <input value={firstName} onChange={e => setFirstName(e.target.value)} />
      </label>
      <label>
        Last name:{' '}
        <input value={lastName} onChange={e => setLastName(e.target.value)} />
      </label>
      <button type="submit">Edit Profile</button>
      <p><i>Hello, {firstName} {lastName}!</i></p>
    </form>
  );
}

main.test.jsx

import {test} from 'node:test';
import assert from 'node:assert/strict';
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import EditProfile from './main.jsx';

const boxes = () => screen.queryAllByRole('textbox');
const greeting = () => screen.getByText(/^Hello,/).textContent;

test('The profile starts as text, with an Edit Profile button', () => {
  render(<EditProfile />);
  assert.equal(boxes().length, 0, 'before editing, textboxes shown: ' + boxes().length);
  assert.ok(screen.getByRole('button', {name: 'Edit Profile'}), 'there is an Edit Profile button');
  assert.equal(screen.getByText('Jane').tagName, 'B', 'Jane is shown in a <b>, not in a ' + screen.getByText('Jane').tagName);
});

test('Edit Profile shows both fields and a Save Profile button', async () => {
  const user = userEvent.setup();
  render(<EditProfile />);
  await user.click(screen.getByRole('button', {name: 'Edit Profile'}));
  assert.deepEqual(boxes().map((box) => box.value), ['Jane', 'Jacobs'], 'after Edit Profile, the fields hold ' + JSON.stringify(boxes().map((box) => box.value)));
  assert.ok(screen.getByRole('button', {name: 'Save Profile'}), 'after Edit Profile, there is a Save Profile button');
});

test('Save Profile shows the edited name as text', async () => {
  const user = userEvent.setup();
  render(<EditProfile />);
  await user.click(screen.getByRole('button', {name: 'Edit Profile'}));
  await user.clear(boxes()[0]);
  await user.type(boxes()[0], 'Ada');
  await user.click(screen.getByRole('button', {name: 'Save Profile'}));
  assert.equal(boxes().length, 0, 'after Save Profile, textboxes shown: ' + boxes().length);
  assert.equal(screen.getByText('Ada').tagName, 'B', 'Ada is shown in a <b>');
  assert.equal(greeting(), 'Hello, Ada Jacobs!', 'after saving, the greeting reads ' + JSON.stringify(greeting()));
});

In a Vite React project (npm create vite@latest, template react), put main.jsx in src/ as App.jsx and start it with npm run dev. React renders it inside <StrictMode>, as here.

Run the program:

npm run dev

There is no command for the checks on your computer yet. They are in main.test.jsx, written with node:test and React Testing Library.

Common mistakes

A value without an onChange handler

import { useState } from 'react';

export default function Form() {
  const [answer, setAnswer] = useState('');

  return (
    <form>
      <textarea value={answer} />
      <button disabled={answer.length === 0}>Submit</button>
    </form>
  );
}

What React prints

You provided a `value` prop to a form field without an `onChange` handler.

Why, and the fix

With value={answer}, React shows exactly the state, so typing changes nothing unless a handler updates answer. React warns that the field is read-only. Connect the handler, step 5: onChange={e => setAnswer(e.target.value)}.

Reading the error before there is one

import { useState } from 'react';

export default function Form() {
  const [answer, setAnswer] = useState('');
  const [error, setError] = useState(null);

  return (
    <form>
      <textarea value={answer} onChange={e => setAnswer(e.target.value)} />
      <button disabled={answer.length === 0}>Submit</button>
      <p className="Error">{error.message}</p>
    </form>
  );
}

What React prints

TypeError: Cannot read properties of null (reading 'message')

Why, and the fix

error starts as null, and the first render already reads error.message, so it throws (Chrome and Node wording; Firefox and Safari word it differently). Show the message only when there is an error: {error !== null && <p className="Error">{error.message}</p>}.

Rendering the error object itself

import { useState } from 'react';

export default function Form() {
  const [error, setError] = useState(null);
  const [status, setStatus] = useState('typing');

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('submitting');
    try {
      await new Promise((resolve, reject) => {
        setTimeout(() => reject(new Error('Wrong answer')), 50);
      });
    } catch (err) {
      setStatus('typing');
      setError(err);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <button disabled={status === 'submitting'}>Submit</button>
      {error !== null && <p className="Error">{error}</p>}
    </form>
  );
}

// A run only mounts. Act like a user: submit once.
setTimeout(() => document.querySelector('button').click(), 50);

What React prints

Objects are not valid as a React child (found: [object Error]).

Why, and the fix

error holds an Error object, and React cannot render an object as a child, so the render after the failed check throws. Render its text instead: <p className="Error">{error.message}</p>.

React in the browser: React 19.3.0 (development build) and React Testing Library, MIT, served from this site; JSX is compiled by TypeScript 6.0.3, Apache-2.0. Licence and source

Exit ticket

5 questions, no hints. Score 80% or more to complete the lesson.

Finish every activity above to unlock the exit ticket.

Report a problem

Spotted something wrong or unclear? Say what, and it will be checked and fixed.

#

At least 20 characters.

Only if you want a reply.

Key ideas

Describe what to show, not how to change it

Without React you write commands for each element: on submit, disable the button, show the spinner; on error, show the message, enable the form again. react.dev compares this imperative style to telling a driver every turn. It works for small examples but gets hard fast, because each new element means checking all the old commands. In React you describe the UI for each visual state, like telling a taxi driver where you want to go. You never write button.disabled = true; you write disabled={status === 'submitting'}, and React updates the page when the state changes.

The five steps

First, list the visual states the user might see: for a quiz form, empty, typing, submitting, success and error. A mock controlled by a status prop lets you look at each one before you add any logic. Second, find what triggers the changes: human inputs, such as clicking or typing, and computer inputs, such as a network response arriving or a timeout completing. Third, represent the state in memory with useState. Fourth, remove any state that is not essential. Fifth, connect event handlers that set the state. Human inputs usually need event handlers; computer inputs arrive in code you wait for.

One status string instead of many booleans

A first try often has a boolean for each visual state: isEmpty, isTyping, isSubmitting, isSuccess, isError. They can contradict each other: isTyping and isSubmitting can both be true, which no screen should show. Ask three questions. Does it cause a paradox? Then combine the booleans into one status that is 'typing', 'submitting' or 'success'. Is it already in another variable? isEmpty is answer.length === 0. Is it the inverse of another? isError is error !== null. The quiz keeps just answer, error and status, and every piece of state is essential: removing any of them breaks the form.

Sources

Last reviewed October 5, 2026