Home / Articles / A React Code Review Checklist: Derived State, Reducers, Effects and Memo

This article is published in English.

A React Code Review Checklist: Derived State, Reducers, Effects and Memo

Learn to spot seven common React review findings, from duplicated state and hand-rolled fetching to misused effects and blanket memoization, and what to write instead.

1906 words

Developers with a few years of React experience usually know how props flow, how hooks work and why state should never be mutated directly. Yet code reviews keep surfacing the same issues in their pull requests: components that work but are fragile, hard to test, overbuilt or difficult to extend. Moving past that stage is less about learning obscure APIs and more about sharpening the model of where state lives, what effects are for and when optimization pays off. This checklist walks through seven recurring findings, using a to-do list in TypeScript as the running example, and shows the pattern to reach for in each case.

1. State that should be derived

The core rule of React state is to keep a single source of truth. Whenever the same information lives in two places, the copies will eventually disagree.

What it looks like

A to-do list that needs both all items and completed items often ends up with two arrays in state:

// ❌ BAD: Storing derived state in two separate places
const [todos, setTodos] = useState<Todo[]>([]);
const [completedTodos, setCompletedTodos] = useState<Todo[]>([]);

Now every change has to be applied twice. If a user renames a task and the code updates todos but forgets completedTodos, the interface shows the old title in one list and the new one in the other.

What to write instead

Keep one array in state and compute the completed subset while rendering:

// ✅ GOOD: Single source of truth with derived state
const [todos, setTodos] = useState<Todo[]>([]);
// Derived value recalculated on every render (inexpensive computation)
const completedTodos = todos.filter((todo) => todo.completed);

Filtering an array on each render is cheap for typical sizes, and the derived list can never drift out of sync because it is recalculated from the source every time. If the calculation becomes genuinely expensive, for example filtering tens of thousands of items, wrap it in useMemo; otherwise a plain expression is clearer.

2. Hand-rolled data fetching in useEffect

Manual fetch calls inside useEffect look simple, but they tend to accumulate race conditions, lack caching and require loading and error state to be managed by hand in every component.

What it looks like

This effect loads the list once and uses an isMounted flag so it does not set state after the component unmounts:

// ❌ BAD: Boilerplate-heavy, error-prone, no caching or deduplication
useEffect(() => {
  let isMounted = true;
  fetch('/api/todos')
    .then((res) => res.json())
    .then((data) => {
      if (isMounted) setTodos(data);
    });
  return () => { isMounted = false; };
}, []);

It still has gaps. There is no error handling, no loading state, no deduplication when two components request the same data, no caching between screens and no retry. The isMounted flag also only suppresses the late update; it does not cancel the request, which an AbortController would.

What to write instead

Use a library built for server state, such as TanStack Query, SWR or RTK Query. The equivalent TanStack Query call declares a cache key and a fetch function and receives data, loading and error state back:

// ✅ GOOD: Declarative, automatically cached, auto-retried
const { data: todos, isLoading, error } = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
});

Libraries like these handle background revalidation, request deduplication, retries and cache sharing out of the box, and they offer structured support for optimistic updates and integration with error boundaries. The component code shrinks to a description of what it needs.

3. A pile of useState calls where a reducer fits

useState is ideal for simple values such as a toggle or a text field. Once updates depend on previous values, or state involves nested arrays and objects changed by several different actions, a collection of setters makes components bloated and invites stale-state bugs.

What it looks like

A single component declares five to ten useState hooks and updates them from many callbacks to add, edit, toggle and remove items. The business rules end up scattered across event handlers.

What to write instead

useReducer moves state transitions out of the component into one function. The reducer below uses a discriminated union for its actions, so TypeScript knows the payload type for each case. Adding a todo appends a new object with a generated id; toggling maps over the list and flips the matching item. Note that the toggle line as printed contains a typo: ...todo.completed should be !todo.completed, the logical negation, otherwise the code does not compile.

type Action =
  | { type: 'ADD_TODO'; payload: string }
  | { type: 'TOGGLE_TODO'; payload: string };

function todoReducer(state: Todo[], action: Action): Todo[] {
  switch (action.type) {
    case 'ADD_TODO':
      return [...state, { id: crypto.randomUUID(), text: action.payload, completed: false }];
    case 'TOGGLE_TODO':
      return state.map((todo) =>
        todo.id === action.payload ? { ...todo, completed: ...todo.completed } : todo
      );
    default:
      return state;
  }
}

