Chapter 3

Props, State and Data Flow Design

React's data model rests on two complementary concepts: Props and State. They are not two forms of the same thing — they serve fundamentally different roles, and confusing them (or failing to understand why data flow must be unidirectional) is the root cause of most architectural problems beginners encounter.

Unidirectional Data Flow: The Why, Not Just the How

"Unidirectional data flow" is React's core constraint. Most developers know what it is; far fewer understand why it was chosen.

In a two-way data binding system (Angular 1, early Vue), the UI and data can mutate each other. This feels convenient in small applications. As the application grows, the source of any particular state change becomes difficult to track — any component in the tree might be the origin of a mutation, and debugging an unexpected state change requires auditing every possible trigger path.

React's unidirectional data flow imposes a stricter rule: data flows downward from parent to child through props; if a child needs to affect a parent, it must do so by calling a callback function provided by the parent. State changes can only be initiated through setState or a useState updater function. React then re-renders the affected subtree.

This creates a predictable causal chain: data changes → React re-renders → UI updates. There are no hidden bidirectional channels, no "I changed X but why did Y change?" confusion. The system's behavior is auditable by tracing props and callbacks.

Props Immutability: Why You Cannot Mutate Props

Props are read-only from the perspective of the component that receives them. This is not merely a convention — it has deep structural reasons.

Consider the scenario: a parent passes an object prop to a child, and the child directly mutates a property on that object. Since objects are passed by reference in JavaScript, the parent's object is also mutated. But the parent has no way to know this happened — React never had the opportunity to detect a change and schedule a re-render. The result is a UI state that is out of sync with the data, producing a bug that is nearly impossible to trace.

The deeper reason comes from React's rendering optimization model. React determines whether to re-render a component by checking if its props have changed (when using React.memo or PureComponent). If child components could mutate props, this comparison mechanism would break — you cannot determine whether an update is needed by comparing old and new props if those props were mutated in place.

Props immutability guarantees that a component is a pure mapping of its parent's render output, making the entire system's behavior predictable.

// Wrong: mutating props directly
function UserCard({ user }) {
  user.name = 'Modified'; // Never do this
  return <div>{user.name}</div>;
}

// Correct: create local state derived from props
function UserCard({ user }) {
  const [editedName, setEditedName] = useState(user.name);
  return (
    <div>
      <input
        value={editedName}
        onChange={e => setEditedName(e.target.value)}
      />
    </div>
  );
}

State Semantics: Snapshots, Not Mutable Variables

React state is not an ordinary JavaScript variable. Every setState call schedules a re-render. When React re-renders by calling your component function, useState returns the new state value — a fresh snapshot for that render, not a reference to a shared mutable container.

This means state is an immutable snapshot within each render:

function Counter() {
  const [count, setCount] = useState(0);

  function handleClick() {
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
    // count is 0 for the entire duration of this render
    // These three calls are equivalent to setCount(1), not setCount(3)
  }

  return <button onClick={handleClick}>{count}</button>;
}

If you need to apply multiple incremental updates based on the previous state, use the functional update form:

function handleClick() {
  setCount(c => c + 1); // c receives the most recent queued value
  setCount(c => c + 1);
  setCount(c => c + 1);
  // Correct: count increases by 3
}

React 18 introduced Automatic Batching, which batches multiple setState calls across all contexts — including setTimeout and Promise callbacks — into a single re-render. React 16 and 17 only batched updates inside React event handlers. This was a subtle source of bugs that would cause double renders in async code, and the 18 change eliminates it.

Lifting State: The Canonical Pattern for Shared Data

When two components need to share the same data, the correct approach is to lift the state to their nearest common ancestor:

// Temperature converter: Celsius and Fahrenheit inputs stay in sync

function TemperatureConverter() {
  const [celsius, setCelsius] = useState('');

  const fahrenheit = celsius !== ''
    ? (parseFloat(celsius) * 9 / 5 + 32).toFixed(2)
    : '';

  return (
    <div>
      <TemperatureInput
        scale="celsius"
        value={celsius}
        onChange={setCelsius}
      />
      <TemperatureInput
        scale="fahrenheit"
        value={fahrenheit}
        onChange={f => setCelsius(((parseFloat(f) - 32) * 5 / 9).toFixed(2))}
      />
    </div>
  );
}

function TemperatureInput({ scale, value, onChange }) {
  return (
    <label>
      {scale === 'celsius' ? 'Celsius' : 'Fahrenheit'}:
      <input value={value} onChange={e => onChange(e.target.value)} />
    </label>
  );
}

Examine the design decisions here carefully:

Single source of truth: only Celsius is actual state; Fahrenheit is a derived value calculated on each render. There is exactly one number in memory, not two potentially inconsistent copies.

Ownership lives in the common ancestor: TemperatureConverter owns the state. Neither input owns its own value.

Both inputs are controlled: their displayed values are driven entirely by the parent.

The cost of lifting state is "prop drilling" — passing state and updater functions through intermediate layers. When component hierarchies become deep, this grows tedious. That is the signal to consider Context or a state management library. But in shallow hierarchies, prop drilling is often the correct choice: it makes data flow explicitly visible, which is a significant debugging advantage.

Component Communication Patterns

Parent to Child: Props (Foundation)

The most fundamental form of communication. Covered extensively above.

Child to Parent: Callback Props

function Parent() {
  const [message, setMessage] = useState('');

  return (
    <div>
      <Child onMessage={setMessage} />
      <p>Message from child: {message}</p>
    </div>
  );
}

function Child({ onMessage }) {
  return (
    <button onClick={() => onMessage('Hello from child!')}>
      Send Message
    </button>
  );
}

onMessage is a callback passed from parent to child. The child calls it to "notify" the parent. The parent decides what to do with the notification — the child has no direct access to the parent's state or implementation.

