Component State Fundamentals

Understanding how state works and drives React applications

What is State in React?

State represents the data that can change over the lifetime of a component. It's essentially a component's memory, allowing it to track, respond to, and reflect changes in data as users interact with the application.

flowchart LR E[External Event] --> S[State Change] S --> R[Re-render Component] R --> U[Updated UI] U --> E

State is critical for creating dynamic, interactive applications because:

Analogy: State as a Component's Memory

Think of a component as a person and state as that person's memory:

  • Just like humans remember things (like names, faces, events), components need to remember data (like form inputs, toggle states, selected options)
  • When a person's memory changes (learns something new), their behavior might change. Similarly, when a component's state changes, it re-renders
  • People have private memories that others can't directly access. Similarly, a component's state is private to that component (unless explicitly shared)
  • When we forget something, we need someone to remind us. Similarly, when a component unmounts, its state is lost unless saved elsewhere

State vs. Props

Before diving deeper into state, it's important to understand the difference between state and props:

State Props
Internal to a component Passed from parent to child
Can be modified by the component Read-only (immutable)
Changes trigger re-renders New props trigger re-renders
Persists between re-renders Updated on each re-render of parent
Should contain minimal required data Can contain any type of data

Props vs. State Example

// Parent component passing props
function Parent() {
  // This is state in the Parent
  const [count, setCount] = useState(0);
  
  // Handler to update state
  const incrementCount = () => {
    setCount(count + 1);
  };
  
  return (
    <div>
      <h2>Parent Count: {count}</h2>
      <button onClick={incrementCount}>Increment</button>
      
      {/* Passing state as props to child */}
      <Child count={count} onIncrement={incrementCount} />
    </div>
  );
}

// Child receives props, but also has its own state
function Child({ count, onIncrement }) {
  // This is state internal to the Child
  const [childCount, setChildCount] = useState(0);
  
  const incrementChildCount = () => {
    setChildCount(childCount + 1);
  };
  
  return (
    <div className="child">
      <h3>Received from Parent: {count}</h3>
      <button onClick={onIncrement}>Increment Parent Count</button>
      
      <h3>Child's Own Count: {childCount}</h3>
      <button onClick={incrementChildCount}>Increment Child Count</button>
    </div>
  );
}

When to Use State vs. Props

  • Use State when:
    • Data changes over time within a component
    • User interactions should affect the displayed content
    • You need to maintain component-specific data
  • Use Props when:
    • Passing data down from parent to child
    • Configuring a component from outside
    • Sharing data between components

Types of State

State can be categorized in different ways based on its purpose and scope:

Local vs. Global State

UI State vs. Server State

Common Types of State by Function

graph TD A[Types of State] --> B[Local UI State] A --> C[Form State] A --> D[Navigation State] A --> E[Application State] A --> F[Server Cache State] B --> B1[Modal open/closed] B --> B2[Accordion expanded/collapsed] B --> B3[Loading indicators] C --> C1[Input values] C --> C2[Form validation] C --> C3[Submission status] D --> D1[Current route] D --> D2[Active tab] D --> D3[Breadcrumb history] E --> E1[User authentication] E --> E2[Theme preferences] E --> E3[Feature flags] F --> F1[API response cache] F --> F2[Query results] F --> F3[Data loading status]

Examples of Different State Types

// Local UI State
function Accordion() {
  const [isOpen, setIsOpen] = useState(false);
  
  return (
    <div>
      <h3 onClick={() => setIsOpen(!isOpen)}>
        Click to {isOpen ? 'collapse' : 'expand'}
      </h3>
      {isOpen && <p>This content is dynamically shown/hidden.</p>}
    </div>
  );
}

// Form State
function LoginForm() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const [errors, setErrors] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);
  
  // More form logic...
}

// Application State (often managed with Context or Redux)
function ThemeProvider() {
  const [theme, setTheme] = useState('light');
  
  const toggleTheme = () => {
    setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
  };
  
  // Provide theme context to the application...
}

// Server Cache State (often managed with React Query or SWR)
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    const fetchUser = async () => {
      try {
        setLoading(true);
        const response = await fetch(`/api/users/${userId}`);
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };
    
    fetchUser();
  }, [userId]);
  
  // Render based on loading, error, and user state...
}

State in Class Components

Before hooks were introduced in React 16.8, state was primarily managed in class components using this.state and this.setState().

Basic State in Class Components

