Skip to content
aviral gupta

// B4.3 · ~30 min · Beginner

Editor, linting and formatting

After this lesson you can set up your editor for React, let a linter check the Rules of Hooks and Prettier format on save, and fix components that break those rules.

Lesson 3 of 4 in B4 Setting up a project

You will be able to

  • Name the editors react.dev lists, and set up Prettier with format on save
  • Explain what a linter is for, and which Hooks rules ESLint and the Vite template's oxlint check
  • Find and fix components that break the Rules of Hooks
  1. Warm-up · Activity 1 of 7

    Warm-up from lesson B4.2: the template's src/main.jsx wraps App in <StrictMode>, and npm run dev serves it. The page loads, and you click the button once. How many times does the console show render?

    function Counter() {
      console.log('render');
      const [count, setCount] = useState(0);
      return <button onClick={() => setCount(count + 1)}>count is {count}</button>;
    }
  2. Predict · Activity 2 of 7

    Predict before you read on. Ticket returns early once it is used, and calls a second useState after that return. What happens when you click the button?

    function Ticket() {
      const [used, setUsed] = useState(false);
      if (used) {
        return <p>Ticket used</p>;
      }
      const [seat] = useState('12A');
      return <button onClick={() => setUsed(true)}>Use seat {seat}</button>;
    }
  3. Practice · Activity 3 of 7

    Match each place where a Hook is called with the fix react.dev gives on its Rules of Hooks page.

  4. Practice · Activity 4 of 7

    Fill in the command that the Vite React template's package.json runs for npm run lint (create-vite 9.2.1).

    "lint": "",
  5. Practice · Activity 5 of 7

    Votes calls useState inside map, over a fixed array of three. That breaks the Rules of Hooks. What happens when it mounts?

    function Votes() {
      const counts = [0, 1, 2].map(() => useState(0));
      return <p>{counts.length} counters</p>;
    }
  6. Brain teaser · Activity 6 of 7

    Brain teaser. Badge calls a second useState only for admins. Page starts with isAdmin true, and its button toggles it. When does React throw?

    function Badge({ isAdmin }) {
      const [name] = useState('Ada');
      if (isAdmin) {
        const [stars] = useState(5);
      }
      return <p>{name}</p>;
    }
    
    function Page() {
      const [admin, setAdmin] = useState(true);
      return (
        <>
          <button onClick={() => setAdmin(!admin)}>Toggle</button>
          <Badge isAdmin={admin} />
        </>
      );
    }
  7. Apply · Activity 7 of 7

    Mini-task: set up your editor around the project from lesson B4.2. In my-app, run npm run lint. Then add a second useState inside an if in src/App.jsx and run npm run lint again: read the rule and the message it prints. Fix it by moving the Hook to the top level, and run npm run lint until it no longer reports the rule. In VS Code, install the Prettier extension and turn on format on save; mess up the indentation of App.jsx and save.

    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

Hooks on top, branches below

TeaCard calls its Hooks first, every render, in the same order: useToggle, a small custom Hook whose name starts with use, and useState for the quantity. Only then does it branch, inside the JSX, with open &&. The timers at the bottom act like a user: they click Show details, then Add one. The runner waits for them, then prints #root.

main.jsx

import { useState } from 'react';

// A custom Hook: its name starts with use, and it calls a Hook.
function useToggle(initialValue) {
  const [on, setOn] = useState(initialValue);
  return [on, () => setOn(!on)];
}

export default function TeaCard() {
  // Every Hook at the top level, in the same order on every render.
  const [open, toggle] = useToggle(false);
  const [quantity, setQuantity] = useState(1);

  // Branch below the Hooks: here, inside the JSX.
  return (
    <section>
      <h2>Green tea</h2>
      <button onClick={toggle}>{open ? 'Hide details' : 'Show details'}</button>
      {open && (
        <p>
          Quantity: {quantity}{' '}
          <button onClick={() => setQuantity(quantity + 1)}>Add one</button>
        </p>
      )}
    </section>
  );
}

// A run only mounts. Act like a user: open the details, then add one.
setTimeout(() => {
  document.querySelector('button').click();
  setTimeout(() => {
    document.querySelectorAll('button')[1].click();
  }, 50);
}, 50);

Run it with

npm run dev

Output

<!-- #root -->
<div id="root"><section><h2>Green tea</h2><button>Hide details</button><p>Quantity: 2 <button>Add one</button></p></section></div>
  • quantity exists from the first render, while the details are still closed, so the order of Hooks never changes.
  • useToggle is a custom Hook: a function whose name starts with use and that calls other Hooks. It follows the same rules.
  • In the browser the page stays live: click Hide details and Show details, and the quantity is kept.
  • Every Hook here is called at the top level of a component or custom Hook, as the Rules of Hooks ask.
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 Hook inside an if

