You now know how to use state, update it, and organize it. But there's a difference between code that works and code that's professional. In this lesson, you'll learn the best practices that separate beginner code from production-ready code: immutability patterns, where to place state in your component tree, when to lift state up, and advanced techniques that will make you a better React developer. Let's level up your state management skills! 🚀
Immutability: The Golden Rule
The most important rule of React state: never mutate state directly. Always create new values.
Why Immutability Matters
function TodoList() {
const [todos, setTodos] = React.useState(['Task 1', 'Task 2'])
// ❌ Wrong - mutating state directly
const addTodoBroken = (task) => {
todos.push(task) // Mutates the array
setTodos(todos) // React doesn't see the change!
// Component doesn't re-render because todos is the same array object
}
// ✅ Correct - creating new array
const addTodo = (task) => {
setTodos([...todos, task]) // New array
// React sees new array, component re-renders
}
return (
<div>
{todos.map((todo, i) => <p key={i}>{todo}</p>)}
<button onClick={() => addTodoBroken('Task 3')}>
Add (Broken)
</button>
<button onClick={() => addTodo('Task 3')}>
Add (Works)
</button>
</div>
)
}
// React uses Object.is() to compare old and new state
// If they're the same object, React skips re-render
// Immutability ensures React always sees the changeBenefits of Immutability:
- React can detect changes reliably
- Enables performance optimizations (React.memo, etc.)
- Easier to track state history (undo/redo)
- Prevents subtle bugs from shared references
- Makes code more predictable and testable
Immutable Update Patterns
Arrays: Common Operations
function TodoApp() {
const [todos, setTodos] = React.useState([
{ id: 1, text: 'Learn React', done: false },
{ id: 2, text: 'Build app', done: false }
])
// ✅ Add item
const addTodo = (text) => {
const newTodo = { id: Date.now(), text, done: false }
setTodos([...todos, newTodo])
// Or: setTodos(prev => [...prev, newTodo])
}
// ✅ Remove item
const removeTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id))
}
// ✅ Update item
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, done: !todo.done } : todo
))
}
// ✅ Replace all
const clearCompleted = () => {
setTodos(todos.filter(todo => !todo.done))
}
// ✅ Insert at position
const insertAt = (index, text) => {
const newTodo = { id: Date.now(), text, done: false }
setTodos([
...todos.slice(0, index),
newTodo,
...todos.slice(index)
])
}
}Objects: Common Operations
function UserProfile() {
const [user, setUser] = React.useState({
name: 'Alice',
age: 25,
email: 'alice@example.com',
settings: {
theme: 'light',
notifications: true
}
})
// ✅ Update single property
const updateName = (name) => {
setUser({ ...user, name })
}
// ✅ Update multiple properties
const updateInfo = (name, email) => {
setUser({ ...user, name, email })
}
// ✅ Update nested property
const updateTheme = (theme) => {
setUser({
...user,
settings: {
...user.settings,
theme
}
})
}
// ✅ Toggle boolean
const toggleNotifications = () => {
setUser({
...user,
settings: {
...user.settings,
notifications: !user.settings.notifications
}
})
}
// ✅ Remove property
const removeEmail = () => {
const { email, ...rest } = user
setUser(rest)
}
}📝 Spread Operator Cheatsheet
Arrays:[...arr, newItem] - Add to end[newItem, ...arr] - Add to startarr.filter(condition) - Remove itemsarr.map(transform) - Update items
Objects:{ ...obj, key: value } - Update/add property{ ...obj, nested: { ...obj.nested, key: value } } - Update nested
Where to Place State
One of the most important decisions: which component should own the state?
Principle: Keep State as Local as Possible
// ❌ Bad - state in parent but only used by child
function App() {
const [isModalOpen, setIsModalOpen] = React.useState(false)
return (
<div>
<Header />
<MainContent />
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
</div>
)
}
// ✅ Good - state in the component that uses it
function Modal() {
const [isOpen, setIsOpen] = React.useState(false)
return (
<>
<button onClick={() => setIsOpen(true)}>Open Modal</button>
{isOpen && (
<div className="modal">
<button onClick={() => setIsOpen(false)}>Close</button>
</div>
)}
</>
)
}
function App() {
return (
<div>
<Header />
<MainContent />
<Modal /> {/* State stays local */}
</div>
)
}When to Lift State Up
Lift state to the nearest common ancestor when multiple components need to share it:
// ❌ Problem - SearchBar and Results can't communicate
function SearchBar() {
const [query, setQuery] = React.useState('')
// How does Results get the query?
}
function Results() {
// Need the query to filter results
}
function App() {
return (
<>
<SearchBar />
<Results />
</>
)
}
// ✅ Solution - lift state to common parent
function App() {
const [query, setQuery] = React.useState('')
return (
<>
<SearchBar query={query} onQueryChange={setQuery} />
<Results query={query} />
</>
)
}
function SearchBar({ query, onQueryChange }) {
return (
<input
value={query}
onChange={e => onQueryChange(e.target.value)}
/>
)
}
function Results({ query }) {
// Can now use query to filter results
return <div>Results for: {query}</div>
}State Placement Rules:
- Start with state in the component that uses it
- If another component needs it, lift to common parent
- If state is used across many components, consider Context
- Keep state as close as possible to where it's used
Don't Store Derived State
Calculate values from existing state instead of storing them:
// ❌ Bad - storing derived values
function ShoppingCart() {
const [items, setItems] = React.useState([])
const [total, setTotal] = React.useState(0)
const [itemCount, setItemCount] = React.useState(0)
const addItem = (item) => {
setItems([...items, item])
setTotal(total + item.price) // Easy to forget!
setItemCount(itemCount + 1) // Can get out of sync!
}
}
// ✅ Good - calculate derived values
function ShoppingCart() {
const [items, setItems] = React.useState([])
// Calculated on every render
const total = items.reduce((sum, item) => sum + item.price, 0)
const itemCount = items.length
const addItem = (item) => {
setItems([...items, item])
// total and itemCount automatically update!
}
}
// Don't worry about performance - these calculations are fast
// React is optimized for this patternWhen Calculation is Expensive
// If calculation is truly expensive, use useMemo (advanced)
function DataTable({ data }) {
const [sortBy, setSortBy] = React.useState('name')
// ❌ Without optimization - sorts on every render
const sortedData = [...data].sort((a, b) => {
// Expensive sorting logic...
})
// ✅ With useMemo - only sorts when data or sortBy changes
const sortedData = React.useMemo(() => {
return [...data].sort((a, b) => {
// Expensive sorting logic...
})
}, [data, sortBy])
// But don't use useMemo unless you have a performance problem!
}State Initialization
Lazy Initialization
// ❌ Expensive function runs on every render
function App() {
const [data, setData] = React.useState(expensiveCalculation())
// expensiveCalculation() runs even during re-renders!
}
// ✅ Function form - only runs once
function App() {
const [data, setData] = React.useState(() => expensiveCalculation())
// expensiveCalculation() only runs on initial render
}
// Real example
function UserProfile() {
// ❌ Bad - localStorage read on every render
const [theme, setTheme] = React.useState(localStorage.getItem('theme') || 'light')
// ✅ Good - localStorage read only once
const [theme, setTheme] = React.useState(() => {
return localStorage.getItem('theme') || 'light'
})
}Initial State from Props
// ⚠️ Common pattern but be careful
function EditableField({ initialValue }) {
const [value, setValue] = React.useState(initialValue)
// Problem: if initialValue prop changes, state doesn't update!
return (
<input
value={value}
onChange={e => setValue(e.target.value)}
/>
)
}
// ✅ If you want to sync with prop changes, use a key
function Parent() {
const [user, setUser] = React.useState({ name: 'Alice' })
return (
<EditableField
key={user.id} // New key forces component to remount
initialValue={user.name}
/>
)
}
// ✅ Or use useEffect to sync (we'll learn this later)
function EditableField({ initialValue }) {
const [value, setValue] = React.useState(initialValue)
React.useEffect(() => {
setValue(initialValue)
}, [initialValue])
return <input value={value} onChange={e => setValue(e.target.value)} />
}Controlled vs Uncontrolled Components
Controlled Components (Recommended)
// ✅ Controlled - React state is the "single source of truth"
function ControlledInput() {
const [value, setValue] = React.useState('')
return (
<input
value={value}
onChange={e => setValue(e.target.value)}
/>
)
}
// Benefits:
// - React state always matches input value
// - Easy to validate, transform, or clear
// - Can disable/enable based on other state
// - Predictable and testableUncontrolled Components
// ⚠️ Uncontrolled - DOM is the source of truth
function UncontrolledInput() {
const inputRef = React.useRef()
const handleSubmit = () => {
console.log(inputRef.current.value) // Read from DOM
}
return (
<>
<input ref={inputRef} defaultValue="" />
<button onClick={handleSubmit}>Submit</button>
</>
)
}
// Use uncontrolled only when:
// - Working with file inputs (can't be controlled)
// - Integrating with non-React code
// - Performance is critical and you don't need instant updates
// Otherwise, prefer controlled components!Resetting State
Reset with Key Prop
function App() {
const [selectedUser, setSelectedUser] = React.useState(users[0])
return (
<div>
<select onChange={e => setSelectedUser(users[e.target.value])}>
{users.map((user, i) => (
<option key={user.id} value={i}>{user.name}</option>
))}
</select>
{/* Key changes when user changes, form resets! */}
<UserForm key={selectedUser.id} user={selectedUser} />
</div>
)
}
// When key changes, React destroys old component and creates new one
// All state is reset automaticallyReset with Callback
function Form() {
const [formData, setFormData] = React.useState({
name: '',
email: '',
message: ''
})
const handleSubmit = (e) => {
e.preventDefault()
console.log(formData)
// Reset form after submit
setFormData({
name: '',
email: '',
message: ''
})
}
const handleReset = () => {
setFormData({
name: '',
email: '',
message: ''
})
}
}Interactive Demo
💡 Tip: Edit the code above and click "Run" to see your changes
Common State Pitfalls
1. Using State for Everything
// ❌ Bad - storing unnecessary state
function ProductCard({ product }) {
const [name, setName] = React.useState(product.name)
const [price, setPrice] = React.useState(product.price)
const [discountedPrice, setDiscountedPrice] = React.useState(
product.price * 0.9
)
// All of this should just come from props!
}
// ✅ Good - use props directly, calculate what you need
function ProductCard({ product }) {
const discountedPrice = product.price * 0.9
return (
<div>
<h3>{product.name}</h3>
<p>Was: ${product.price}</p>
<p>Now: ${discountedPrice}</p>
</div>
)
}2. Stale State in Callbacks
// ❌ Problem - stale state in timeout
function Counter() {
const [count, setCount] = React.useState(0)
const handleClick = () => {
setCount(count + 1)
setTimeout(() => {
console.log(count) // Logs old value!
}, 3000)
}
}
// ✅ Solution 1 - use function form
function Counter() {
const [count, setCount] = React.useState(0)
const handleClick = () => {
setCount(prev => {
const newCount = prev + 1
setTimeout(() => {
console.log(newCount) // Logs correct value!
}, 3000)
return newCount
})
}
}
// ✅ Solution 2 - use useEffect (we'll learn this later)3. Too Many Re-renders
// ❌ Infinite loop!
function Example() {
const [count, setCount] = React.useState(0)
setCount(count + 1) // This runs during render!
// Causes re-render, which runs this again, infinite loop
return <div>{count}</div>
}
// ✅ Correct - update in event handler
function Example() {
const [count, setCount] = React.useState(0)
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
)
}Key Takeaways
- Never mutate state - always create new values
- Use spread operator for immutable updates
- Keep state as local as possible
- Lift state up to common ancestor when shared
- Calculate derived values, don't store them
- Use lazy initialization for expensive calculations
- Prefer controlled components for forms
- Use key prop to reset component state
- Use function form when new state depends on previous
- Don't call setState during render
- Avoid storing props in state (they can get out of sync)
- Start simple, optimize only when needed
- Immutability enables React's performance optimizations
What's Next?
Congratulations! You've completed the State Management section. You now understand:
- What state is and why it matters
- How to use the useState hook
- How state updates trigger re-renders
- When to split or combine state
- Professional state management best practices
You have the foundation for building truly interactive React applications! But there's so much more to learn:
- Events - Handling user interactions properly
- Side Effects - API calls, timers, subscriptions
- Context - Sharing state across many components
- Advanced Hooks - useReducer, useMemo, useCallback
- And much more!
Keep going - you're building real React skills! 🎉