Week 9: React State & Interactivity

Building Dynamic Applications with useState and Forms

INFO 153A/253A - Front-End Web Architecture

UC Berkeley School of Information

October 20, 2025

Part 1: Prep Work Recap

Understanding React State

From Your Prep: What is State?

"State is basically just data" - two types exist in React applications

  • State is data that changes over time: Unlike props which are read-only, state can be modified
  • Component-level state: Data specific to one component that no others need
  • App-level (global) state: Data shared across multiple components
  • State drives UI updates: When state changes, React automatically re-renders affected components
  • State is immutable: You never modify state directly - always use setter functions
  • React tracks state changes: This enables the declarative programming model we've been learning

Component vs App-Level State

Component-Level State

  • Navigation open/closed
  • Form input values
  • Modal visibility
  • Local UI toggles

App-Level State

  • List of tasks/items
  • User authentication
  • Shopping cart data
  • Shared filters
  • Component-level state stays local: No other component needs to know about it
  • Example from prep: isOpen for a navigation menu - only the nav component cares
  • App-level state must be shared: Multiple components need access to the same data
  • Example from prep: feedback items needed by both list and form components
  • Start local, lift when needed: Begin with component state, promote to app-level only when multiple components need it
  • Different management strategies: Component state uses useState, app state might use Context API (Week 10)

The useState Hook Introduction

import { useState } from 'react';

function FeedbackItem() {
  // Prep work example: Setting up state
  const [rating, setRating] = useState(7);
  const [text, setText] = useState('This is an example of a feedback item');

  return (
    <div className="card">
      <div className="num-display">{rating}</div>
      <div className="text-display">{text}</div>
    </div>
  );
}
  • useState is React's most essential hook: Hooks always start with "use" - this is the most common
  • Array destructuring pattern: Returns [currentValue, setterFunction]
  • First element is the state value: What you'll display or use in your component
  • Second element is the setter: Function to update that state value
  • Naming convention: [value, setValue] or [rating, setRating] - be descriptive!
  • Default value in parentheses: The initial state when component first renders

Updating State from Prep

import { useState } from 'react';

function FeedbackItem() {
  const [rating, setRating] = useState(7);

  function handleClick() {
    setRating(10);  // Direct value
  }

  // Temporary button from prep work
  return (
    <div className="card">
      <div className="num-display">{rating}</div>
      <button onClick={handleClick}>Change Rating</button>
    </div>
  );
}
  • Call the setter to update state: setRating(10) changes rating to 10
  • NEVER modify state directly: rating = 10 will NOT work and breaks React
  • State is immutable: It must be reset using the setter function, not reassigned
  • React re-renders on state change: When you call setRating, component function runs again with new value
  • Event handlers trigger updates: onClick calls handleClick which calls setRating
  • UI updates automatically: You don't touch the DOM - React handles it

Functional State Updates

import { useState } from 'react';

function FeedbackItem() {
  const [rating, setRating] = useState(7);

  function handleClick() {
    // Pass function instead of value
    setRating((prevRating) => {
      console.log('Previous:', prevRating); // 7, 8, 9...
      return prevRating + 1;
    });
  }

  return (
    <div className="card">
      <div className="num-display">{rating}</div>
      <button onClick={handleClick}>Increment</button>
    </div>
  );
}
  • Two ways to set state: Direct value or updater function
  • Updater function gets previous value: Param is the current state before update
  • Return the new value: Whatever you return becomes the new state
  • When to use functional updates: When new state depends on previous state
  • Safer for async updates: Guarantees you're working with latest state
  • Common pattern for counters: Increment, decrement, toggle operations

Form State from Prep Work

import { useState } from 'react';

function FeedbackForm() {
  const [text, setText] = useState('');

  function handleTextChange(e) {
    setText(e.target.value);
  }

  return (
    <form>
      <input
        type="text"
        placeholder="Write a review"
        value={text}
        onChange={handleTextChange}
      />
      <button type="submit">Send</button>
    </form>
  );
}
  • Forms need state for each input: Text input gets its own state variable
  • onChange event fires on every keystroke: Updates state with current input value
  • value attribute connects to state: Input displays whatever's in state
  • e.target.value is the typed text: Event object gives us what user entered
  • This is a "controlled component": React state controls the input's value
  • Real-time state updates: State changes with every character typed

