← Home

Better React: Predictability Principles

This started as an internal guide I wrote for our engineers. Some examples and challenges are adapted from the React docs’ guide on choosing state structure.

This one’s a bit longer. You might want to set aside focused time.

Introduction

When you’re building with React, there’s no shortage of advice: performance tips, new libraries, and state management tradeoffs. But in the middle of all that complexity, it’s easy to miss something more fundamental — something that makes every other part of your app easier to understand, debug, and evolve:

What makes a component predictable?

At its core, a predictable component is one that behaves exactly as you’d expect — based on what you see in the code.

You don’t need to hunt down unrelated files, inspect browser logs, or mentally simulate edge cases. The logic is local. The behavior is clear. The surprises are few.

Principles for Writing More Predictable React

When building a React component, you make constant decisions about how to manage state, how to name things, and where logic should live. Even when the code runs correctly, poor decisions in these areas can make your component harder to understand, reason about, or change later.

These principles are designed to help you structure your state and props in a way that makes your components predictable — meaning anyone reading them can tell, with confidence, what’s happening and why.

Only store what can’t be derived

If a value can be calculated from props or other state during render, there’s usually no need to store it separately. Derived values stored in state are a common source of bugs and desync.

If two or more pieces of state are always updated together, they likely belong in the same state object. This avoids bugs caused by partial updates or forgotten dependencies.

Avoid contradictory state

State should be structured so that components can’t end up in invalid or conflicting conditions (e.g. isSaving: true and status: 'idle'). Design your structure to prevent contradictions from happening.

Reduce duplication

The more you duplicate the same data across state variables or components, the harder it is to keep them in sync. Avoid duplicating data unless there’s a strong reason to do so.

Clear Naming — Avoid Patterns That Hide Intent or Cause Confusion

We use descriptive and consistent prop names. For event handlers, we typically use the on[Action] pattern (e.g., onClick, onSubmit). For boolean props, we often use the is[State] convention (e.g., isActive, isVisible).

Stick to unidirectional data flow

Data should flow downwards from parent to child components via props. Changes to data should be handled by event handlers passed down as props, promoting a clear and traceable flow of information.

Keep render logic pure

The render function should be free of side effects. No API calls, no timeouts, no direct DOM manipulation. These belong in useEffect, event handlers, or external logic.

Only store what can’t be derived

If a piece of information can be calculated or derived from the component’s props or its existing state variables during rendering, you generally shouldn’t store it in a separate state variable.

Storing derived values can lead to inconsistencies, increased complexity, and potential bugs if you forget to update them whenever their dependencies change.

In this example, fullName is redundant. It can always be constructed by concatenating firstName and lastName. Storing it as a separate state variable requires us to update it in both handleFirstNameChange and handleLastNameChange, increasing the chances of a mistake and making the component’s logic less direct.

The more predictable approach is to derive fullName directly in the render output:

import { useState } from 'react';

export default function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');
  const fullName = firstName + ' ' + lastName;

  function handleFirstNameChange(e) {
    setFirstName(e.target.value);
  }

  function handleLastNameChange(e) {
    setLastName(e.target.value);
  }

  return (
    // ... (rest of the form)
    <p>
      Your ticket will be issued to: <b>{fullName}</b>
    </p>
    // ...
  );
}

Challenge

In this example, each Letter has an isSelected prop and an onToggle handler that marks it as selected. This works, but the state is stored as a selectedId (either null or an ID), so only one letter can get selected at any given time.

Change the state structure to support multiple selection. (How would you structure it? Think about this before writing the code.) Each checkbox should become independent from the others. Clicking a selected letter should uncheck it. Finally, the footer should show the correct number of the selected items.

When you have pieces of state that are conceptually linked or are always updated in tandem, it’s often more predictable and maintainable to group them together into a single state object rather than managing them as separate state variables.

import React, { useState } from 'react';

function Modal() {
  const [isOpen, setIsOpen] = useState(false);
  const [x, setX] = useState(0);
  const [y, setY] = useState(0);

  const openModal = (newX, newY) => {
    setIsOpen(true);
    setX(newX);
    setY(newY);
  };

  const closeModal = () => {
    setIsOpen(false);
  };

  const handleDrag = (deltaX, deltaY) => {
    setX(prevX => prevX + deltaX);
    setY(prevY => prevY + deltaY);
  };

  return (
    <div>
      <button onClick={() => openModal(100, 150)}>Open Modal</button>
      {isOpen && (
        <div style={{ position: 'absolute', left: x, top: y, border: '1px solid black', padding: '20px' }}>
          <h3>Modal Content</h3>
          <button onClick={closeModal}>Close</button>
          <button onClick={() => handleDrag(10, 10)}>Drag</button>
        </div>
      )}
    </div>
  );
}

export default Modal;

While this works, managing isOpen, x, and y separately can lead to less clear code and potential for inconsistencies. For instance, you might accidentally update the position without setting isOpen to true.

import React, { useState } from 'react';

