๐ฏ State Management Patterns
You've learned how to use useState in individual components - awesome! But real applications have multiple components that need to share and communicate data. How do you decide where to put state? When should components share state? How do you pass data between components efficiently? These are the questions that separate beginners from confident React developers. In this lesson, we'll explore the essential patterns for organizing state in your applications. Let's level up your state management skills! ๐
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Understand when and how to lift state up to parent components
- Recognize and solve prop drilling problems
- Apply state colocation for better component organization
- Identify and calculate derived state instead of storing it
- Choose appropriate state initialization strategies
- Design component hierarchies with proper state placement
- Refactor components to improve state management
- Build scalable React applications with clean state architecture
Estimated Time: 75-90 minutes
Project: Build a todo list where multiple components share and manage state
๐ In This Lesson
๐ State Placement Principles
The most important question in state management is: "Where should I put this state?" Let's learn the principles that guide this decision.
๐ The Golden Rule
State should live at the lowest common ancestor of all components that need it.
In other words: Keep state as close as possible to where it's used, but high enough that all components that need it can access it.
The State Placement Decision Tree
graph TD
A[Need to add state] --> B{Used by one component?}
B -->|Yes| C[Keep it local in that component]
B -->|No| D{Used by sibling components?}
D -->|Yes| E[Lift to parent]
D -->|No| F{Used by distant relatives?}
F -->|Yes| G[Lift to common ancestor]
style C fill:#4CAF50,color:#fff
style E fill:#2196F3,color:#fff
style G fill:#FF9800,color:#fff
Three Key Questions
Before Adding State, Ask:
- Can this be calculated from existing state or props?
โ If yes, don't store it - derive it instead! - Which components need to read or update this state?
โ State should live in their common parent - Is this state truly shared, or just passed through?
โ If just passed through, consider restructuring
State Ownership Models
| Pattern | When to Use | Example |
|---|---|---|
| Local State | Only one component needs it | Toggle button's open/closed state |
| Lifted State | Siblings need to share data | Filter and list both need search term |
| Global State | Many distant components need it | User authentication, theme |
๐ก Mental Model: The State Tree
Think of your component tree as an actual tree:
- Leaves (components at bottom): Local state that affects only them
- Branches (parent components): State shared by their children
- Trunk (top-level components): State needed throughout the app
Water (state) flows down from trunk to branches to leaves, but never sideways!
โฌ๏ธ Lifting State Up
Lifting state up is one of the most fundamental patterns in React. It's how sibling components share data.
๐ Definition
Lifting State Up: Moving state from a child component to a parent component so that multiple children can share and synchronize that state.
The Problem: Siblings Can't Talk Directly
Scenario: Temperature Converter
Imagine two input fields - one for Celsius, one for Fahrenheit. When you type in either, the other should update. But they're sibling components!
graph TD
A[App] --> B[CelsiusInput]
A --> C[FahrenheitInput]
B -.Can't talk directly!.-> C
style B fill:#f44336,color:#fff
style C fill:#f44336,color:#fff
The Solution: Lift State to Parent
Before: State in Children (Doesn't Work)
// โ Problem: Each has its own independent state
const CelsiusInput: React.FC = () => {
const [temperature, setTemperature] = useState('');
return (
<input
value={temperature}
onChange={(e) => setTemperature(e.target.value)}
/>
);
};
const FahrenheitInput: React.FC = () => {
const [temperature, setTemperature] = useState('');
return (
<input
value={temperature}
onChange={(e) => setTemperature(e.target.value)}
/>
);
};
โ They can't synchronize - each has separate state!
After: State in Parent (Works!)
// โ
Solution: Lift state to parent
interface TemperatureInputProps {
temperature: string;
onTemperatureChange: (temp: string) => void;
scale: 'c' | 'f';
}
const TemperatureInput: React.FC<TemperatureInputProps> = ({
temperature,
onTemperatureChange,
scale
}) => {
const scaleName = scale === 'c' ? 'Celsius' : 'Fahrenheit';
return (
<div>
<label>{scaleName}:</label>
<input
value={temperature}
onChange={(e) => onTemperatureChange(e.target.value)}
/>
</div>
);
};
// Parent component manages the shared state
const TemperatureConverter: React.FC = () => {
const [temperature, setTemperature] = useState('');
const [scale, setScale] = useState<'c' | 'f'>('c');
const handleCelsiusChange = (temp: string) => {
setScale('c');
setTemperature(temp);
};
const handleFahrenheitChange = (temp: string) => {
setScale('f');
setTemperature(temp);
};
// Convert temperatures
const celsius = scale === 'f'
? ((parseFloat(temperature) - 32) * 5/9).toFixed(1)
: temperature;
const fahrenheit = scale === 'c'
? ((parseFloat(temperature) * 9/5) + 32).toFixed(1)
: temperature;
return (
<div>
<TemperatureInput
temperature={celsius}
onTemperatureChange={handleCelsiusChange}
scale="c"
/>
<TemperatureInput
temperature={fahrenheit}
onTemperatureChange={handleFahrenheitChange}
scale="f"
/>
</div>
);
};
โ Now they stay synchronized through the parent!
How Lifting State Works
sequenceDiagram
participant User
participant Child1
participant Parent
participant Child2
User->>Child1: Type "25" in Celsius
๐ฎ Interactive Demo: Lifting State in Action
Watch how sibling components communicate through their parent:
Both inputs stay synchronized through lifted state!
Child1->>Parent: onTemperatureChange("25")
Parent->>Parent: Update state, calculate Fahrenheit
Parent->>Child1: Re-render with temp="25"
Parent->>Child2: Re-render with temp="77"
Child2->>User: Display "77" Fahrenheit
Note over Parent: Parent is "source of truth"
Key Steps to Lift State
The Lifting State Up Recipe
- Remove state from children - Delete their useState calls
- Add state to parent - Parent now owns the data
- Pass data down as props - Children receive the current value
- Pass callbacks down - Children can request updates
- Make children controlled - They no longer manage their own state
โ Benefits of Lifting State
- Single source of truth - One place owns the data
- Synchronized updates - All components see the same data
- Easier debugging - State logic is in one place
- Better testability - Test parent, children are simple
๐ณ๏ธ Prop Drilling Problem
As your component tree grows, you might find yourself passing props through many layers of components that don't actually use them. This is called "prop drilling" - and it's a code smell!
๐ Definition
Prop Drilling: Passing props through multiple component layers, where intermediate components don't use the props - they just pass them down to deeper children.
The Problem Illustrated
graph TD
A[App - has user state] --> B[Header]
A --> C[Main]
C --> D[Sidebar]
C --> E[Content]
E --> F[Article]
F --> G[Author - needs user]
A -.props drilling.-> B
A -.props drilling.-> C
C -.props drilling.-> E
E -.props drilling.-> F
F -.user prop.-> G
style A fill:#667eea,color:#fff
style B fill:#f44336,color:#fff
style C fill:#f44336,color:#fff
style E fill:#f44336,color:#fff
style F fill:#f44336,color:#fff
style G fill:#4CAF50,color:#fff
Example: Prop Drilling Code
// โ Prop drilling - passing user through many layers
interface User {
name: string;
avatar: string;
}
const App: React.FC = () => {
const [user, setUser] = useState<User>({ name: 'Alice', avatar: '๐ค' });
return <Main user={user} />;
};
// Main doesn't use user, just passes it down
const Main: React.FC<{ user: User }> = ({ user }) => {
return <Content user={user} />;
};
// Content doesn't use user, just passes it down
const Content: React.FC<{ user: User }> = ({ user }) => {
return <Article user={user} />;
};
// Article doesn't use user, just passes it down
const Article: React.FC<{ user: User }> = ({ user }) => {
return <Author user={user} />;
};
// Finally! Author actually uses it
const Author: React.FC<{ user: User }> = ({ user }) => {
return <div>{user.avatar} {user.name}</div>;
};
Why Prop Drilling is Bad
| Problem | Impact |
|---|---|
| Verbose code | Every intermediate component needs prop definitions |
| Hard to refactor | Adding/removing props affects many files |
| Tight coupling | Intermediate components know about data they don't use |
| Poor reusability | Components can't be reused without the drilled props |
| Confusing | Hard to track where data comes from and goes to |
Solutions to Prop Drilling
1. Component Composition (Recommended)
Instead of passing props down, pass components as children:
// โ
Better: Use composition
const App: React.FC = () => {
const [user, setUser] = useState<User>({ name: 'Alice', avatar: '๐ค' });
return (
<Main>
<Content>
<Article>
<Author user={user} />
</Article>
</Content>
</Main>
);
};
// Now these components don't need to know about user!
const Main: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return <main>{children}</main>;
};
const Content: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return <div className="content">{children}</div>;
};
2. Context API (For Truly Global Data)
We'll cover this in detail in Module 5, but here's a preview:
// Create context (we'll learn this later!)
const UserContext = React.createContext<User | null>(null);
// Provider at top
const App: React.FC = () => {
const [user, setUser] = useState<User>({ name: 'Alice', avatar: '๐ค' });
return (
<UserContext.Provider value={user}>
<Main />
</UserContext.Provider>
);
};
// Consumer deep in tree - no prop drilling!
const Author: React.FC = () => {
const user = useContext(UserContext);
return <div>{user?.avatar} {user?.name}</div>;
};
โ ๏ธ When is Prop Drilling Okay?
Prop drilling isn't always bad! It's fine when:
- Only 1-2 levels deep
- The data flow is clear and obvious
- Components are tightly related
- Alternative solutions would be more complex
Don't prematurely optimize! If it's not causing problems, it's fine.
๐ State Colocation
While we've talked about lifting state up, sometimes the opposite is true - you should push state down! This is called state colocation.
๐ Definition
State Colocation: Keeping state as close as possible to where it's used. If only one component (or a small component subtree) needs the state, keep it there instead of unnecessarily lifting it up.
The Colocation Principle
Kent C. Dodds' Rule
"Place code as close to where it's relevant as possible"
Don't lift state higher than necessary. Each component should own the minimal amount of state it needs.
Problem: State Too High in the Tree
Example: Accordion Component
// โ Bad: App manages accordion state it doesn't need
const App: React.FC = () => {
const [openPanel, setOpenPanel] = useState<number | null>(null);
return (
<div>
<Header />
<Sidebar />
<Accordion openPanel={openPanel} setOpenPanel={setOpenPanel} />
<Footer />
</div>
);
};
// Why does App need to know about accordion state?
// Header, Sidebar, and Footer don't care!
Better: State Lives Where It's Used
// โ
Good: Accordion manages its own state
const App: React.FC = () => {
return (
<div>
<Header />
<Sidebar />
<Accordion /> {/* Self-contained! */}
<Footer />
</div>
);
};
const Accordion: React.FC = () => {
// State lives here - only this component needs it
const [openPanel, setOpenPanel] = useState<number | null>(null);
return (
<div>
<AccordionPanel
id={0}
isOpen={openPanel === 0}
onToggle={() => setOpenPanel(openPanel === 0 ? null : 0)}
>
Panel 1 Content
</AccordionPanel>
<AccordionPanel
id={1}
isOpen={openPanel === 1}
onToggle={() => setOpenPanel(openPanel === 1 ? null : 1)}
>
Panel 2 Content
</AccordionPanel>
</div>
);
};
Benefits of State Colocation
| Benefit | Why It Matters |
|---|---|
| Better Performance | Only relevant components re-render when state changes |
| Easier to Understand | State logic is near where it's used |
| More Maintainable | Changes are localized, not spread across files |
| Better Reusability | Components are self-contained and portable |
| Cleaner Parent Components | Parents don't manage irrelevant state |
Real-World Example: Modal Dialog
Modal That Manages Its Own State
// โ
Modal is self-contained
interface ModalProps {
trigger: React.ReactNode;
children: React.ReactNode;
}
const Modal: React.FC<ModalProps> = ({ trigger, children }) => {
// State is colocated with the component that uses it
const [isOpen, setIsOpen] = useState(false);
return (
<>
<div onClick={() => setIsOpen(true)}>
{trigger}
</div>
{isOpen && (
<div className="modal-overlay" onClick={() => setIsOpen(false)}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
{children}
<button onClick={() => setIsOpen(false)}>Close</button>
</div>
</div>
)}
</>
);
};
// Easy to use anywhere!
const App: React.FC = () => {
return (
<div>
<Modal trigger={<button>Open Settings</button>}>
<SettingsForm />
</Modal>
<Modal trigger={<button>Open Profile</button>}>
<ProfileEditor />
</Modal>
</div>
);
};
โ When to Colocate State
- UI state (open/closed, hover, focus)
- Form input values (if form is self-contained)
- Component-specific filters or settings
- Animation or transition state
- Temporary or transient data
โ ๏ธ When NOT to Colocate
- Multiple components need to read the state
- Multiple components need to update the state
- State needs to persist across unmounting
- State affects URL or needs to be bookmarkable
๐ Derived State
One of the most common mistakes in React is storing data that can be calculated from existing state or props. This creates unnecessary complexity and potential bugs!
๐ Definition
Derived State: Data that can be calculated from existing state or props. Instead of storing it in state, compute it during render.
The Problem: Redundant State
Example: Shopping Cart
// โ Bad: Storing derived values
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
const ShoppingCart: React.FC = () => {
const [items, setItems] = useState<CartItem[]>([]);
const [itemCount, setItemCount] = useState(0); // Redundant!
const [totalPrice, setTotalPrice] = useState(0); // Redundant!
const addItem = (item: CartItem) => {
setItems([...items, item]);
setItemCount(itemCount + 1); // Extra work!
setTotalPrice(totalPrice + item.price); // Extra work!
};
// What if we remove an item? Update all three states!
// What if quantity changes? Update all three states!
// Easy to get out of sync! ๐ฑ
};
Better: Calculate Derived Values
// โ
Good: Derive values from source of truth
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
const ShoppingCart: React.FC = () => {
// Only store the source of truth
const [items, setItems] = useState<CartItem[]>([]);
// Derive everything else
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: CartItem) => {
setItems([...items, item]);
// That's it! Counts update automatically
};
return (
<div>
<p>Items: {itemCount}</p>
<p>Total: ${totalPrice.toFixed(2)}</p>
{/* ... */}
</div>
);
};
Common Derived State Patterns
| What You Want | Don't Store | Derive Instead |
|---|---|---|
| Full name | const [fullName, ...] |
const fullName = `${first} ${last}` |
| Array length | const [count, ...] |
const count = items.length |
| Filtered list | const [filtered, ...] |
const filtered = items.filter(...) |
| Sorted list | const [sorted, ...] |
const sorted = [...items].sort(...) |
| Computed total | const [total, ...] |
const total = items.reduce(...) |
| Boolean check | const [isEmpty, ...] |
const isEmpty = items.length === 0 |
Real Example: Todo List with Filters
Deriving Filtered Lists
interface Todo {
id: number;
text: string;
completed: boolean;
}
type Filter = 'all' | 'active' | 'completed';
const TodoList: React.FC = () => {
// Store only the essential state
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState<Filter>('all');
// Derive filtered list (recalculated every render)
const filteredTodos = todos.filter(todo => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true; // 'all'
});
// Derive statistics
const totalCount = todos.length;
const activeCount = todos.filter(t => !t.completed).length;
const completedCount = todos.filter(t => t.completed).length;
return (
<div>
<div>
<button onClick={() => setFilter('all')}>
All ({totalCount})
</button>
<button onClick={() => setFilter('active')}>
Active ({activeCount})
</button>
<button onClick={() => setFilter('completed')}>
Completed ({completedCount})
</button>
</div>
<ul>
{filteredTodos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</div>
);
};
๐ก Performance Note
You might worry: "Doesn't recalculating every render hurt performance?"
Usually, no! JavaScript is fast at simple operations. Only optimize if you have actual performance problems. For expensive calculations, you can use useMemo (we'll learn that in Module 5).
โ Benefits of Derived State
- Single source of truth - No risk of state getting out of sync
- Less code - No need to update multiple states
- Fewer bugs - Can't forget to update derived values
- Easier to reason about - Clear what the source data is
๐ฌ State Initialization
How you initialize state matters! Let's explore different initialization strategies and when to use each.
Basic Initialization
Direct Value
// Simple - pass the initial value directly
const [count, setCount] = useState(0);
const [name, setName] = useState('');
const [items, setItems] = useState<string[]>([]);
Lazy Initialization
Function Initializer
If calculating the initial state is expensive, pass a function instead:
// โ Bad: Runs expensive function on every render
const [todos, setTodos] = useState(loadTodosFromLocalStorage());
// loadTodosFromLocalStorage() runs every time, but only first value is used!
// โ
Good: Lazy initialization - runs only once
const [todos, setTodos] = useState(() => loadTodosFromLocalStorage());
// Function only called on first render
// Another example
const [bigArray, setBigArray] = useState(() => {
console.log('Computing initial state...');
return Array.from({ length: 10000 }, (_, i) => i);
});
// Expensive computation only happens once!
Initialization from Props
Using Props for Initial State
// Common pattern: Initialize from props
interface CounterProps {
initialCount: number;
}
const Counter: React.FC<CounterProps> = ({ initialCount }) => {
const [count, setCount] = useState(initialCount);
// Note: This only uses initialCount on first render
// Later prop changes won't update state!
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>+</button>
</div>
);
};
โ ๏ธ Important: Props Don't Re-Initialize State
// Parent changes initialCount
<Counter initialCount={10} /> // First render: count = 10
<Counter initialCount={20} /> // Re-render: count still 10!
// State is initialized once and then managed by the component
// Prop changes don't reset it!
Resetting State with Key
Using Key to Force Re-initialization
If you need state to reset when a prop changes, use the key prop:
// Force component to remount (and re-initialize state) when userId changes
<UserProfile key={userId} userId={userId} />
// Each unique key creates a new instance
<Counter key="counter1" initialCount={0} />
<Counter key="counter2" initialCount={10} />
// These are separate components with separate state!
Initialization Strategies
| Strategy | When to Use | Example |
|---|---|---|
| Direct Value | Simple, cheap computation | useState(0) |
| Lazy Function | Expensive computation | useState(() => calculate()) |
| From Props | Initial value from parent | useState(props.initial) |
| From Local Storage | Persist across sessions | useState(() => localStorage.get()) |
| With Key Reset | Reset when prop changes | <Comp key={id} /> |
Local Storage Pattern
Persisting State Across Page Loads
const useLocalStorage = <T,>(key: string, initialValue: T) => {
// Lazy initialization from localStorage
const [value, setValue] = useState<T>(() => {
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
// Update localStorage when state changes
const setStoredValue = (newValue: T) => {
try {
setValue(newValue);
window.localStorage.setItem(key, JSON.stringify(newValue));
} catch (error) {
console.error(error);
}
};
return [value, setStoredValue] as const;
};
// Usage
const [name, setName] = useLocalStorage('userName', '');
// State persists across page refreshes!
๐ฌ Component Communication Patterns
Components need to communicate! Let's explore the different ways components can share information.
Communication Flow Directions
graph TD
A[Parent] -->|Props down| B[Child]
B -->|Callbacks up| A
C[Sibling 1] -.Can't talk directly.-> D[Sibling 2]
E[Parent] -->|Props| C
E -->|Props| D
C -->|Callback| E
E -->|Re-render| D
style A fill:#667eea,color:#fff
style E fill:#667eea,color:#fff
Pattern 1: Parent to Child (Props)
Passing Data Down
// Parent passes data to child via props
const Parent: React.FC = () => {
const [message, setMessage] = useState('Hello!');
return <Child message={message} />;
};
const Child: React.FC<{ message: string }> = ({ message }) => {
return <p>{message}</p>;
};
Pattern 2: Child to Parent (Callbacks)
Passing Data Up
// Child calls parent's function to send data up
const Parent: React.FC = () => {
const [value, setValue] = useState('');
const handleChange = (newValue: string) => {
setValue(newValue);
console.log('Child sent:', newValue);
};
return (
๐จ Data Flow Patterns Visualized
<div>
<Child onValueChange={handleChange} />
<p>Parent received: {value}</p>
</div>
);
};
const Child: React.FC<{ onValueChange: (value: string) => void }> = ({ onValueChange }) => {
return (
<input
onChange={(e) => onValueChange(e.target.value)}
placeholder="Type something..."
/>
);
};
Pattern 3: Sibling to Sibling (via Parent)
Sharing Data Between Siblings
// Siblings communicate through shared parent state
const Parent: React.FC = () => {
const [sharedValue, setSharedValue] = useState('');
return (
<div>
<InputChild onValueChange={setSharedValue} />
<DisplayChild value={sharedValue} />
</div>
);
};
const InputChild: React.FC<{ onValueChange: (v: string) => void }> = ({ onValueChange }) => {
return <input onChange={(e) => onValueChange(e.target.value)} />;
};
const DisplayChild: React.FC<{ value: string }> = ({ value }) => {
return <p>Sibling says: {value}</p>;
};
Pattern 4: Render Props
Sharing Logic with Render Props
// Component provides functionality, parent controls rendering
interface MousePosition {
x: number;
y: number;
}
interface MouseTrackerProps {
children: (position: MousePosition) => React.ReactNode;
}
const MouseTracker: React.FC<MouseTrackerProps> = ({ children }) => {
const [position, setPosition] = useState({ x: 0, y: 0 });
const handleMouseMove = (e: React.MouseEvent) => {
setPosition({ x: e.clientX, y: e.clientY });
};
return (
<div onMouseMove={handleMouseMove} style={{ height: '100vh' }}>
{children(position)}
</div>
);
};
// Usage - consumer controls what to render
<MouseTracker>
{({ x, y }) => (
<p>Mouse position: {x}, {y}</p>
)}
</MouseTracker>
โ Communication Best Practices
- Prefer props and callbacks - Simple and explicit
- Keep data flow unidirectional - Down via props, up via callbacks
- Name callbacks clearly - Use "on" prefix:
onValueChange,onSubmit - Lift state only when necessary - Don't prematurely optimize
- Consider composition - Sometimes better than prop drilling
๐๏ธ Hands-on Practice
Time to apply these patterns! Let's build components that demonstrate proper state management.
๐๏ธ Exercise 1: Filter and List
Goal: Build a searchable list where search input and list are siblings.
Requirements:
- Parent component manages the search term
- SearchInput component (controlled input)
- FilteredList component (displays filtered items)
- Search term passed down, updates passed up
- Items list should be hardcoded in parent
๐ก Hint
Structure your components like this:
const App = () => {
const [searchTerm, setSearchTerm] = useState('');
const items = ['Apple', 'Banana', 'Cherry', ...];
const filtered = items.filter(item =>
item.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div>
<SearchInput value={searchTerm} onChange={setSearchTerm} />
<FilteredList items={filtered} />
</div>
);
};
โ Solution
import React, { useState } from 'react';
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
}
const SearchInput: React.FC<SearchInputProps> = ({ value, onChange }) => {
return (
<input
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="Search..."
style={{ padding: '0.5rem', width: '100%', marginBottom: '1rem' }}
/>
);
};
interface FilteredListProps {
items: string[];
}
const FilteredList: React.FC<FilteredListProps> = ({ items }) => {
return (
<ul style={{ listStyle: 'none', padding: 0 }}>
{items.length === 0 ? (
<li style={{ color: '#999' }}>No items found</li>
) : (
items.map((item, index) => (
<li key={index} style={{ padding: '0.5rem', borderBottom: '1px solid #eee' }}>
{item}
</li>
))
)}
</ul>
);
};
const SearchableList: React.FC = () => {
const [searchTerm, setSearchTerm] = useState('');
const items = [
'Apple', 'Apricot', 'Banana', 'Blueberry', 'Cherry',
'Cranberry', 'Date', 'Fig', 'Grape', 'Kiwi',
'Lemon', 'Lime', 'Mango', 'Orange', 'Papaya',
'Peach', 'Pear', 'Pineapple', 'Plum', 'Strawberry'
];
// Derive filtered list - don't store it!
const filteredItems = items.filter(item =>
item.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div style={{ maxWidth: '400px', margin: '0 auto', padding: '2rem' }}>
<h2>Fruit Search</h2>
<SearchInput value={searchTerm} onChange={setSearchTerm} />
<p>Showing {filteredItems.length} of {items.length} items</p>
<FilteredList items={filteredItems} />
</div>
);
};
export default SearchableList;
๐๏ธ Exercise 2: Tabs Component
Goal: Build a tabs component that manages its own active tab state.
Requirements:
- Tabs component manages active tab internally (state colocation)
- Tab buttons to switch between tabs
- Content area that shows active tab content
- Highlight active tab
- Parent shouldn't need to manage tab state
๐ก Hint
interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
const Tabs: React.FC<{ tabs: Tab[] }> = ({ tabs }) => {
const [activeTab, setActiveTab] = useState(tabs[0].id);
// Render tabs and content...
};
โ Solution
import React, { useState } from 'react';
interface Tab {
id: string;
label: string;
content: React.ReactNode;
}
interface TabsProps {
tabs: Tab[];
}
const Tabs: React.FC<TabsProps> = ({ tabs }) => {
// State is colocated - only Tabs needs to know which tab is active
const [activeTab, setActiveTab] = useState(tabs[0]?.id || '');
const activeContent = tabs.find(tab => tab.id === activeTab)?.content;
return (
<div style={{ border: '1px solid #ddd', borderRadius: '8px', overflow: 'hidden' }}>
{/* Tab buttons */}
<div style={{ display: 'flex', borderBottom: '2px solid #ddd' }}>
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
style={{
flex: 1,
padding: '1rem',
border: 'none',
background: activeTab === tab.id ? '#667eea' : '#f5f5f5',
color: activeTab === tab.id ? 'white' : '#333',
cursor: 'pointer',
fontWeight: activeTab === tab.id ? 'bold' : 'normal',
transition: 'all 0.2s'
}}
>
{tab.label}
</button>
))}
</div>
{/* Tab content */}
<div style={{ padding: '2rem' }}>
{activeContent}
</div>
</div>
);
};
// Usage - parent doesn't need to manage tab state!
const App: React.FC = () => {
const tabs = [
{
id: 'profile',
label: 'Profile',
content: <div>Profile information goes here</div>
},
{
id: 'settings',
label: 'Settings',
content: <div>Settings options go here</div>
},
{
id: 'notifications',
label: 'Notifications',
content: <div>Notification preferences go here</div>
}
];
return (
<div style={{ maxWidth: '600px', margin: '2rem auto' }}>
<h1>User Dashboard</h1>
<Tabs tabs={tabs} />
</div>
);
};
export default App;
๐๏ธ Exercise 3: Parent-Child Sync
Goal: Build a color picker where RGB inputs sync with a color preview.
Requirements:
- Three sliders for Red, Green, Blue (0-255)
- Color preview box showing current color
- Display hex code of current color
- Parent manages RGB state, children are controlled
- Derive hex color - don't store it!
๐ก Hint
const [rgb, setRgb] = useState({ r: 0, g: 0, b: 0 });
const hexColor = `#${rgb.r.toString(16).padStart(2, '0')}${rgb.g.toString(16).padStart(2, '0')}${rgb.b.toString(16).padStart(2, '0')}`;
โ Solution
import React, { useState } from 'react';
interface RGB {
r: number;
g: number;
b: number;
}
interface ColorSliderProps {
label: string;
value: number;
onChange: (value: number) => void;
color: string;
}
const ColorSlider: React.FC<ColorSliderProps> = ({ label, value, onChange, color }) => {
return (
<div style={{ marginBottom: '1rem' }}>
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 'bold' }}>
{label}: {value}
</label>
<input
type="range"
min="0"
max="255"
value={value}
onChange={(e) => onChange(parseInt(e.target.value))}
style={{ width: '100%', accentColor: color }}
/>
</div>
);
};
const ColorPicker: React.FC = () => {
const [rgb, setRgb] = useState<RGB>({ r: 100, g: 150, b: 200 });
// Derive hex color - don't store it!
const toHex = (n: number) => n.toString(16).padStart(2, '0');
const hexColor = `#${toHex(rgb.r)}${toHex(rgb.g)}${toHex(rgb.b)}`;
return (
<div style={{ maxWidth: '400px', margin: '2rem auto', padding: '2rem' }}>
<h2>RGB Color Picker</h2>
{/* Color Preview */}
<div
style={{
width: '100%',
height: '150px',
backgroundColor: hexColor,
borderRadius: '8px',
marginBottom: '1.5rem',
border: '2px solid #ddd',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: rgb.r + rgb.g + rgb.b > 382 ? '#000' : '#fff',
fontSize: '1.5rem',
fontWeight: 'bold'
}}
>
{hexColor.toUpperCase()}
</div>
{/* Sliders */}
<ColorSlider
label="Red"
value={rgb.r}
onChange={(r) => setRgb({ ...rgb, r })}
color="#ff0000"
/>
<ColorSlider
label="Green"
value={rgb.g}
onChange={(g) => setRgb({ ...rgb, g })}
color="#00ff00"
/>
<ColorSlider
label="Blue"
value={rgb.b}
onChange={(b) => setRgb({ ...rgb, b })}
color="#0000ff"
/>
{/* RGB Values */}
<div style={{ marginTop: '1.5rem', padding: '1rem', background: '#f5f5f5', borderRadius: '4px' }}>
<p style={{ margin: '0.25rem 0' }}><strong>RGB:</strong> ({rgb.r}, {rgb.g}, {rgb.b})</p>
<p style={{ margin: '0.25rem 0' }}><strong>Hex:</strong> {hexColor.toUpperCase()}</p>
</div>
</div>
);
};
export default ColorPicker;
๐ง Refactoring Exercise
Let's take poorly organized state and refactor it to follow best practices!
โ Bad Code: Multiple Issues
This code has several state management problems. Can you spot them?
// ๐จ PROBLEMS IN THIS CODE!
const BadTodoApp: React.FC = () => {
const [todos, setTodos] = useState<Todo[]>([]);
const [completedCount, setCompletedCount] = useState(0); // Problem 1
const [activeCount, setActiveCount] = useState(0); // Problem 1
const [filteredTodos, setFilteredTodos] = useState<Todo[]>([]); // Problem 1
const [filter, setFilter] = useState('all');
const addTodo = (text: string) => {
const newTodo = { id: Date.now(), text, completed: false };
setTodos([...todos, newTodo]);
setActiveCount(activeCount + 1); // Problem 2: Easy to forget
setFilteredTodos([...filteredTodos, newTodo]); // Problem 2
};
const toggleTodo = (id: number) => {
const updated = todos.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
);
setTodos(updated);
// Problem 3: Forgot to update counts! ๐ฑ
};
return <div>{/* JSX */}</div>;
};
Problems:
- Storing derived state (counts and filtered list can be calculated)
- Have to remember to update multiple states
- Easy to forget updates, causing bugs (toggle forgot to update counts!)
โ Good Code: Refactored
// โจ FIXED VERSION
interface Todo {
id: number;
text: string;
completed: boolean;
}
type Filter = 'all' | 'active' | 'completed';
const GoodTodoApp: React.FC = () => {
// Store only essential state
const [todos, setTodos] = useState<Todo[]>([]);
const [filter, setFilter] = useState<Filter>('all');
// Derive everything else
const completedCount = todos.filter(t => t.completed).length;
const activeCount = todos.filter(t => !t.completed).length;
const filteredTodos = todos.filter(t => {
if (filter === 'active') return !t.completed;
if (filter === 'completed') return t.completed;
return true;
});
const addTodo = (text: string) => {
if (!text.trim()) return;
const newTodo: Todo = {
id: Date.now(),
text: text.trim(),
completed: false
};
setTodos([...todos, newTodo]);
// That's it! Counts update automatically โจ
};
const toggleTodo = (id: number) => {
setTodos(todos.map(t =>
t.id === id ? { ...t, completed: !t.completed } : t
));
// Counts update automatically here too! โจ
};
const deleteTodo = (id: number) => {
setTodos(todos.filter(t => t.id !== id));
};
return (
<div style={{ maxWidth: '600px', margin: '2rem auto', padding: '2rem' }}>
<h1>Todo List</h1>
{/* Add Todo Form */}
<TodoInput onAddTodo={addTodo} />
{/* Filter Buttons */}
<div style={{ margin: '1rem 0', display: 'flex', gap: '0.5rem' }}>
<button onClick={() => setFilter('all')}>
All ({todos.length})
</button>
<button onClick={() => setFilter('active')}>
Active ({activeCount})
</button>
<button onClick={() => setFilter('completed')}>
Completed ({completedCount})
</button>
</div>
{/* Todo List */}
<TodoList
todos={filteredTodos}
onToggle={toggleTodo}
onDelete={deleteTodo}
/>
</div>
);
};
// Separate components for better organization
interface TodoInputProps {
onAddTodo: (text: string) => void;
}
const TodoInput: React.FC<TodoInputProps> = ({ onAddTodo }) => {
const [text, setText] = useState('');
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onAddTodo(text);
setText('');
};
return (
<form onSubmit={handleSubmit} style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="Add a todo..."
style={{ flex: 1, padding: '0.5rem' }}
/>
<button type="submit" style={{ padding: '0.5rem 1rem' }}>Add</button>
</form>
);
};
interface TodoListProps {
todos: Todo[];
onToggle: (id: number) => void;
onDelete: (id: number) => void;
}
const TodoList: React.FC<TodoListProps> = ({ todos, onToggle, onDelete }) => {
if (todos.length === 0) {
return <p style={{ textAlign: 'center', color: '#999' }}>No todos yet!</p>;
}
return (
<ul style={{ listStyle: 'none', padding: 0 }}>
{todos.map(todo => (
<li
key={todo.id}
style={{
display: 'flex',
alignItems: 'center',
padding: '0.75rem',
marginBottom: '0.5rem',
background: '#f5f5f5',
borderRadius: '4px'
}}
>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
style={{ marginRight: '0.75rem' }}
/>
<span
style={{
flex: 1,
textDecoration: todo.completed ? 'line-through' : 'none',
color: todo.completed ? '#999' : '#333'
}}
>
{todo.text}
</span>
<button
onClick={() => onDelete(todo.id)}
style={{
background: '#f44336',
color: 'white',
border: 'none',
padding: '0.25rem 0.5rem',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Delete
</button>
</li>
))}
</ul>
);
};
Improvements:
- โ Only stores essential state (todos, filter)
- โ Derives counts and filtered list
- โ No risk of state getting out of sync
- โ Cleaner update functions
- โ Components properly separated
- โ State lifted to appropriate level
โจ Best Practices
Let's consolidate everything you've learned into actionable best practices.
โ 1. Single Source of Truth
Each piece of data should have exactly one source of truth:
- Don't duplicate state between components
- Don't store derived values in state
- Lift state to the lowest common ancestor
โ 2. Minimize State
Before adding state, ask: "Can I calculate this?"
// โ Don't store what you can calculate
const [items, setItems] = useState([]);
const [count, setCount] = useState(0);
// โ
Derive instead
const [items, setItems] = useState([]);
const count = items.length;
โ 3. Colocate State
Keep state as local as possible:
- Only lift state when multiple components need it
- UI state (hover, focus) usually stays local
- Self-contained components are easier to maintain
โ 4. Use Composition Over Prop Drilling
When passing props through multiple layers, consider composition:
// โ Prop drilling
<Layout user={user}>
<Sidebar user={user}>
<UserMenu user={user} />
</Sidebar>
</Layout>
// โ
Composition
<Layout>
<Sidebar>
<UserMenu user={user} />
</Sidebar>
</Layout>
โ 5. Name Callbacks Clearly
Use consistent naming for callbacks:
// โ
Good naming
onValueChange, onSubmit, onUserSelect, onModalClose
// โ Unclear naming
change, submit, click, close
State Management Decision Tree
graph TD
A[Need to add data] --> B{Can it be calculated?}
B -->|Yes| C[Don't store it!
Derive it instead]
B -->|No| D{How many components
need it?}
D -->|One| E[Local state in
that component]
D -->|Siblings| F[Lift to parent]
D -->|Many/distant| G[Consider Context
or global state]
style C fill:#4CAF50,color:#fff
style E fill:#4CAF50,color:#fff
style F fill:#2196F3,color:#fff
style G fill:#FF9800,color:#fff
Checklist for Good State Management
| Question | Good Answer |
|---|---|
| Can this be calculated from other state/props? | If yes, derive it - don't store it |
| Which components need this state? | Put it in their lowest common ancestor |
| Is this state truly global? | Most state isn't - keep it local when possible |
| Are we passing props through many layers? | Consider composition or Context |
| Could this state live lower in the tree? | Push it down for better performance |
| Is our state synchronized correctly? | Single source of truth prevents sync issues |
๐ Summary
Congratulations! You've mastered the essential patterns for managing state in React applications. These patterns are the foundation of building scalable React apps.
What You Learned
๐ State Placement
- The golden rule: lowest common ancestor
- Three key questions before adding state
- Local vs lifted vs global state
โฌ๏ธ Lifting State Up
- How to share state between sibling components
- Moving state to parent components
- Props down, callbacks up pattern
- Creating controlled components
๐ณ๏ธ Prop Drilling
- Recognizing prop drilling problems
- Solutions: composition and Context
- When prop drilling is acceptable
๐ State Colocation
- Keeping state close to where it's used
- Benefits of localized state
- Self-contained components
๐ Derived State
- Calculating values instead of storing them
- Avoiding redundant state
- Single source of truth
๐ฌ State Initialization
- Direct vs lazy initialization
- Initializing from props
- Using key prop to reset state
- Local storage pattern
๐ฏ Key Takeaways
- Minimize state - Don't store what you can calculate
- Single source of truth - Each piece of data has one owner
- Lift judiciously - Only lift when necessary
- Colocate when possible - Keep state local for better performance
- Derive, don't duplicate - Calculate derived values on the fly
- Use composition - Often better than prop drilling
๐ Next Steps
With these patterns mastered, you're ready for more advanced topics:
๐ Coming Up Next
Lesson 3.3: Forms in React
- Controlled vs uncontrolled components
- Handling multiple form inputs
- Form validation patterns
- Type-safe form handling
You'll use the state management patterns you just learned to build complex forms!
๐ช Practice Suggestions
To master these patterns, try building:
- Dashboard with Widgets - Multiple components sharing filter state
- Multi-page Form - Step navigation with state persisting across steps
- Shopping Cart - Product list, cart, and checkout sharing state
- Social Feed - Posts, comments, likes with proper state organization
- Kanban Board - Drag and drop with columns sharing task state
โจ Remember
Good state management is about making deliberate decisions. Before adding state, think: Where should it live? Can it be derived? Who needs access? These patterns will guide you to cleaner, more maintainable React applications!
๐ Additional Resources
๐ Congratulations!
You've completed Lesson 3.2 and learned professional state management patterns! You now know how to organize state in scalable React applications. These patterns will serve you throughout your React career. Excellent work! ๐