Part 2: useState Deep Dive

Mastering React's Core Hook

useState is the Heart of React

Master this hook, and you unlock interactive React applications

  • useState enables component memory: Components remember values between renders
  • Without state, components are stateless: They just display props and never change
  • State makes components alive: They can respond to user actions
  • Each component instance has own state: Two TaskItem components have independent states
  • State persists across renders: When component re-renders, state values are preserved
  • Professional React code is state management: 80% of your work is deciding what state you need and where it lives

State with Different Data Types

import { useState } from 'react';

function TaskManager() {
  // String state
  const [taskText, setTaskText] = useState('');

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

  // Boolean state
  const [isComplete, setIsComplete] = useState(false);

  // Array state
  const [tasks, setTasks] = useState([]);

  // Object state
  const [user, setUser] = useState({ name: '', email: '' });

  return <div>State can hold any JavaScript value!</div>;
}
  • useState works with any data type: Strings, numbers, booleans, arrays, objects
  • Start with appropriate default: Empty string '', 0, false, [], {}
  • Arrays and objects are common: Most app-level state is arrays of objects
  • Type consistency matters: Don't initialize as string then set to number
  • Complex state needs careful updates: Arrays and objects require special handling (coming up!)
  • Choose the right data structure: If you need a list, use array. If you need key-value, use object

Updating Array State

import { useState } from 'react';

function ShoppingCart() {
  const [items, setItems] = useState([
    { id: 1, name: 'Laptop', quantity: 1 },
    { id: 2, name: 'Mouse', quantity: 2 }
  ]);

  function addItem(name) {
    const newItem = { id: Date.now(), name, quantity: 1 };
    setItems([...items, newItem]); // Spread existing, add new
  }

  function removeItem(id) {
    setItems(items.filter(item => item.id !== id)); // Filter out
  }

  function updateQuantity(id, newQuantity) {
    setItems(items.map(item =>
      item.id === id ? { ...item, quantity: newQuantity } : item
    )); // Map to new array with updates
  }

  return <div>/* Render cart */</div>;
}
  • Never mutate state arrays directly: items.push() breaks React - must create new array
  • Spread operator copies array: [...items, newItem] creates new array with all items plus one
  • filter() for deletion: Returns new array without the deleted item
  • map() for updates: Transform array, updating specific items
  • Spread objects when updating: {...item, quantity: newQuantity} preserves other properties
  • These patterns are essential: 90% of React state updates use spread, filter, or map

Updating Object State

import { useState } from 'react';

function UserProfile() {
  const [user, setUser] = useState({
    name: '',
    email: '',
    age: 0,
    preferences: { theme: 'light', notifications: true }
  });

  function updateName(newName) {
    setUser({ ...user, name: newName });
    // Spread copies all properties, then overwrite name
  }

  function updateEmail(newEmail) {
    setUser(prevUser => ({ ...prevUser, email: newEmail }));
    // Functional update with spread
  }

  function toggleTheme() {
    setUser({
      ...user,
      preferences: {
        ...user.preferences,  // Nested spread!
        theme: user.preferences.theme === 'light' ? 'dark' : 'light'
      }
    });
  }

  return <div>/* Profile UI */</div>;
}
  • Never mutate state objects: user.name = 'Bob' doesn't trigger re-render
  • Spread operator copies object: {...user, name: newName} creates new object
  • Spread preserves other properties: Only the specified property changes
  • Nested objects need nested spreads: Spread at each level to properly copy
  • Order matters in spread: Later properties override earlier ones
  • Consider flattening deep objects: Too many nested spreads indicate restructuring needed

Multiple State Variables vs Single Object

Multiple useState Calls

import { useState } from 'react';

const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [age, setAge] = useState(0);

// Easy to update individually
setName('Alice');
setEmail('alice@example.com');

Single Object State

import { useState } from 'react';

