๐ฏ State Management Patterns
You've learned how to use useState inside a single component - awesome! But real applications are made of many components that need to share and coordinate data. Where should a piece of state live? When should components share it, and when should they keep it to themselves? How do you move data around without turning your app into a tangle of props? These questions are exactly what separates a beginner from a confident React developer. In this lesson we'll work through the essential patterns for organizing state in a modern React 19 app - all with function components and TypeScript. 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 the components that need it.
In other words: keep state as close as possible to where it's used, but high enough that every component that needs it can reach it. Put it too low and siblings can't share it; put it too high and you drown unrelated components in re-renders and prop plumbing.
The State Placement Decision Tree
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 - so fundamental that the official React docs teach it before almost anything else about sharing data. It's the answer to a very common question: "I have two components that need the same data. Where do I put it?"
๐ Definition
Lifting State Up: moving state out of a child component and into a shared parent, so that several children can read and update the same value and stay in sync.
The Problem: Siblings Can't Talk Directly
Scenario: Temperature Converter
Imagine two input fields - one for Celsius, one for Fahrenheit. Type into either one and the other should update to match. The catch: they're sibling components, and in React data only flows downward from parent to child. Siblings have no direct line to each other.
Think of two kids in different rooms who can only talk to a parent, never to each other directly. If they need to coordinate, the message has to go up to the parent and back down. That is exactly what lifting state up sets up.
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 input owns a separate, independent piece of state. Typing in one has no way to reach the other.
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 both fields stay synchronized through the parent โ one value, two views.
How Lifting State Works
Follow a single keystroke through the tree. When the user types into the Celsius field, the child does not update itself โ it calls the onTemperatureChange callback it was handed. The parent updates the one value it owns, recomputes the other scale, and re-renders both children with fresh props. The data makes a round trip: up via a callback, back down via props. That round trip is the whole trick.
๐ฎ Try It: One Source of Truth, Two Synced Views
Pick a temperature below. Notice you never set Fahrenheit directly โ the parent owns a single Celsius value and derives Fahrenheit from it, so the two readouts can never disagree.
const [celsius, setCelsius] = useState(20); // the parent owns the truth
๐ Choose a temperature to watch both children re-render in sync.
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
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 |
๐จ Visualizing Prop Drilling
user object is threaded through four components that never read it โ the hallmark of prop drilling. Composition or Context lets you remove the middlemen.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 |
๐จ State Colocation: Before vs After
App re-renders unrelated siblings on every change; colocating it inside Accordion keeps both the re-renders and the prop plumbing local.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 |
๐ฎ Try It: Values You Never Store
Add items to the cart. The count, the total, and the number of unique products are recomputed on every click from the single array we store โ so they can never drift out of sync with it.
const [items, setItems] = useState<CartItem[]>([]); // the only stored state
๐ Add items and watch the derived values keep themselves in sync.
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
The value you hand to useState is the seed your component grows from โ and how you plant that seed matters. Pass a value directly and it's computed on every render (even though React only uses the first one). Pass a function and React runs it lazily, exactly once. Getting this right keeps expensive setup cheap and prevents a whole class of "why did my state reset?" surprises. Let's walk through each strategy and when to reach for it.
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 rarely live in isolation โ they form a family, and families need to talk. React gives that conversation strict rules: data flows down through props and requests flow up through callbacks. It feels limiting at first, but this one-way street is exactly what makes React apps predictable: at any moment you can trace where a value came from and who's allowed to change it. Let's walk through the handful of patterns that cover almost every conversation your components will ever have.
Communication Flow Directions
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 (
<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..."
/>
);
};
๐จ Data Flow Patterns Visualized
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
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! ๐