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
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
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