const [form, setForm] = useState({
  name: '', email: '', age: 0
});

// Need spread for updates
setForm({ ...form, name: 'Alice' });
setForm({ ...form, email: 'alice@example.com' });
  • No single right answer: Both patterns are valid depending on use case
  • Multiple useState for independent values: If values don't change together, separate them
  • Object state for related values: If values represent one concept, group them
  • Multiple useState is simpler: No spread operator needed for updates
  • Object state for forms: Easier to pass entire form data as single unit
  • Professional pattern: Use multiple useState unless there's a reason to group

State Update Gotchas

Common Mistakes

// ❌ DON'T: Direct mutation
tasks.push(newTask);
setTasks(tasks); // React doesn't see the change!

// ❌ DON'T: Forget to spread
setUser({ name: 'Bob' }); // Loses email, age, preferences!

// ✅ DO: Create new array
setTasks([...tasks, newTask]);

// ✅ DO: Spread existing properties
setUser({ ...user, name: 'Bob' });
  • React compares by reference: Same array/object reference = no re-render
  • Must create new reference: Spread operator creates new array/object
  • Forgetting spread loses data: Object update without spread deletes other properties
  • Array methods that mutate are forbidden: push, pop, splice, sort - don't use directly
  • Array methods that return new array are safe: map, filter, concat, slice
  • When in doubt, spread: It's the safest way to update arrays and objects

Part 3: App-Level State Management

Sharing State Across Components

State Lives in the Lowest Common Ancestor

If multiple components need the same data, lift state to their parent

  • Single source of truth: State should live in exactly one place
  • Lift state up: Move state to parent component when multiple children need it
  • Pass state down as props: Parent passes data to children via props
  • Pass setters down as props: Children call parent's setter to update shared state
  • Data flows down, events bubble up: Props go down, callbacks go down (but execute updates above)
  • This is "lifting state up" pattern: One of React's core patterns for sharing state

Lifting State Up Example

import { useState } from 'react';

// ❌ WRONG: State in child - can't share
function TaskList() {
  const [tasks, setTasks] = useState([]);  // Only TaskList can access
  return <div>/* render tasks */</div>;
}

function TaskForm() {
  // How do I add to tasks? Can't access TaskList's state!
}

// ✅ CORRECT: State in parent - shared via props
function App() {
  const [tasks, setTasks] = useState([]);  // Single source of truth

  return (
    <>
      <TaskList tasks={tasks} />
      <TaskForm tasks={tasks} setTasks={setTasks} />
    </>
  );
}
  • Identify shared data: If two components need same data, it's app-level state
  • Find common parent: Lowest component that contains all components needing the data
  • Define state in parent: Parent becomes single source of truth
  • Pass as props: Children receive state as read-only props
  • Pass setters for updates: Children can modify state by calling parent's setter
  • Component tree determines state location: Architectural decision based on data flow needs

Passing State and Setters

import { useState } from 'react';

function App() {
  const [tasks, setTasks] = useState([]);

  function addTask(text) {
    const newTask = { id: Date.now(), text, complete: false };
    setTasks([...tasks, newTask]);
  }

  function deleteTask(id) {
    setTasks(tasks.filter(task => task.id !== id));
  }

  return (
    <div className="app">
      <TaskForm onAddTask={addTask} />
      <TaskList tasks={tasks} onDeleteTask={deleteTask} />
    </div>
  );
}

function TaskForm({ onAddTask }) {
  const [text, setText] = useState('');

  function handleSubmit(e) {
    e.preventDefault();
    onAddTask(text);  // Call parent's addTask function
    setText('');
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button>Add</button>
    </form>
  );
}
  • Define functions in parent: addTask and deleteTask are in App component
  • Functions have access to state: Can read and update tasks through closure
  • Pass functions as props: onAddTask={addTask} gives child access to parent function
  • Children call parent functions: onAddTask(text) updates parent's state
  • Naming convention: Use "on" prefix for callback props (onAddTask, onDelete, onChange)
  • Form has own local state: text is component-level, tasks is app-level

State Architecture for Task Manager

import { useState } from 'react';