import React, { Component } from 'react';

class Counter extends Component {
  constructor(props) {
    super(props);
    
    // Initialize state in the constructor
    this.state = {
      count: 0
    };
    
    // Bind event handlers
    this.increment = this.increment.bind(this);
    this.decrement = this.decrement.bind(this);
  }
  
  increment() {
    // Update state with setState
    this.setState({ count: this.state.count + 1 });
  }
  
  decrement() {
    this.setState({ count: this.state.count - 1 });
  }
  
  render() {
    return (
      <div>
        <h2>Count: {this.state.count}</h2>
        <button onClick={this.increment}>+</button>
        <button onClick={this.decrement}>-</button>
      </div>
    );
  }
}

Important Characteristics of Class Component State

Functional Updates with setState

When new state depends on previous state, a functional update form should be used:

// Incorrect: May lead to stale state issues
increment() {
  this.setState({ count: this.state.count + 1 });
}

// Correct: Using functional update
increment() {
  this.setState(prevState => ({
    count: prevState.count + 1
  }));
}

State Updates Are Merged

In class components, setState() performs a shallow merge of the provided object with the current state:

// Component with multiple state properties
class UserForm extends Component {
  constructor(props) {
    super(props);
    this.state = {
      name: '',
      email: '',
      preferences: {
        newsletter: false,
        darkMode: false
      }
    };
  }
  
  updateName(name) {
    // Only updates name, other state properties are preserved
    this.setState({ name });
  }
  
  toggleNewsletter() {
    // Nested objects aren't deeply merged - must include the entire nested object
    this.setState({
      preferences: {
        ...this.state.preferences,
        newsletter: !this.state.preferences.newsletter
      }
    });
  }
}

State in Functional Components with useState

In modern React, functional components use the useState hook to manage state. This approach is more concise and allows for better organization of state logic.

Basic useState Example

import React, { useState } from 'react';

function Counter() {
  // useState returns a state value and a function to update it
  const [count, setCount] = useState(0);
  
  const increment = () => {
    setCount(count + 1);
  };
  
  const decrement = () => {
    setCount(count - 1);
  };
  
  return (
    <div>
      <h2>Count: {count}</h2>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  );
}

Multiple State Variables

Unlike class components where state is a single object, useState allows for separate state variables:

function UserForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [subscribed, setSubscribed] = useState(false);
  
  return (
    <form>
      <div>
        <label>Name:</label>
        <input 
          type="text" 
          value={name} 
          onChange={e => setName(e.target.value)} 
        />
      </div>
      
      <div>
        <label>Email:</label>
        <input 
          type="email" 
          value={email} 
          onChange={e => setEmail(e.target.value)} 
        />
      </div>
      
      <div>
        <label>
          <input 
            type="checkbox" 
            checked={subscribed} 
            onChange={e => setSubscribed(e.target.checked)} 
          />
          Subscribe to newsletter
        </label>
      </div>
    </form>
  );
}

Using Objects with useState

For related state, you can still use objects with useState, but you need to manage updates differently:

function UserProfile() {
  const [user, setUser] = useState({
    name: '',
    email: '',
    preferences: {
      newsletter: false,
      darkMode: false
    }
  });
  
  const updateName = (name) => {
    // Must include all properties since useState doesn't merge
    setUser({
      ...user,  // spread the existing state
      name      // update the name property
    });
  };
  
  const toggleNewsletter = () => {
    // Nested objects require deeper spreading
    setUser({
      ...user,
      preferences: {
        ...user.preferences,
        newsletter: !user.preferences.newsletter
      }
    });
  };
}

Best Practices for useState

  • Single Responsibility: Each state variable should ideally manage one aspect of state
  • Group Related State: Use objects for closely related state, but consider splitting unrelated state
  • Use Multiple useState Hooks: Don't be afraid to use many useState calls if it makes the code clearer
  • Use Functional Updates: For state that depends on previous state, use the functional form
  • Prefer Local State: Keep state as close as possible to where it's used

Functional Updates with useState

Just like with class components, when new state depends on the previous state, you should use the functional update form of the state setter:

Using Functional Updates