function Modal() {
  const [modalState, setModalState] = useState({
    isOpen: false,
    x: 0,
    y: 0,
  });

  const openModal = (newX, newY) => {
    setModalState({ isOpen: true, x: newX, y: newY });
  };

  const closeModal = () => {
    setModalState(prevState => ({ ...prevState, isOpen: false }));
  };

  const handleDrag = (deltaX, deltaY) => {
    setModalState(prevState => ({
      ...prevState,
      x: prevState.x + deltaX ?? 0,
      y: prevState.y + deltaY ?? 0,
    }));
  };

  // ... (rest of the modal logic using modalState)
}

By grouping these related values into the modalState object, we create a clearer representation of the modal’s state as a single entity. Updates to the modal’s state are now more cohesive.

Challenge

This packing list has a footer that shows how many items are packed, and how many items there are overall. It seems to work at first, but it is buggy. For example, if you mark an item as packed and then delete it, the counter will not be updated correctly. Fix the counter so that it’s always correct.

You’re halfway through! Feel free to pause here, take a break, or come back later. The concepts will still be here when you return — and your future self will thank you.

Avoid contradictory state

A significant challenge in building predictable React components arises when dealing with multiple pieces of state that are related but managed independently. When the possible combinations of these state variables aren’t carefully controlled, your component can end up in illogical or “contradictory” states, leading to unpredictable behavior and making debugging significantly harder.

The core idea is to identify sets of state variables whose values are interdependent or represent different phases of the same process. If these are managed separately, ensuring that only valid combinations exist at any given time becomes complex and error-prone.

While isSending and isSent are boolean flags, they represent different stages of the feedback submission process. Managing them as separate pieces of state makes it possible for the component to inadvertently enter a state where both are true — a logical contradiction — or set accidentally in a different order. As your components grow complex it becomes harder to understand what happened.

Instead, we can use a single status variable with clear, mutually exclusive values:

import { useState } from 'react';

export default function FeedbackForm() {
  const [text, setText] = useState('');
  const [status, setStatus] = useState('typing'); // 'typing' | 'sending' | 'sent'

  async function handleSubmit(e) {
    e.preventDefault();
    setStatus('sending');
    await sendMessage(text);
    setStatus('sent');
  }

  const isSending = status === 'sending';
  const isSent = status === 'sent';

  if (isSent) {
    return <h1>Thanks for feedback!</h1>
  }

  return (
    <form onSubmit={handleSubmit}>
      {/* ... form elements using isSending for disabling ... */}
      {isSending && <p>Sending...</p>}
    </form>
  );
}

By limiting the status to a single valid value at any time, we prevent the possibility of getting out of sync with each other and cause unexpected behavior. The boolean flags isSending and isSent are now derived from this single source of truth, ensuring they are always consistent and predictable.

Challenge

There is a list of letters in state. When you hover or focus a particular letter, it gets highlighted. The currently highlighted letter is stored in the highlightedLetter state variable. You can “star” and “unstar” individual letters, which updates the letters array in state.

This code works, but there is a minor UI glitch. When you press “Star” or “Unstar”, the highlighting disappears for a moment. However, it reappears as soon as you move your pointer or switch to another letter with keyboard. Why is this happening? Fix it so that the highlighting doesn’t disappear after the button click.

Avoid redundant state

A seemingly innocent pattern that often leads to redundant state and unpredictable behavior is initializing state directly from props, like this:

function Message({ messageColor }) {
  const [color, setColor] = useState(messageColor);
  // ... component logic using 'color' ...
}

While this might seem like a way to have a local, modifiable version of a prop, it introduces a subtle but significant issue: the component’s state (color) will only be initialized with the messageColor prop on the initial render. If the messageColor prop from the parent component changes on subsequent renders, the color state within the Message component will not automatically update. This can lead to the component displaying stale information and behaving unexpectedly if you assume color always reflects the latest messageColor.

This pattern is particularly problematic when dealing with data fetched asynchronously or dynamic styling based on the parent state. Developers might forget that the initial prop value is only used once, leading to components that don’t react to prop updates as expected.

If you really need to make a copy of the state, make sure you’re listening to updates inside useEffect and avoid creating deep copies of the object.

Challenge

This Clock component receives two props: color and time. When you select a different color in the select box, the Clock component receives a different color prop from its parent component. However, for some reason, the displayed color doesn’t update. Why? Fix the problem.

If you’ve made it this far — well done. If you need to step away, no problem. Just pick up from where you left off.

Clear Naming — Avoid Patterns That Hide Intent or Cause Confusion

While naming might seem like the most basic part of writing components, it’s also the most commonly overlooked. One subtle but impactful example of this is how callbacks are passed to components — especially when mutator functions like setState are passed directly.

Take a native <button> as an example. The HTML spec gives it an onClick event. You don’t have to think about how that callback is implemented — you just wire it up with the action you want, and you know how it will behave. It’s predictable, semantic, and self-contained.

Contrast that with custom components that are passed direct mutators:

<Toggle onChange={setIsActive} />

or worse, passing the setIsActive directly:

<Toggle setIsActive={setIsActive} />

At first glance, this looks reasonable. But it hides intent. What exactly is changing? What values it accepts? And what side effects does it trigger? You won’t know until you drill down into the Toggle component’s implementation.

Compare that to:

<Toggle onToggle={() => setIsActive((v) => !v)} />

Now the behavior is self-explanatory: it’s a toggle action and it indicates it’s switching between two values, so probably a boolean. Toggle can also be used in other components — it doesn’t need to worry about setIsActive.