function App() {
  // App-level state - shared across components
  const [tasks, setTasks] = useState([]);
  const [filter, setFilter] = useState('all'); // all, active, completed

  // Derived state - computed from existing state
  const filteredTasks = tasks.filter(task => {
    if (filter === 'active') return !task.complete;
    if (filter === 'completed') return task.complete;
    return true; // 'all'
  });

  const taskCount = filteredTasks.length;

  return (
    <>
      <TaskFilter filter={filter} setFilter={setFilter} />
      <TaskList tasks={filteredTasks} />
      <TaskCounter count={taskCount} />
    </>
  );
}
  • tasks is app-level state: Multiple components need this data
  • filter is also app-level: Affects what TaskList displays
  • filteredTasks is derived state: Computed from tasks and filter - NOT stored in useState
  • Don't store what you can calculate: Derived state recomputes on each render automatically
  • taskCount is derived too: Just filteredTasks.length - no need for separate state
  • Minimal state principle: Store only the essential data, derive everything else

When to Lift State Up

Lift State When:

  • Multiple components need the same data
  • Components need to stay in sync
  • Parent needs to know child's state

Keep State Local When:

  • Only one component needs the data
  • State is temporary (like form input)
  • UI-only state (modal open/closed)
  • Start local, lift only when needed: Don't prematurely lift state
  • Refactor as requirements change: It's okay to move state location later
  • Each lift increases complexity: More props to pass, more code to maintain
  • Too much lifting is "prop drilling": Passing props through many layers (Context API solves this - Week 10)
  • Best practice: tasks and filter in App, form input values stay in TaskForm

Part 4: Forms in React

Controlled Components Pattern

React State Controls the Form

In controlled components, React state is the single source of truth for input values

  • HTML forms have built-in state: Browser remembers input values
  • React needs to control that state: To integrate with React's state management
  • Controlled component pattern: React state drives input value, onChange updates state
  • Two-way binding: State → input value, user input → state (via onChange)
  • Benefits of control: Easy validation, conditional rendering, dynamic forms
  • Industry standard: 99% of React forms use controlled components

Controlled Input Pattern

import { useState } from 'react';

function TaskForm() {
  const [text, setText] = useState('');

  function handleChange(e) {
    setText(e.target.value);
  }

  function handleSubmit(e) {
    e.preventDefault();
    console.log('Submitted:', text);
    setText(''); // Clear after submit
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={text}           // Controlled by state
        onChange={handleChange} // Updates state on every keystroke
        placeholder="Enter task"
      />
      <button type="submit">Add Task</button>
    </form>
  );
}
  • value attribute connects input to state: Input always displays what's in state
  • onChange updates state on every keystroke: Keeps state synchronized with input
  • e.target.value is current input text: What user typed into the field
  • State is single source of truth: Want to know input value? Check state!
  • Can programmatically set value: setText('') clears input from code
  • preventDefault on form submit: Stops page reload, the default form behavior

Multiple Form Inputs

import { useState } from 'react';

function SignupForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    password: ''
  });

  function handleChange(e) {
    setFormData({
      ...formData,
      [e.target.name]: e.target.value  // Computed property name
    });
  }

  function handleSubmit(e) {
    e.preventDefault();
    console.log('Form data:', formData);
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" value={formData.name} onChange={handleChange} />
      <input name="email" type="email" value={formData.email} onChange={handleChange} />
      <input name="password" type="password" value={formData.password} onChange={handleChange} />
      <button type="submit">Sign Up</button>
    </form>
  );
}
  • Single state object for all inputs: formData holds all field values
  • Single onChange handler: One function handles all inputs
  • name attribute identifies field: e.target.name tells us which input changed
  • Computed property syntax: [e.target.name] uses variable as object key
  • Spread preserves other fields: Only updates the changed field
  • Scalable pattern: Easy to add more fields without writing more handlers

Form Validation Example

import { useState } from 'react';

