You are viewing a preview of this lesson. Sign in to start learning
Back to React

State & Props Management

Master data flow between components and internal state

State & Props Management in React

Master React's core data flow concepts with free flashcards and spaced repetition practice. This lesson covers component state, props passing, lifting state up, and the distinction between controlled and uncontrolled componentsโ€”essential skills for building interactive React applications.

Welcome to React's Data Management ๐Ÿ’ป

Every interactive application needs to manage data. In React, this data flows through your component tree via two primary mechanisms: state and props. Understanding how these work together is fundamental to building scalable React applications.

Think of React components like a chain of restaurants ๐Ÿ”. Each location (component) needs its own local inventory (state), but they also receive supplies from headquarters (props from parent components). Some decisions happen locally, while others need to be coordinated across all locations.

Core Concepts

What is State? ๐Ÿ”„

State is data that a component owns and manages internally. When state changes, React re-renders the component to reflect those changes.

Key characteristics of state:

  • Private and local to the component
  • Mutable (can be changed)
  • Triggers re-renders when updated
  • Persists between renders

State is declared using the useState hook in functional components:

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

This declares a state variable count with an initial value of 0 and a setter function setCount to update it.

๐Ÿ’ก Tip: Never mutate state directly! Always use the setter function. React compares references to determine if it needs to re-render.

What are Props? ๐Ÿ“ฆ

Props (short for "properties") are arguments passed from parent to child components. They enable data flow down the component tree.

Key characteristics of props:

  • Read-only (immutable from child's perspective)
  • Passed from parent to child
  • Can be any JavaScript value (strings, numbers, objects, functions)
  • Enable component reusability

Props are passed like HTML attributes:

<UserCard name="Alice" age={25} isActive={true} />

And accessed in the child component:

function UserCard({ name, age, isActive }) {
  return (
    <div>
      <h2>{name}</h2>
      <p>Age: {age}</p>
    </div>
  );
}

The Unidirectional Data Flow ๐ŸŒŠ

React enforces a one-way data flow: data moves down from parent to child via props, and changes move up via callback functions.

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚         Parent Component            โ”‚
โ”‚                                     โ”‚
โ”‚  State: { count: 5 }                โ”‚
โ”‚                                     โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚  โ”‚ Props โ†“                       โ”‚ โ”‚
โ”‚  โ”‚ count={count}                 โ”‚ โ”‚
โ”‚  โ”‚ onIncrement={handleIncrement} โ”‚ โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
               โ”‚
               โ†“ Props flow down
โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚         Child Component             โ”‚
โ”‚                                     โ”‚
โ”‚  Receives: count, onIncrement       โ”‚
โ”‚                                     โ”‚
โ”‚                            โ”‚
โ”‚                                     โ”‚
โ”‚  Callbacks โ†‘ flow up (trigger       โ”‚
โ”‚  parent state changes)              โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

๐Ÿค” Did you know? This unidirectional flow makes React applications easier to debug. You can trace data changes by following the component tree from parent to child, rather than hunting through bidirectional bindings.

Lifting State Up ๐ŸŽˆ

When multiple components need to share the same state, you lift the state up to their closest common ancestor.

The pattern:

  1. Move state to the common parent
  2. Pass state down as props
  3. Pass setter functions down as props
  4. Child components call those functions to update parent state
function ParentComponent() {
  const [sharedValue, setSharedValue] = useState('');
  
  return (
    <div>
      <InputComponent value={sharedValue} onChange={setSharedValue} />
      <DisplayComponent value={sharedValue} />
    </div>
  );
}

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

function DisplayComponent({ value }) {
  return <p>You typed: {value}</p>;
}

๐Ÿง  Memory device: Think "Lift State Up" = LSU = "Let Siblings Unite" - when siblings need to communicate, lift their shared state to the parent.

State vs Props: Quick Comparison

Aspect State ๐Ÿ”„ Props ๐Ÿ“ฆ
Ownership Owned by component Owned by parent
Mutability Mutable (via setter) Immutable (read-only)
Source Declared in component Passed from parent
Changes trigger Local re-render Re-render when parent updates
Use case Component-specific data Configuring components

Controlled vs Uncontrolled Components ๐ŸŽฎ

Controlled components have their form data managed by React state:

function ControlledInput() {
  const [value, setValue] = useState('');
  
  return (
    <input 
      value={value} 
      onChange={(e) => setValue(e.target.value)} 
    />
  );
}

The input's value is always synchronized with React state. React is the "single source of truth."

Uncontrolled components store their own state internally (in the DOM):

function UncontrolledInput() {
  const inputRef = useRef();
  
  const handleSubmit = () => {
    console.log(inputRef.current.value);
  };
  
  return (
    <>
      <input ref={inputRef} />
      <button onClick={handleSubmit}>Submit</button>
    </>
  );
}

The DOM manages the input value; React only accesses it when needed via a ref.

๐Ÿ’ก Best practice: Use controlled components for most form inputs. They provide better validation, conditional rendering, and integration with React's state management.

Detailed Examples

Example 1: Counter with State Management

A simple counter demonstrating state updates:

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  const [step, setStep] = useState(1);
  
  const increment = () => setCount(count + step);
  const decrement = () => setCount(count - step);
  const reset = () => setCount(0);
  
  return (
    <div>
      <h2>Count: {count}</h2>
      <div>
        <button onClick={decrement}>-</button>
        <button onClick={reset}>Reset</button>
        <button onClick={increment}>+</button>
      </div>
      <div>
        <label>
          Step: 
          <input 
            type="number" 
            value={step} 
            onChange={(e) => setStep(Number(e.target.value))} 
          />
        </label>
      </div>
    </div>
  );
}