Reducers pay off in two ways:

  • Testability. A reducer is a pure function, so you can test it without rendering anything or mocking React.
  • Predictability. Every update goes through a named action instead of arbitrary setters called from scattered callbacks, which makes changes easy to trace and log.

4. No tests because "testing is a big project"

Many teams treat testing as all or nothing: either a full end-to-end setup, which feels overwhelming, or nothing at all. A better approach starts with the tests that are cheapest to write and most valuable to keep.

Prioritize in this order:

  • Standalone business logic. Helpers, formatters, validators and utilities that live outside the component tree.
  • Reducers and custom hooks. Pure state transition logic.
  • Critical user journeys. The key interactions, tested with React Testing Library (@testing-library/react).

A reducer test is a good first step. It builds an initial state, dispatches an action directly through the function and asserts on the result, with no rendering involved:

// Example: Testing a reducer takes 2 minutes and provides huge peace of mind
test('should toggle todo status', () => {
  const initialState = [{ id: '1', text: 'Write Article', completed: false }];
  const newState = todoReducer(initialState, { type: 'TOGGLE_TODO', payload: '1' });

  expect(newState[0].completed).toBe(true);
});

A test like this takes minutes to write and protects the rules the whole feature depends on. Once the reducer typo from the previous section is fixed, this test is exactly what would have caught it.

5. Using useEffect to synchronize React with itself

useEffect is the most misused hook in React. A frequent pattern is using it to copy or adjust internal state when a prop changes, which causes an extra render and a brief moment of inconsistent UI.

Two misconceptions come up repeatedly:

  • "State must be updated in an effect when a prop changes." Usually not. Compute the value during render, or, if a child's state should start fresh, give it a key that changes with the relevant prop so React remounts it.
  • "Click behavior belongs in an effect." Logic triggered by a user action belongs in the event handler, such as onClick, not in an effect that watches for the state change the click produced.

The guiding principle: effects are an escape hatch for synchronizing with systems outside React, such as browser APIs, subscriptions, analytics or third-party DOM widgets. Data that React already owns can be transformed without one. For a longer treatment with before-and-after examples, see stop syncing state with useEffect.

6. Memoizing everything by default

Performance work tends to swing between two extremes: ignoring rendering cost entirely, or wrapping every component in React.memo and every function in useCallback.

Why blanket memoization backfires

useMemo and useCallback are not free. React has to store the dependencies and compare them on every render. For a component that is cheap to render, that bookkeeping can cost as much as the render it tries to skip, and it makes code harder to read. Memoization also only helps when every prop is stable; one inline object or arrow function passed to a memoized child defeats it.

A more deliberate approach

  • Profile first. The React DevTools Profiler shows which components actually render too often or too slowly.
  • Fix structure before adding memo. Moving state down into the component that uses it, or passing expensive subtrees as children, often prevents unnecessary renders without any memoization.
  • Memoize where it matters. Apply React.memo to large lists and heavy charts, and keep their props stable with useCallback and useMemo there.

7. Treating CSS and accessibility as someone else's job

Strong React engineers are also strong in the browser platform. Components are only as good as the markup and styles they produce.

Areas worth mastering:

  • Modern layout. Know Flexbox and CSS Grid well enough that absolute positioning is no longer the fallback for ordinary layouts.
  • Scoped styling. Keep styles next to components with CSS Modules, Tailwind CSS or CSS-in-JS, rather than relying on large global stylesheets where class names collide.
  • Accessibility and usability. Use semantic elements, such as a real <button> instead of a <div onClick={...}>. Make sure keyboard navigation with Tab, Enter and Space works everywhere. Provide visible :focus-visible styles and clear interactive states, such as a pointer cursor on clickable elements.

Review checklist

When reviewing a React pull request, look for these substitutions:

  • Derived values stored in extra useState hooks: compute them during render instead.
  • Manual API calls inside useEffect: use TanStack Query, SWR or RTK Query.
  • Many useState calls for complex arrays and objects: move transitions into useReducer and test the reducer.
  • No tests because setup feels expensive: start with pure functions, reducers and custom hooks.
  • useEffect used to sync state: derive the value or handle it in the event handler.
  • useCallback and React.memo everywhere: profile before memoizing.
  • Clickable div elements and missing focus styles: use semantic HTML and verify keyboard access.

Wrapping up

Most of these findings share a root cause: state or logic living in more places, or running more often, than it needs to. Keeping one source of truth, letting purpose-built tools own server data, isolating transitions in pure functions and reserving effects for the outside world all reduce the number of moving parts. Code with fewer moving parts is easier to test, easier to optimize when it matters and easier for the next reviewer to trust.