function Counter() {
  const [count, setCount] = useState(0);
  
  // Incorrect: May not use latest state due to closures
  const increment = () => {
    setCount(count + 1);  // Uses value from render closure
  };
  
  // Correct: Always uses latest state
  const betterIncrement = () => {
    setCount(prevCount => prevCount + 1);
  };
  
  // This is particularly important with multiple updates
  const incrementThree = () => {
    // This might not increment by 3 because all updates
    // reference the same closure value of count
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  };
  
  // This will correctly increment by 3
  const betterIncrementThree = () => {
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
    setCount(prev => prev + 1);
  };
  
  return (
    <div>
      <h2>Count: {count}</h2>
      <button onClick={betterIncrement}>+1</button>
      <button onClick={betterIncrementThree}>+3</button>
    </div>
  );
}

Functional Updates with Object State

When working with object state, functional updates can be especially useful:

function TaskManager() {
  const [tasks, setTasks] = useState([]);
  
  const addTask = (newTask) => {
    // Using functional update ensures we always have the latest state
    setTasks(prevTasks => [...prevTasks, newTask]);
  };
  
  const removeTask = (taskId) => {
    setTasks(prevTasks => prevTasks.filter(task => task.id !== taskId));
  };
  
  const updateTask = (taskId, updates) => {
    setTasks(prevTasks => 
      prevTasks.map(task => 
        task.id === taskId ? { ...task, ...updates } : task
      )
    );
  };
}

Real-World Example: Counter with Delay

Here's a practical example showing why functional updates are important:

function DelayedCounter() {
  const [count, setCount] = useState(0);
  
  // Problem: This might use stale state
  const delayedIncrement = () => {
    setTimeout(() => {
      setCount(count + 1);  // This uses the 'count' from when the function was created
    }, 2000);
  };
  
  // Solution: Use functional update
  const betterDelayedIncrement = () => {
    setTimeout(() => {
      setCount(prevCount => prevCount + 1);  // This uses the latest state
    }, 2000);
  };
  
  return (
    <div>
      <h2>Count: {count}</h2>
      <button onClick={betterDelayedIncrement}>
        Increment after 2 seconds
      </button>
    </div>
  );
}

If you click the button multiple times in rapid succession, the functional update ensures each click is properly counted, whereas the non-functional version might miss updates.

Lazy Initialization of State

Both useState and class component state can use lazy initialization for expensive initial state calculations.

Lazy State Initialization

// Without lazy initialization
function UserPreferences() {
  // This runs on every render
  const [preferences, setPreferences] = useState(
    JSON.parse(localStorage.getItem('preferences')) || { 
      theme: 'light', 
      fontSize: 'medium' 
    }
  );
  
  // ...
}

// With lazy initialization
function BetterUserPreferences() {
  // The function only runs once during initial render
  const [preferences, setPreferences] = useState(() => {
    // Expensive operation only happens once
    const savedPrefs = localStorage.getItem('preferences');
    return savedPrefs 
      ? JSON.parse(savedPrefs) 
      : { theme: 'light', fontSize: 'medium' };
  });
  
  // ...
}

When to Use Lazy Initialization

Use the lazy initialization pattern when:

  • The initial state requires expensive computation
  • The initial state is derived from localStorage or other storage
  • The initial state requires complex transformations
  • You want to avoid running the initialization logic on every render

State vs. Derived Values

Not everything needs to be in state. Values that can be calculated from existing state or props should not be duplicated in state.

Using Derived Values

// Bad: Redundant state
function CartWithRedundantState() {
  const [items, setItems] = useState([
    { id: 1, name: 'Product 1', price: 10, quantity: 2 },
    { id: 2, name: 'Product 2', price: 15, quantity: 1 }
  ]);
  const [itemCount, setItemCount] = useState(3);  // Total quantity
  const [totalPrice, setTotalPrice] = useState(35);  // Total price
  
  const addItem = (item) => {
    setItems([...items, item]);
    // Now we need to remember to update these derived values
    setItemCount(itemCount + item.quantity);
    setTotalPrice(totalPrice + (item.price * item.quantity));
  };
  
  // This approach is error-prone and can lead to inconsistencies
}

// Good: Calculate derived values when needed
function CartWithDerivedValues() {
  const [items, setItems] = useState([
    { id: 1, name: 'Product 1', price: 10, quantity: 2 },
    { id: 2, name: 'Product 2', price: 15, quantity: 1 }
  ]);
  
  // Calculate these values from the primary state
  const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
  const totalPrice = items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
  
  const addItem = (item) => {
    setItems([...items, item]);
    // No need to update derived values; they're recalculated on render
  };
}