What's happening:

  • Two state variables: count and step
  • Each button click triggers a state update
  • The input is controlled - its value always reflects the step state
  • When state changes, React re-renders with new values

Example 2: Props Passing and Composition

Building reusable components with props:

function ProductCard({ product, onAddToCart }) {
  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p className="price">${product.price}</p>
      <p>{product.description}</p>
      <button onClick={() => onAddToCart(product.id)}>
        Add to Cart
      </button>
    </div>
  );
}

function ProductList() {
  const [cart, setCart] = useState([]);
  
  const products = [
    { id: 1, name: 'Laptop', price: 999, image: '/laptop.jpg', description: 'Fast laptop' },
    { id: 2, name: 'Mouse', price: 29, image: '/mouse.jpg', description: 'Wireless mouse' },
  ];
  
  const addToCart = (productId) => {
    setCart([...cart, productId]);
  };
  
  return (
    <div>
      <h2>Products (Cart: {cart.length} items)</h2>
      <div className="product-grid">
        {products.map(product => (
          <ProductCard 
            key={product.id}
            product={product} 
            onAddToCart={addToCart} 
          />
        ))}
      </div>
    </div>
  );
}

Key patterns:

  • ProductCard is reusable - it works with any product data
  • Props include both data (product) and callbacks (onAddToCart)
  • State lives in ProductList, the parent component
  • Child communicates with parent via the callback function

Example 3: Lifting State for Shared Data

Two components sharing temperature data:

function TemperatureInput({ scale, temperature, onTemperatureChange }) {
  const handleChange = (e) => {
    onTemperatureChange(e.target.value);
  };
  
  const scaleNames = { c: 'Celsius', f: 'Fahrenheit' };
  
  return (
    <div>
      <label>
        Temperature in {scaleNames[scale]}:
        <input value={temperature} onChange={handleChange} />
      </label>
    </div>
  );
}

function BoilingVerdict({ celsius }) {
  if (celsius >= 100) {
    return <p style={{color: 'red'}}>The water would boil! ๐Ÿ’ง๐Ÿ”ฅ</p>;
  }
  return <p style={{color: 'blue'}}>The water would not boil. โ„๏ธ</p>;
}

function Calculator() {
  const [temperature, setTemperature] = useState('');
  const [scale, setScale] = useState('c');
  
  const handleCelsiusChange = (temp) => {
    setScale('c');
    setTemperature(temp);
  };
  
  const handleFahrenheitChange = (temp) => {
    setScale('f');
    setTemperature(temp);
  };
  
  const celsius = scale === 'f' 
    ? ((temperature - 32) * 5 / 9).toFixed(2)
    : temperature;
  const fahrenheit = scale === 'c' 
    ? (temperature * 9 / 5 + 32).toFixed(2)
    : temperature;
  
  return (
    <div>
      <TemperatureInput 
        scale="c" 
        temperature={celsius} 
        onTemperatureChange={handleCelsiusChange} 
      />
      <TemperatureInput 
        scale="f" 
        temperature={fahrenheit} 
        onTemperatureChange={handleFahrenheitChange} 
      />
      <BoilingVerdict celsius={parseFloat(celsius)} />
    </div>
  );
}

Why this works:

  • State is lifted to Calculator (the common parent)
  • Both inputs stay synchronized through shared state
  • Conversion logic lives in the parent
  • Each child is a controlled component receiving value and callback props

Example 4: Form with Multiple Controlled Inputs

Managing complex form state:

function RegistrationForm() {
  const [formData, setFormData] = useState({
    username: '',
    email: '',
    password: '',
    subscribe: false
  });
  
  const [errors, setErrors] = useState({});
  
  const handleChange = (e) => {
    const { name, value, type, checked } = e.target;
    setFormData(prev => ({
      ...prev,
      [name]: type === 'checkbox' ? checked : value
    }));
  };
  
  const validate = () => {
    const newErrors = {};
    if (formData.username.length < 3) {
      newErrors.username = 'Username must be at least 3 characters';
    }
    if (!formData.email.includes('@')) {
      newErrors.email = 'Invalid email address';
    }
    if (formData.password.length < 6) {
      newErrors.password = 'Password must be at least 6 characters';
    }
    return newErrors;
  };
  
  const handleSubmit = (e) => {
    e.preventDefault();
    const newErrors = validate();
    if (Object.keys(newErrors).length === 0) {
      console.log('Form submitted:', formData);
    } else {
      setErrors(newErrors);
    }
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label>
          Username:
          <input 
            name="username"
            value={formData.username}
            onChange={handleChange}
          />
        </label>
        {errors.username && <span className="error">{errors.username}</span>}
      </div>
      
      <div>
        <label>
          Email:
          <input 
            name="email"
            type="email"
            value={formData.email}
            onChange={handleChange}
          />
        </label>
        {errors.email && <span className="error">{errors.email}</span>}
      </div>
      
      <div>
        <label>
          Password:
          <input 
            name="password"
            type="password"
            value={formData.password}
            onChange={handleChange}
          />
        </label>
        {errors.password && <span className="error">{errors.password}</span>}
      </div>
      
      <div>
        <label>
          <input 
            name="subscribe"
            type="checkbox"
            checked={formData.subscribe}
            onChange={handleChange}
          />
          Subscribe to newsletter
        </label>
      </div>
      
      <button type="submit">Register</button>
    </form>
  );
}