TeaCard calls useState for the quantity only once the card is open, so Show details breaks the page. Move that Hook to the top level, so both Hooks run on every render, and keep the rest. The tests open the card, add two, hide and show it again: the quantity must be 3.

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

    Click Show details in Run: React throws, because the second render calls one Hook more than the first.

  2. Hint 2

    A Hook may stand before an early return, never after it or inside the if.

  3. Hint 3

    Move const [quantity, setQuantity] = useState(1); right below the first useState.

Show a solution

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

import { useState } from 'react';

export default function TeaCard() {
  const [open, setOpen] = useState(false);
  const [quantity, setQuantity] = useState(1);

  if (open) {
    return (
      <section>
        <button onClick={() => setOpen(false)}>Hide details</button>
        <p>Quantity: {quantity}</p>
        <button onClick={() => setQuantity(quantity + 1)}>Add one</button>
      </section>
    );
  }

  return (
    <section>
      <button onClick={() => setOpen(true)}>Show details</button>
    </section>
  );
}
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 TeaCard() {
  const [open, setOpen] = useState(false);

  if (open) {
    // TODO: this Hook is only called once the card is open.
    const [quantity, setQuantity] = useState(1);
    return (
      <section>
        <button onClick={() => setOpen(false)}>Hide details</button>
        <p>Quantity: {quantity}</p>
        <button onClick={() => setQuantity(quantity + 1)}>Add one</button>
      </section>
    );
  }

  return (
    <section>
      <button onClick={() => setOpen(true)}>Show details</button>
    </section>
  );
}

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 TeaCard from './main.jsx';

test('Show details opens the card with quantity 1', async () => {
  const user = userEvent.setup();
  render(<TeaCard />);
  await user.click(screen.getByRole('button', {name: 'Show details'}));
  const quantity = screen.queryByText(/^Quantity:/);
  assert.equal(quantity?.textContent, 'Quantity: 1', 'after Show details the page reads ' + JSON.stringify(document.body.textContent));
});

test('Add one counts, and the count survives Hide and Show', async () => {
  const user = userEvent.setup();
  render(<TeaCard />);
  await user.click(screen.getByRole('button', {name: 'Show details'}));
  const add = screen.queryByRole('button', {name: 'Add one'});
  assert.ok(add !== null, 'after Show details there is no button "Add one"');
  await user.click(add);
  await user.click(screen.getByRole('button', {name: 'Add one'}));
  await user.click(screen.getByRole('button', {name: 'Hide details'}));
  await user.click(screen.getByRole('button', {name: 'Show details'}));
  const quantity = screen.queryByText(/^Quantity:/);
  assert.equal(quantity?.textContent, 'Quantity: 3', 'after two adds, Hide and Show the page reads ' + JSON.stringify(document.body.textContent));
});

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

Hooks in a loop

Poll calls useState inside map, one per option. That works until Add Juice adds an option, and with it one Hook more. Give each option its own component, Option, with its own useState, and render one Option per item with a key. Votes must count per option and survive Add Juice.

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

    Click Add Juice in Run: React throws, because the render calls useState one time more.

  2. Hint 2

    A component calls its Hooks at its own top level, so each Option can have a useState of its own.

  3. Hint 3

    Write function Option({ name }) with const [count, setCount] = useState(0);, and render <Option key={option} name={option} /> in the map.

Show a solution

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

import { useState } from 'react';

function Option({ name }) {
  const [count, setCount] = useState(0);
  return (
    <li>
      <span>{name}: {count}</span>{' '}
      <button onClick={() => setCount(count + 1)}>Vote for {name}</button>
    </li>
  );
}

export default function Poll() {
  const [options, setOptions] = useState(['Tea', 'Coffee']);

  return (
    <>
      <ul>
        {options.map(option => (
          <Option key={option} name={option} />
        ))}
      </ul>
      {!options.includes('Juice') && (
        <button onClick={() => setOptions([...options, 'Juice'])}>Add Juice</button>
      )}
    </>
  );
}
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 Poll() {
  const [options, setOptions] = useState(['Tea', 'Coffee']);
  // TODO: one useState per option, called inside map.
  const votes = options.map(() => useState(0));

  return (
    <>
      <ul>
        {options.map((option, i) => {
          const [count, setCount] = votes[i];
          return (
            <li key={option}>
              <span>{option}: {count}</span>{' '}
              <button onClick={() => setCount(count + 1)}>Vote for {option}</button>
            </li>
          );
        })}
      </ul>
      {!options.includes('Juice') && (
        <button onClick={() => setOptions([...options, 'Juice'])}>Add Juice</button>
      )}
    </>
  );
}

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 Poll from './main.jsx';

test('Each option counts its own votes', async () => {
  const user = userEvent.setup();
  render(<Poll />);
  await user.click(screen.getByRole('button', {name: 'Vote for Tea'}));
  await user.click(screen.getByRole('button', {name: 'Vote for Tea'}));
  await user.click(screen.getByRole('button', {name: 'Vote for Coffee'}));
  assert.equal(screen.queryByText(/^Tea:/)?.textContent, 'Tea: 2', 'Tea after two votes');
  assert.equal(screen.queryByText(/^Coffee:/)?.textContent, 'Coffee: 1', 'Coffee after one vote');
});