Optimizing Expensive Derived Calculations

If calculating derived values is expensive, you can use memoization with useMemo:

import { useState, useMemo } from 'react';

function ExpensiveCalculationExample() {
  const [numbers, setNumbers] = useState([1, 2, 3, 4, 5]);
  
  // This calculation will only re-run if the numbers array changes
  const sum = useMemo(() => {
    console.log('Calculating sum...');
    return numbers.reduce((acc, num) => acc + num, 0);
  }, [numbers]);
  
  // This will re-calculate on every render (inefficient for expensive operations)
  // const sum = numbers.reduce((acc, num) => acc + num, 0);
  
  return (
    <div>
      <p>Sum: {sum}</p>
      <button
        onClick={() => setNumbers([...numbers, Math.floor(Math.random() * 100)])}
      >
        Add Random Number
      </button>
    </div>
  );
}

When to Use State vs. Derived Values

State Management Patterns

As applications grow, managing state becomes more complex. Here are some common patterns for state management in React applications:

Lifting State Up

When multiple components need access to the same state, lift it to their closest common ancestor:

function ParentWithSharedState() {
  // State is lifted to the parent component
  const [count, setCount] = useState(0);
  
  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);
  
  return (
    <div>
      <h2>Count: {count}</h2>
      
      {/* Both children work with the same state */}
      <ChildA 
        count={count} 
        onIncrement={increment} 
      />
      
      <ChildB 
        count={count} 
        onDecrement={decrement} 
      />
    </div>
  );
}

State Management Libraries

For larger applications, state management libraries like Redux, MobX, or Recoil can help manage complex state:

Approach Pros Cons Best For
Component State Simple, built-in, easy to learn Can lead to prop drilling, hard to share state Small apps, component-specific state
Context API Built-in, no dependencies, good for themes/auth Performance concerns with frequent updates Medium apps, infrequently changing state
Redux Predictable, great dev tools, well-established Boilerplate, steep learning curve Large apps, complex state logic
MobX Less boilerplate, automatic tracking More "magic", less explicit data flow Apps with complex domains, OOP fans
Recoil Atomic model, great for code splitting Newer, less mature ecosystem Apps needing fine-grained reactivity
Zustand Simple API, minimal boilerplate Less established than Redux Apps wanting Redux without the boilerplate

Real-World Example: Todo App State Management

Let's see how component state can be structured for a small todo application:

function TodoApp() {
  // Main state for the todo list
  const [todos, setTodos] = useState([]);
  
  // UI state for the new todo input
  const [newTodoText, setNewTodoText] = useState('');
  
  // UI state for filtering todos
  const [filter, setFilter] = useState('all'); // 'all', 'active', 'completed'
  
  // Derived value - filtered todos
  const filteredTodos = todos.filter(todo => {
    if (filter === 'active') return !todo.completed;
    if (filter === 'completed') return todo.completed;
    return true; // 'all' filter
  });
  
  // Add new todo
  const addTodo = () => {
    if (!newTodoText.trim()) return;
    
    const newTodo = {
      id: Date.now(),
      text: newTodoText,
      completed: false
    };
    
    setTodos([...todos, newTodo]);
    setNewTodoText(''); // Reset input
  };
  
  // Toggle todo completed status
  const toggleTodo = (id) => {
    setTodos(prevTodos => 
      prevTodos.map(todo => 
        todo.id === id ? { ...todo, completed: !todo.completed } : todo
      )
    );
  };
  
  // Delete todo
  const deleteTodo = (id) => {
    setTodos(prevTodos => prevTodos.filter(todo => todo.id !== id));
  };
  
  // Clear all completed todos
  const clearCompleted = () => {
    setTodos(prevTodos => prevTodos.filter(todo => !todo.completed));
  };
  
  return (
    <div className="todo-app">
      {/* Input form */}
      <form onSubmit={(e) => { e.preventDefault(); addTodo(); }}>
        <input
          type="text"
          value={newTodoText}
          onChange={(e) => setNewTodoText(e.target.value)}
          placeholder="What needs to be done?"
        />
        <button type="submit">Add</button>
      </form>
      
      {/* Todo list */}
      <ul className="todo-list">
        {filteredTodos.map(todo => (
          <li key={todo.id}>
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={() => toggleTodo(todo.id)}
            />
            <span style={{ 
              textDecoration: todo.completed ? 'line-through' : 'none' 
            }}>
              {todo.text}
            </span>
            <button onClick={() => deleteTodo(todo.id)}>×</button>
          </li>
        ))}
      </ul>
      
      {/* Filter controls */}
      <div className="filters">
        <button 
          className={filter === 'all' ? 'active' : ''}
          onClick={() => setFilter('all')}
        >
          All
        </button>
        <button 
          className={filter === 'active' ? 'active' : ''}
          onClick={() => setFilter('active')}
        >
          Active
        </button>
        <button 
          className={filter === 'completed' ? 'active' : ''}
          onClick={() => setFilter('completed')}
        >
          Completed
        </button>
        
        {todos.some(todo => todo.completed) && (
          <button onClick={clearCompleted}>Clear completed</button>
        )}
      </div>
      
      {/* Todo count */}
      <div className="todo-count">
        {todos.filter(todo => !todo.completed).length} items left
      </div>
    </div>
  );
}