Advanced patterns:

  • Single state object for all form fields
  • Generic handleChange function using computed property names
  • Separate state for validation errors
  • Controlled checkbox (uses checked instead of value)

Common Mistakes โš ๏ธ

1. Mutating State Directly

โŒ Wrong:

const [items, setItems] = useState([1, 2, 3]);
items.push(4); // Mutates state directly!
setItems(items); // React won't detect the change

โœ… Right:

const [items, setItems] = useState([1, 2, 3]);
setItems([...items, 4]); // Creates new array

Why: React uses shallow comparison to detect changes. Mutating state directly changes the content but not the reference, so React won't re-render.

2. Stale State in Callbacks

โŒ Wrong:

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

const increment = () => {
  setCount(count + 1);
  setCount(count + 1); // Both use the same 'count' value!
};
// Clicking button increments by 1, not 2

โœ… Right:

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

const increment = () => {
  setCount(prev => prev + 1);
  setCount(prev => prev + 1);
};
// Now it increments by 2

Why: When you call the setter multiple times, use the functional form to access the most current state.

3. Modifying Props

โŒ Wrong:

function ChildComponent({ user }) {
  user.name = 'Modified'; // Never modify props!
  return <div>{user.name}</div>;
}

โœ… Right:

function ChildComponent({ user }) {
  const [localUser, setLocalUser] = useState(user);
  
  const handleChange = (newName) => {
    setLocalUser({ ...localUser, name: newName });
  };
  
  return <div>{localUser.name}</div>;
}

Why: Props are read-only. If you need to modify data, copy it to local state first.

4. Forgetting Keys in Lists

โŒ Wrong:

{users.map(user => (
  <UserCard user={user} /> // Missing key!
))}

โœ… Right:

{users.map(user => (
  <UserCard key={user.id} user={user} />
))}

Why: Keys help React identify which items changed, were added, or removed. Without keys, React may re-render unnecessarily or lose component state.

5. Using Index as Key

โŒ Wrong (when items can reorder):

{items.map((item, index) => (
  <Item key={index} data={item} /> // Breaks if items reorder
))}

โœ… Right:

{items.map(item => (
  <Item key={item.id} data={item} /> // Stable unique identifier
))}

Why: If items can be reordered, added, or deleted, using index as key can cause bugs. Use a stable unique identifier instead.

๐Ÿ”ง Try This: Build a Todo List

Apply what you've learned by building a todo list that demonstrates:

  • State management (array of todos)
  • Props passing (todo items to child components)
  • Controlled inputs (new todo text field)
  • Lifting state (if you add filtering functionality)

Start simple, then add features like:

  • Mark todos as complete
  • Delete todos
  • Filter by status (all/active/completed)
  • Edit existing todos

Key Takeaways ๐ŸŽฏ

  1. State is private, mutable data owned by a component; props are read-only data passed from parent to child
  2. State changes trigger re-renders; React compares references, not deep values
  3. Never mutate state directly - always use the setter function with a new reference
  4. Use functional updates (setPrev => prev + 1) when the new state depends on the previous state
  5. Lift state up to the nearest common ancestor when multiple components need to share data
  6. Props flow down, events flow up - this unidirectional data flow makes apps predictable
  7. Controlled components synchronize form inputs with React state; prefer them over uncontrolled components
  8. Always use stable, unique keys when rendering lists
  9. Props can be any JavaScript value, including functions (callbacks)
  10. Composition over inheritance - build complex UIs by combining simple components with props

๐Ÿ“‹ Quick Reference Card

useStateconst [value, setValue] = useState(initial)
Update statesetValue(newValue) or setValue(prev => newValue)
Pass props<Child prop={value} />
Receive propsfunction Child({ prop }) { }
Controlled inputvalue={state} onChange={e => setState(e.target.value)}
Lift stateMove state to common parent, pass down as props + callbacks
List keykey={item.id} (unique, stable identifier)
Update arraysetItems([...items, newItem])
Update objectsetUser({...user, name: 'New'})

๐Ÿ“š Further Study

  1. React Documentation - State and Lifecycle: https://react.dev/learn/state-a-components-memory
  2. React Documentation - Lifting State Up: https://react.dev/learn/sharing-state-between-components
  3. Kent C. Dodds - Application State Management with React: https://kentcdodds.com/blog/application-state-management-with-react