test('Add Juice keeps the votes, and Juice counts too', async () => {
  const user = userEvent.setup();
  render(<Poll />);
  await user.click(screen.getByRole('button', {name: 'Vote for Tea'}));
  await user.click(screen.getByRole('button', {name: 'Add Juice'}));
  const juice = screen.queryByRole('button', {name: 'Vote for Juice'});
  assert.ok(juice !== null, 'after Add Juice the page reads ' + JSON.stringify(document.body.textContent));
  await user.click(juice);
  assert.equal(screen.queryByText(/^Tea:/)?.textContent, 'Tea: 1', 'Tea after one vote and Add Juice');
  assert.equal(screen.queryByText(/^Juice:/)?.textContent, 'Juice: 1', 'Juice after one vote');
});

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 Hook after an early return

import { useState } from 'react';

function Order({ sent }) {
  const [items] = useState(2);
  if (sent) {
    return <p>Order sent.</p>;
  }
  const [note] = useState('');
  return <p>{items} items, note: {note || 'none'}</p>;
}

export default function App() {
  const [sent, setSent] = useState(false);
  return (
    <>
      <Order sent={sent} />
      <button onClick={() => setSent(true)}>Send</button>
    </>
  );
}

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

What React prints

Rendered fewer hooks than expected. This may be caused by an accidental early return statement.

Why, and the fix

The first render calls two Hooks in Order; after Send, the early return skips the second. Move const [note] = useState(''); above the if, so every render calls both. react.dev: always use Hooks at the top level, before any early returns.

An Effect only under a condition

import { useEffect, useRef, useState } from 'react';

export default function Search() {
  const [query, setQuery] = useState('');
  if (query !== '') {
    useEffect(() => {
      document.title = 'Search: ' + query;
    });
  }
  const inputRef = useRef(null);

  return (
    <>
      <input ref={inputRef} value={query} readOnly />
      <button onClick={() => setQuery('tea')}>Search tea</button>
    </>
  );
}

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

What React prints

React has detected a change in the order of Hooks called by Search.

Why, and the fix

After the click, the second Hook Search calls is useEffect instead of useRef, so React warns and prints a table of both orders, then throws. Call useEffect on every render and put the condition inside it: useEffect(() => { if (query !== '') document.title = 'Search: ' + query; });. react.dev: do not call Hooks inside conditions or loops.

A Hook inside useMemo

import { useMemo, useState } from 'react';

export default function Price() {
  // useMemo, a Hook you meet later, caches a calculation.
  const label = useMemo(() => {
    const [cents] = useState(250);
    return '€' + (cents / 100).toFixed(2);
  }, []);

  return <p>Green tea {label}</p>;
}

What React prints

Do not call Hooks inside useEffect(...), useMemo(...), or other built-in Hooks.

Why, and the fix

The function passed to useMemo is not the top level of a component, even though it runs while Price renders. React warns already on the first render. Call useState at the top level of Price, and only use its value inside the callback: const [cents] = useState(250); const label = useMemo(() => '€' + (cents / 100).toFixed(2), [cents]);.

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

An editor and a linter

react.dev calls VS Code one of the most popular editors, and also names WebStorm, Sublime Text and Vim. A linter finds problems in your code as you write. react.dev recommends ESLint and says to enable all the eslint-plugin-react-hooks rules: they catch the most severe bugs early. Vite's React template ships a different linter, oxlint: in a run on 05.10.2026, package.json had "lint": "oxlint", and .oxlintrc.json set react/rules-of-hooks to error. For a useState inside an if, oxlint 1.86.0 printed: React Hook "useState" is called conditionally. React Hooks must be called in the exact same order in every component render. An editor extension shows such problems as you type.

Prettier and format on save

Prettier reformats your code to preset, configurable rules: indentation, quotes, tabs or spaces. Nobody has to argue about them. In VS Code, open Quick Open (Ctrl/Cmd+P), paste ext install esbenp.prettier-vscode and press Enter. Then press Ctrl/Cmd+Shift+P, type settings, search for format on save and tick it: react.dev says you should ideally format on every save. If your ESLint preset has formatting rules, they may conflict with Prettier; react.dev recommends eslint-config-prettier to turn them off, so ESLint only catches logical mistakes. To make sure files are formatted before a pull request is merged, run prettier --check in continuous integration.

The Rules of Hooks

Functions whose names start with use are Hooks, such as useState or your own useToggle. Call them only at the top level of a component or a custom Hook, before any early return: not in conditions, loops, event handlers, functions passed to useMemo or useEffect, or try/catch. Hooks rely on a stable call order on every render. React catches some breaks while it runs: one more Hook after a click throws "Rendered more hooks than during the previous render.", and a Hook inside useMemo prints a warning. A Hook in a loop over a fixed array runs silently: only the linter sees it. To fix, move the Hook up and branch below it, or give each item its own component.

Sources

Last reviewed October 5, 2026