Cross-Tree Communication: The Context API

Context solves the prop drilling problem for genuinely global data, allowing values to "skip" the tree without manual forwarding through intermediate components:

const ThemeContext = createContext('light');

function App() {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Layout />
    </ThemeContext.Provider>
  );
}

// Deeply nested component; Layout never needs to pass theme manually
function Button({ children }) {
  const { theme } = useContext(ThemeContext);
  return (
    <button className={`btn-${theme}`}>{children}</button>
  );
}

Context is appropriate for: application theme, locale/language settings, the current authenticated user, global configuration. Do not treat Context as a general-purpose solution for avoiding prop drilling. Overused Context makes data flow opaque, reintroducing the "anything can change anything" problem that unidirectional flow was designed to prevent.

A practical guideline: if a value changes frequently and many unrelated components consume it, Context will cause excessive re-renders. In these cases, dedicated state management (Zustand, Redux, Jotai) with selective subscriptions is more appropriate.

Event Bus: The Anti-Pattern to Avoid

In early React communities, some developers used an event bus (EventEmitter) for arbitrary cross-component communication:

// Anti-pattern — do not do this
const eventBus = new EventEmitter();

// Component A
eventBus.emit('user-selected', userId);

// Component B
useEffect(() => {
  eventBus.on('user-selected', handleUserSelected);
  return () => eventBus.off('user-selected', handleUserSelected);
}, []);

The problems with this pattern:

It breaks unidirectional data flow: the origin and destination of data changes are no longer traceable through the component tree. React DevTools cannot observe event bus communication.

Lifecycle management is fragile: you must register and deregister listeners at exactly the right moments. Forgetting to clean up causes memory leaks and stale handler invocations.

Testing becomes difficult: components depend on an implicit global channel, making isolated unit testing nearly impossible without mocking the event bus.

Alternatives: state lifting with callback props (local communication between related components), Context with useReducer (app-wide state that changes via explicit dispatched actions), or Zustand/Redux (complex application state with computed selectors and middleware).

Controlled vs Uncontrolled Components

This pair of terms describes the relationship between form elements and React state specifically.

Controlled components — form element values driven by React state:

function ControlledInput() {
  const [value, setValue] = useState('');

  return (
    <input
      value={value}
      onChange={e => setValue(e.target.value)}
    />
  );
}

The input always displays the current value state. Every keystroke triggers onChange, updates state, schedules a re-render, and the input's displayed content is determined by the new state value. React has full control over the form element.

Uncontrolled components — form elements manage their own internal value; React reads it via a ref when needed:

function UncontrolledInput() {
  const inputRef = useRef(null);

  function handleSubmit() {
    console.log(inputRef.current.value);
  }

  return (
    <>
      <input ref={inputRef} defaultValue="initial" />
      <button onClick={handleSubmit}>Submit</button>
    </>
  );
}

Use uncontrolled components for: integrating third-party non-React libraries (rich text editors, date pickers that own their own DOM), handling file uploads (<input type="file"> is always uncontrolled — React cannot set its value for security reasons), and very large forms where keystroke-level re-renders have a measurable performance impact.

For most forms, controlled components are the correct choice. They enable instant validation, conditional formatting, and programmatic value manipulation, all of which become difficult with uncontrolled components.

React 19: useActionState and Server-Connected Forms

React 19 introduces useActionState (previously useFormState in the React DOM canary), designed to connect forms with Server Actions:

import { useActionState } from 'react';

async function submitAction(prevState, formData) {
  const name = formData.get('name');
  if (!name) return { error: 'Name is required' };
  await saveUser({ name });
  return { success: true };
}

function UserForm() {
  const [state, action, isPending] = useActionState(submitAction, null);

  return (
    <form action={action}>
      <input name="name" />
      {state?.error && <p className="error">{state.error}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
}

useActionState manages loading state and server response automatically. The form action attribute receives the wrapped function directly — no onSubmit handler, no e.preventDefault(). This pattern integrates cleanly with the new React 19 philosophy of treating server interactions as first-class form primitives.

State Design Principles

A few principles that prevent common bugs and improve maintainability:

Minimize State

Only put in state what actually needs to trigger a re-render. Derive everything else through computation:

// Wrong: storing derived data
const [items, setItems] = useState([]);
const [itemCount, setItemCount] = useState(0); // redundant

// Correct: derive on render
const [items, setItems] = useState([]);
const itemCount = items.length; // computed, not stored

Avoid Mirroring Props into State

Do not copy props into state unless you explicitly need an editable local copy and explicitly handle subsequent prop updates:

// Problematic: if parent's initialName changes, state does not update
function UserCard({ initialName }) {
  const [name, setName] = useState(initialName);
  // Stale if parent re-renders with a new initialName
}

// Better: use the prop directly when no local mutation is needed
function UserCard({ name }) {
  return <div>{name}</div>;
}

// If you need an editable copy, use key to reset when the identity changes
<UserCard key={userId} initialName={user.name} />

If two pieces of state always change together, merge them into a single object to keep updates atomic:

// Scattered state — easy to forget to update one
const [x, setX] = useState(0);
const [y, setY] = useState(0);

// Grouped state — single update, always consistent
const [position, setPosition] = useState({ x: 0, y: 0 });
setPosition({ x: 100, y: 200 });

Understanding the boundary between Props and State, and the direction of data flow, is the foundation for all React architecture. These principles do not change as your application grows. State management libraries — Redux, Zustand, Jotai, Recoil — provide better tooling for managing large-scale state, but they do not offer a different philosophy. They are implementations of the same unidirectional, explicit-mutation model, applied at application scale.

Rate this chapter
4.8  / 5  (83 ratings)

💬 Comments