function TaskForm() {
  const [text, setText] = useState('');
  const [error, setError] = useState('');

  function handleChange(e) {
    const value = e.target.value;
    setText(value);

    // Real-time validation
    if (value.length < 10) {
      setError('Task must be at least 10 characters');
    } else {
      setError('');
    }
  }

  function handleSubmit(e) {
    e.preventDefault();
    if (text.length < 10) {
      setError('Task must be at least 10 characters');
      return;
    }
    // Submit valid task
    console.log('Valid task:', text);
    setText('');
    setError('');
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={text} onChange={handleChange} />
      {error && <p style={{ color: 'red' }}>{error}</p>}
      <button type="submit" disabled={text.length < 10}>Add Task</button>
    </form>
  );
}
  • Validation happens in onChange: Check input as user types
  • Error state holds validation message: Separate state for error text
  • Conditional rendering of error: {error &&

    } shows error only when it exists

  • Button disabled based on validation: disabled={text.length < 10} prevents invalid submission
  • Validate on submit too: Always validate on submit as backup
  • This is controlled component power: Easy validation, conditional UI, all synchronized with state

Checkbox and Select Inputs

import { useState } from 'react';

function TaskForm() {
  const [task, setTask] = useState({
    text: '',
    priority: 'medium',
    urgent: false
  });

  function handleTextChange(e) {
    setTask({ ...task, text: e.target.value });
  }

  function handlePriorityChange(e) {
    setTask({ ...task, priority: e.target.value });
  }

  function handleUrgentChange(e) {
    setTask({ ...task, urgent: e.target.checked }); // Note: checked not value
  }

  return (
    <form>
      <input type="text" value={task.text} onChange={handleTextChange} />

      <select value={task.priority} onChange={handlePriorityChange}>
        <option value="low">Low</option>
        <option value="medium">Medium</option>
        <option value="high">High</option>
      </select>

      <label>
        <input
          type="checkbox"
          checked={task.urgent}  // Note: checked not value
          onChange={handleUrgentChange}
        />
        Urgent
      </label>
    </form>
  );
}
  • Text inputs use value attribute: What we've been using
  • Checkboxes use checked attribute: Boolean true/false, not string value
  • e.target.checked for checkboxes: Not e.target.value - different property!
  • Select works like text input: value attribute controls selected option
  • Radio buttons use checked too: Same pattern as checkboxes
  • All follow controlled pattern: State drives UI, onChange updates state

Form Submission Best Practices

import { useState } from 'react';

function TaskForm({ onAddTask }) {
  const [text, setText] = useState('');
  const [isSubmitting, setIsSubmitting] = useState(false);

  async function handleSubmit(e) {
    e.preventDefault();

    // Validation
    if (!text.trim()) return;

    // Disable form during submission
    setIsSubmitting(true);

    try {
      // Call parent's callback
      await onAddTask(text);
      setText(''); // Clear on success
    } catch (error) {
      console.error('Failed to add task:', error);
    } finally {
      setIsSubmitting(false); // Re-enable form
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={text}
        onChange={e => setText(e.target.value)}
        disabled={isSubmitting}
      />
      <button type="submit" disabled={isSubmitting || !text.trim()}>
        {isSubmitting ? 'Adding...' : 'Add Task'}
      </button>
    </form>
  );
}
  • Always preventDefault: Stops page reload on form submission
  • Validate before submission: Check text isn't empty (trim removes whitespace)
  • Loading state during submission: isSubmitting prevents double-submits
  • Disable inputs while submitting: Better UX, prevents confusing states
  • Clear form on success: setText('') after successful add
  • Try/catch for error handling: Handle failures gracefully
  • Finally re-enables form: Whether success or failure, form becomes usable again

Part 5: State Management Best Practices

Professional Patterns

State is the Heart of Your Application

Good state management makes your app maintainable and scalable

  • State determines UI: All visual changes come from state updates
  • Minimal state principle: Store only what you can't calculate
  • Single source of truth: Each piece of data should live in exactly one place
  • Lift state carefully: Don't over-lift, but don't duplicate either
  • Normalize complex state: Arrays of objects with IDs, like databases
  • These patterns scale: Same principles apply to small apps and large enterprise systems

Common State Anti-Patterns

Anti-Pattern 1: Duplicating State

import { useState } from 'react';

// ❌ BAD: tasks and taskCount separate
const [tasks, setTasks] = useState([]);
const [taskCount, setTaskCount] = useState(0);

function addTask(task) {
  setTasks([...tasks, task]);
  setTaskCount(taskCount + 1); // Manual sync - can get out of sync!
}

// ✅ GOOD: Derive count from tasks
const [tasks, setTasks] = useState([]);
const taskCount = tasks.length; // Always accurate!
  • Don't store derived data in state: Calculate it instead
  • Duplication causes sync bugs: Two sources of truth can disagree
  • Derivation is cheap: Calculating tasks.length is instant
  • If you can calculate it, don't store it: Rule of thumb for minimal state
  • Examples of derived state: length, filtered lists, sorted lists, totals

State Update Batching

import { useState } from 'react';

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

  function handleClick() {
    // React 18: These are batched automatically
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
    console.log(count); // Still 0! State updates are asynchronous
  }

  // After click, count is 1, not 3!
  // All three setCount calls use same stale count (0)

  // ✅ CORRECT: Use functional updates
  function handleClickCorrect() {
    setCount(c => c + 1);
    setCount(c => c + 1);
    setCount(c => c + 1);
  }

  // After click, count is 3! Each gets previous value

  return <button onClick={handleClick}>Count: {count}</button>;
}
  • React batches state updates: Multiple setState calls in same function are batched
  • State updates are asynchronous: count doesn't update immediately
  • All three calls use same count: They all read 0, so all set to 1
  • Functional updates solve this: Each gets actual previous value
  • When to use functional updates: Whenever new state depends on previous state
  • React 18 improvement: Automatic batching even in async functions

