← Back to blog

Your tests pass. Your linter doesn't.

4 min read
#react#hooks#best-practices#linting#useEffect

Every React developer has written code like this:

function TodoList() {
  const [list, setList] = useState([]);
  const [showClearCompleted, setShowClearCompleted] = useState(false);

  useEffect(() => {
    setShowClearCompleted(list.some((task) => task.isComplete));
  }, [list]);

  // ...
}

The app works. The tests pass. The feature is shipped. And nobody notices that this code has a bug — not a functional bug, but a structural one.

The silent lie of derived state

setShowClearCompleted inside a useEffect that watches list is the React equivalent of buying a notebook, writing down the result of 2 + 2, and then, every time one of the numbers changes, erasing it and writing 4 again — instead of just asking "what is 2 + 2?" when you need the answer.

The value of showClearCompleted is derived from list. It is not a separate piece of state. It is a computation over state you already have. Yet the code above treats it as if it were independent — storing it, syncing it, and triggering an extra render to apply it.

👉 Try it yourself: Challenge #53 — Best Practices

Here is what the corrected version looks like:

function TodoList() {
  const [list, setList] = useState([]);
  const showClearCompleted = list.some((task) => task.isComplete);

  // ...
}

No effect. No extra state variable. No cascading render. Just a value computed from state you already own. The component renders, the computation runs, the UI updates — all in a single pass.

Why this matters: cascading renders

When you call setState inside a useEffect, React has to do two renders instead of one. The first render computes the effect dependencies. The effect fires. It calls setState. React schedules a second render with the new state.

In a small component, you won't feel it. In a page with dozens of components, each with their own derived-state effects, the cascade compounds. You get flickers, stale intermediate states, and a performance profile that is needlessly heavy for no reason at all.

And the worst part? If you only test the output — the DOM, the rendered text, the visible behavior — you will never catch this.

Most platforms don't check. We do.

Go to any coding challenge site. Pick a React challenge. Write the version with useEffect. Run the tests. They pass. Congratulations, you solved it.

But did you write good React? The answer depends on what "good" means. If it means "the tests pass," then yes. If it means "idiomatic, performant, maintainable React that your coworkers won't silently judge during code review," then no.

Challenge #53 on React Challenges is different. When you hit Run Tests, two things happen:

  1. Your code runs against a suite of behavioral tests (does the todo list add, delete, toggle, and clear items?)
  2. Your code is checked against an ESLint configuration that enforces React best practices

If you use useEffect to sync derived state, the linter catches it:

 ✗ App.tsx:29:5  error
   react-hooks/set-state-in-effect
   Calling setState synchronously within an effect causes cascading renders

And the challenge is not considered complete until all lint rules pass.

The test-passing trap

There is a quiet assumption in the coding challenge world: if the tests pass, you are done. This assumption trains developers into habits that are fine for a green checkmark and dangerous for a production codebase.

You can write a component that works and is wrong. You can render the correct UI with code that silently burns through render cycles, hides derived data behind state machines, and uses effects as event handlers because the mental model of React's render cycle never fully clicked.

Most platforms let you walk away with the green checkmark and a false sense of mastery. We wanted to build one that tells you the second thing: yes, it works — but it is not right yet.

And for that, you need a linter.

👉 Try it yourself: Challenge #53 — Best Practices