For larger applications, you might split this into multiple components and use context or a state management library to share the todo state.

Practice Activities

Activity 1: Counter Application

Objective: Create a counter component with various features to practice state fundamentals.

Instructions:

  1. Create a counter component with increment and decrement buttons
  2. Add a reset button that returns the counter to its initial value
  3. Implement a "step" input that controls how much the counter changes with each click
  4. Add buttons for "Increment after 2 seconds" that uses functional updates
  5. Create a "count history" feature that tracks the last 5 values of the counter

Activity 2: Form State Management

Objective: Practice managing form state with useState.

Instructions:

  1. Create a sign-up form with the following fields:
    • Name (text input)
    • Email (email input)
    • Password (password input)
    • Confirm Password (password input)
    • Role (select dropdown with options: Developer, Designer, Manager)
    • Notifications (checkbox)
  2. Implement form validation:
    • All fields except notifications are required
    • Email must be a valid format
    • Password must be at least 8 characters
    • Confirm password must match password
  3. Display validation errors below each field
  4. Show a summary of the form data when submitted successfully

Activity 3: State Refactoring

Objective: Refactor inefficient state management in an existing component.

Instructions:

  1. Refactor the following component to use proper state management:
function ProductList() {
  // Original product data
  const [products, setProducts] = useState([
    { id: 1, name: 'Laptop', price: 999.99, inStock: true },
    { id: 2, name: 'Phone', price: 699.99, inStock: true },
    { id: 3, name: 'Tablet', price: 399.99, inStock: false }
  ]);

  // Redundant state - these should be derived values
  const [inStockProducts, setInStockProducts] = useState([]);
  const [totalProducts, setTotalProducts] = useState(0);
  const [totalValue, setTotalValue] = useState(0);
  
  // Update derived state whenever products change
  useEffect(() => {
    const productsInStock = products.filter(p => p.inStock);
    setInStockProducts(productsInStock);
    setTotalProducts(products.length);
    setTotalValue(products.reduce((sum, p) => sum + p.price, 0));
  }, [products]);
  
  // Toggles the stock status but has to manually update multiple state values
  const toggleStock = (id) => {
    const newProducts = products.map(p => 
      p.id === id ? { ...p, inStock: !p.inStock } : p
    );
    setProducts(newProducts);
    
    // Don't need these if using derived values
    const productsInStock = newProducts.filter(p => p.inStock);
    setInStockProducts(productsInStock);
    setTotalProducts(newProducts.length);
    setTotalValue(newProducts.reduce((sum, p) => sum + p.price, 0));
  };
  
  return (
    <div>
      <h2>Product List ({totalProducts} products, ${totalValue.toFixed(2)} total)</h2>
      <ul>
        {products.map(product => (
          <li key={product.id}>
            {product.name} - ${product.price.toFixed(2)}
            <button onClick={() => toggleStock(product.id)}>
              {product.inStock ? 'Mark Out of Stock' : 'Mark In Stock'}
            </button>
          </li>
        ))}
      </ul>
      <h3>In Stock Products ({inStockProducts.length})</h3>
      <ul>
        {inStockProducts.map(product => (
          <li key={product.id}>
            {product.name} - ${product.price.toFixed(2)}
          </li>
        ))}
      </ul>
    </div>
  );
}

Resources for Further Learning

Official Documentation:

Articles and Tutorials:

Summary

In the next lecture, we'll explore the useState hook in more depth, including advanced patterns and best practices.