State Initialization

import { useState } from 'react';

// ❌ EXPENSIVE: Function runs every render
const [tasks, setTasks] = useState(loadTasksFromLocalStorage());

// ✅ LAZY INITIALIZATION: Function runs once
const [tasks, setTasks] = useState(() => {
  console.log('Loading from localStorage...');
  return loadTasksFromLocalStorage();
});

// Example lazy initialization function
function loadTasksFromLocalStorage() {
  const saved = localStorage.getItem('tasks');
  return saved ? JSON.parse(saved) : [];
}

// Lazy init also works for expensive computations
const [data, setData] = useState(() => {
  return processExpensiveData(props.rawData);
});
  • useState argument is initial state: Only used on first render
  • Function calls are expensive: Even if only used once, called every render
  • Pass function to useState: React calls it once, uses result as initial state
  • Arrow function wrapper: () => loadTasks() defers execution
  • localStorage is slow: Good candidate for lazy initialization
  • Performance optimization: Only matters for expensive initialization

Debugging State with React DevTools

Essential DevTools Features

  • Components tab shows all state and props
  • Click component to see its current state values
  • Edit state values in real-time to test scenarios
  • See state updates highlighted when they change
  • Profiler shows what triggered re-renders
  • React DevTools is essential: Install browser extension for Chrome or Firefox
  • Inspect component state live: See exact values of all state variables
  • Manually edit state: Change values to test edge cases
  • Track state changes: Highlights show which state updated
  • Debug why components re-render: Profiler shows performance bottlenecks
  • When something doesn't work: Check state in DevTools first

Key Takeaways

React State Fundamentals Mastered Today

  • ✅ useState is React's core hook for adding state to components
  • ✅ State enables interactivity and dynamic UI updates
  • ✅ Never mutate state - always use setter functions with new arrays/objects
  • ✅ Lift state to common parent when sharing between components
  • ✅ Controlled components make React the single source of truth for forms
  • ✅ Derive state when possible - don't duplicate data
  • Component-level vs app-level state: Keep state as local as possible, lift only when needed
  • Immutability is crucial: Use spread operator for arrays and objects
  • Forms follow controlled pattern: State drives value, onChange updates state
  • Functional updates when dependent: Use prev => prev + 1 pattern
  • Task manager architecture: tasks and filter in App, operations as callbacks
  • React DevTools for debugging: Essential tool for understanding state behavior