Component State Management Patterns
1. Local State with useState and useReducer
| Pattern | When to Use | Pros | Cons |
|---|---|---|---|
| useState | Simple independent state values | Simple API, easy to understand, minimal boilerplate | Can get messy with complex state |
| useReducer | Complex state logic, multiple sub-values | Centralized logic, predictable updates, testable | More boilerplate, steeper learning curve |
| Multiple useState | Unrelated state pieces | Clear separation of concerns | Many setters, potential for mistakes |
| Object State | Related properties that update together | Single state object, atomic updates | Must spread previous state carefully |
| State Initializer | Expensive initial computation | Computed only once | Function call syntax needed |
Example: Choosing between useState and useReducer
// useState for simple state
const SimpleCounter = () => {
const [count, setCount] = useState(0);
return <button onClick={() => setCount(count + 1)}>{count}</button>;
};
// Multiple useState for independent values
const UserForm = () => {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [age, setAge] = useState(0);
// Each state is independent and clear
return (/* form */);
};
// useState with object for related data
const ProfileEditor = () => {
const [profile, setProfile] = useState({
name: '',
email: '',
bio: ''
});
const updateField = (field, value) => {
setProfile(prev => ({ ...prev, [field]: value }));
};
return (/* editor */);
};
// useReducer for complex state logic
const ShoppingCart = () => {
const [state, dispatch] = useReducer(cartReducer, {
items: [],
total: 0,
discount: 0,
tax: 0
});
return (
<div>
<button onClick={() => dispatch({
type: 'ADD_ITEM',
payload: item
})}>
Add to Cart
</button>
<button onClick={() => dispatch({ type: 'APPLY_DISCOUNT' })}>
Apply Discount
</button>
<button onClick={() => dispatch({ type: 'CALCULATE_TOTAL' })}>
Calculate Total
</button>
</div>
);
};
// Decision tree:
// - 1 value, simple updates? → useState
// - Few independent values? → multiple useState
// - Related values updating together? → useState with object
// - Complex logic, state depends on actions? → useReducer
// - State transitions like a state machine? → useReducer
2. State Lifting and Sharing Between Components
| Technique | Description | Use Case | Trade-off |
|---|---|---|---|
| Lift State Up | Move state to common parent component | Share state between sibling components | Parent re-renders on every update |
| Prop Drilling | Pass state through component tree | Simple component hierarchies | Tedious with deep nesting |
| Inverse Data Flow | Child updates parent via callbacks | Child needs to modify parent state | Callback props everywhere |
| Composition | Pass components as children/props | Avoid drilling through wrappers | Requires planning component structure |
| Context | Global state without prop drilling | Deep nesting, many consumers | Can cause unnecessary re-renders |
Example: State lifting patterns
// Before: State in child components (not shared)
const TemperatureInput1 = () => {
const [temp, setTemp] = useState('');
return <input value={temp} onChange={(e) => setTemp(e.target.value)} />;
};
// After: Lift state to parent
const TemperatureConverter = () => {
const [celsius, setCelsius] = useState('');
const fahrenheit = celsius ? (celsius * 9/5 + 32).toFixed(1) : '';
return (
<div>
<TemperatureInput
scale="Celsius"
temperature={celsius}
onTemperatureChange={setCelsius}
/>
<TemperatureInput
scale="Fahrenheit"
temperature={fahrenheit}
onTemperatureChange={(f) => setCelsius(((f - 32) * 5/9).toFixed(1))}
/>
<p>Boiling: {celsius >= 100 ? 'Yes' : 'No'}</p>
</div>
);
};
const TemperatureInput = ({ scale, temperature, onTemperatureChange }) => (
<div>
<label>{scale}:</label>
<input
value={temperature}
onChange={(e) => onTemperatureChange(e.target.value)}
/>
</div>
);
// Composition to avoid prop drilling
const Layout = ({ header, sidebar, content }) => (
<div>
<header>{header}</header>
<div className="main">
<aside>{sidebar}</aside>
<main>{content}</main>
</div>
</div>
);
// Usage: Pass components with their own state
const App = () => {
const [user, setUser] = useState(null);
return (
<Layout
header={<Header user={user} onLogout={() => setUser(null)} />}
sidebar={<Sidebar />} {/* No user prop needed */}
content={<Dashboard user={user} />}
/>
);
};
// When to lift state:
// ✓ Two+ components need same data
// ✓ Data needs to be synchronized
// ✓ Parent needs to coordinate children
// ✗ Only one component uses it (keep local)
// ✗ Performance issues (consider Context/memo)
3. State Colocation and Component Responsibility
| Principle | Description | Benefit | Example |
|---|---|---|---|
| State Colocation | Keep state as close to where it's used | Better performance, easier maintenance | Modal open state in Modal component |
| Single Responsibility | Component manages its own concerns | Reusable, testable, maintainable | Form validates its own fields |
| Lift on Demand | Start local, lift only when needed | Avoid premature optimization | Lift when sibling needs access |
| Encapsulation | Hide implementation details | Flexibility to change internals | Accordion manages panel states |
| Separation of Concerns | Split unrelated state into components | Independent testing and changes | Filter state vs display state |
Example: State colocation best practices
// ❌ Bad: State too high in tree
const App = () => {
const [modalOpen, setModalOpen] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const [accordionIndex, setAccordionIndex] = useState(0);
return (
<div>
<Modal open={modalOpen} onClose={() => setModalOpen(false)} />
<Tooltip open={tooltipOpen} />
<Accordion activeIndex={accordionIndex} />
</div>
);
};
// ✓ Good: State colocated with components
const Modal = () => {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button onClick={() => setIsOpen(true)}>Open</button>
{isOpen && (
<div className="modal">
<button onClick={() => setIsOpen(false)}>Close</button>
</div>
)}
</>
);
};
// ✓ Good: Component responsibility
const SearchableList = ({ items }) => {
// Search state belongs here
const [query, setQuery] = useState('');
const [sortOrder, setSortOrder] = useState('asc');
const filteredItems = useMemo(() => {
return items
.filter(item => item.name.includes(query))
.sort((a, b) => sortOrder === 'asc'
? a.name.localeCompare(b.name)
: b.name.localeCompare(a.name)
);
}, [items, query, sortOrder]);
return (
<div>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<button onClick={() => setSortOrder(prev =>
prev === 'asc' ? 'desc' : 'asc'
)}>
Sort
</button>
<List items={filteredItems} />
</div>
);
};
// ✓ Good: Lift only when necessary
const ProductPage = () => {
// Shared between multiple components
const [selectedProduct, setSelectedProduct] = useState(null);
return (
<div>
<ProductList onSelect={setSelectedProduct} />
<ProductDetails product={selectedProduct} />
<RelatedProducts productId={selectedProduct?.id} />
</div>
);
};
// Guidelines:
// 1. Start with local state
// 2. Lift when siblings need to share
// 3. Keep state close to usage
// 4. Each component owns its UI state
Note: State colocation improves performance because only
components that need the state will re-render. Avoid lifting state too early—do it when actually needed.
4. Derived State and Computed Values
| Technique | Implementation | Use Case | Performance |
|---|---|---|---|
| Inline Calculation | const total = items.reduce(...) |
Simple, cheap calculations | Recalculated every render |
| useMemo | useMemo(() => calc, [deps]) |
Expensive computations | Cached between renders |
| Avoid Sync State | Don't copy props to state | Prevent sync issues | No duplication overhead |
| Controlled Derivation | Derive in render, don't store | Always in sync with source | Depends on calculation cost |
| Key Reset Pattern | <Comp key={id} /> |
Reset state when prop changes | Component remounts |
Example: Derived state patterns
// ✓ Good: Derive inline (cheap calculation)
const CartSummary = ({ items }) => {
const total = items.reduce((sum, item) => sum + item.price, 0);
const itemCount = items.length;
const tax = total * 0.1;
return (
<div>
<p>Items: {itemCount}</p>
<p>Subtotal: ${total}</p>
<p>Tax: ${tax}</p>
<p>Total: ${total + tax}</p>
</div>
);
};
// ✓ Good: useMemo for expensive calculations
const DataTable = ({ data, filters }) => {
const filteredData = useMemo(() => {
return data
.filter(row => filters.every(f => f(row)))
.sort((a, b) => a.name.localeCompare(b.name))
.map(row => transformRow(row));
}, [data, filters]);
return <table>{/* render filteredData */}</table>;
};
// ❌ Bad: Syncing props to state
const SearchInput = ({ initialValue }) => {
// DON'T DO THIS
const [value, setValue] = useState(initialValue);
// value won't update when initialValue changes!
return <input value={value} onChange={(e) => setValue(e.target.value)} />;
};
// ✓ Good: Controlled component
const SearchInput = ({ value, onChange }) => {
return <input value={value} onChange={onChange} />;
};
// ✓ Good: Key reset pattern when prop changes
const UserProfile = ({ userId }) => {
return <ProfileForm key={userId} userId={userId} />;
// ProfileForm remounts and resets state when userId changes
};
// ❌ Bad: Storing derived state
const FilteredList = ({ items, filter }) => {
const [filteredItems, setFilteredItems] = useState([]);
// This creates sync issues!
useEffect(() => {
setFilteredItems(items.filter(filter));
}, [items, filter]);
return <List items={filteredItems} />;
};
// ✓ Good: Derive on every render
const FilteredList = ({ items, filter }) => {
const filteredItems = useMemo(
() => items.filter(filter),
[items, filter]
);
return <List items={filteredItems} />;
};
// Rule: If you can calculate it from props/state, don't store it!
Warning: Never copy props to state unless you explicitly want to ignore
prop updates. This causes sync bugs. Derive values instead or use the key
prop to reset component state.
5. State Normalization for Complex Data
| Pattern | Structure | Benefit | Use Case |
|---|---|---|---|
| Normalized State | { byId: {}, allIds: [] } |
O(1) lookups, no duplication | Large datasets, frequent updates |
| Flat Structure | Avoid deep nesting | Easier updates, better performance | Nested entities |
| Entity Tables | Separate entities by type | Clear organization, type safety | Multiple entity types |
| Reference by ID | Store IDs instead of objects | Single source of truth | Relationships between entities |
| Selector Functions | Compute derived views | Reusable query logic | Complex filtering/sorting |
Example: State normalization patterns (Part 1/2)
// ❌ Bad: Nested, duplicated data
const badState = {
posts: [
{
id: 1,
title: 'Post 1',
author: { id: 1, name: 'Alice' },
comments: [
{ id: 1, text: 'Comment', author: { id: 2, name: 'Bob' } }
]
}
]
};
// Problems: Deep nesting, author duplicated, hard to update
// ✓ Good: Normalized structure
const normalizedState = {
users: {
byId: {
1: { id: 1, name: 'Alice' },
2: { id: 2, name: 'Bob' }
},
allIds: [1, 2]
},
posts: {
byId: {
1: { id: 1, title: 'Post 1', authorId: 1, commentIds: [1] }
},
allIds: [1]
},
comments: {
byId: {
1: { id: 1, text: 'Comment', authorId: 2, postId: 1 }
},
allIds: [1]
}
};
// Reducer for normalized state
const postsReducer = (state, action) => {
switch (action.type) {
case 'ADD_POST':
return {
...state,
byId: {
...state.byId,
[action.payload.id]: action.payload
},
allIds: [...state.allIds, action.payload.id]
};
case 'UPDATE_POST':
return {
...state,
byId: {
...state.byId,
[action.payload.id]: {
...state.byId[action.payload.id],
...action.payload.updates
}
}
};
case 'DELETE_POST':
const { [action.payload]: deleted, ...remainingById } = state.byId;
return {
byId: remainingById,
allIds: state.allIds.filter(id => id !== action.payload)
};
default:
return state;
}
};
Example: State normalization patterns (Part 2/2)
// Selector functions
const selectPostById = (state, postId) => state.posts.byId[postId];
const selectPostWithAuthor = (state, postId) => {
const post = state.posts.byId[postId];
const author = state.users.byId[post.authorId];
return { ...post, author };
};
const selectPostsWithComments = (state) => {
return state.posts.allIds.map(postId => {
const post = state.posts.byId[postId];
const comments = post.commentIds.map(
commentId => state.comments.byId[commentId]
);
return { ...post, comments };
});
};
// Component usage
const PostList = () => {
const [state, dispatch] = useReducer(postsReducer, initialState);
const posts = state.posts.allIds.map(id => state.posts.byId[id]);
return (
<div>
{posts.map(post => (
<Post
key={post.id}
post={post}
author={state.users.byId[post.authorId]}
onUpdate={(updates) => dispatch({
type: 'UPDATE_POST',
payload: { id: post.id, updates }
})}
/>
))}
</div>
);
};
// Benefits of normalization:
// ✓ Single source of truth
// ✓ Fast lookups by ID
// ✓ Easy updates (no searching)
// ✓ No data duplication
// ✓ Better performance
Note: Consider normalization when you have: (1) nested entities, (2) the same entity in
multiple places, (3) frequent updates to entities, (4) need for fast lookups. Libraries like normalizr can help.
6. State Machines and useReducer Patterns
| Concept | Description | Benefit | Example |
|---|---|---|---|
| State Machine | Finite set of states and transitions | Predictable, testable state logic | Loading, success, error states |
| Valid Transitions | Only allow specific state changes | Prevent impossible states | Can't be loading and success |
| Action-based Updates | Dispatch actions to trigger transitions | Clear intent, easier debugging | FETCH, SUCCESS, ERROR actions |
| State Context | Additional data within each state | Rich state information | Error message in error state |
| XState Integration | Formal state machine library | Visualization, advanced features | Complex workflows |
Example: State machine patterns with useReducer (Part 1/2)
// State machine for data fetching
const STATES = {
IDLE: 'idle',
LOADING: 'loading',
SUCCESS: 'success',
ERROR: 'error'
};
const fetchReducer = (state, action) => {
switch (action.type) {
case 'FETCH':
return { status: STATES.LOADING, data: null, error: null };
case 'SUCCESS':
return { status: STATES.SUCCESS, data: action.payload, error: null };
case 'ERROR':
return { status: STATES.ERROR, data: null, error: action.payload };
case 'RESET':
return { status: STATES.IDLE, data: null, error: null };
default:
return state;
}
};
const DataFetcher = ({ url }) => {
const [state, dispatch] = useReducer(fetchReducer, {
status: STATES.IDLE,
data: null,
error: null
});
const fetchData = async () => {
dispatch({ type: 'FETCH' });
try {
const response = await fetch(url);
const data = await response.json();
dispatch({ type: 'SUCCESS', payload: data });
} catch (error) {
dispatch({ type: 'ERROR', payload: error.message });
}
};
// Render based on state
if (state.status === STATES.IDLE) {
return <button onClick={fetchData}>Load Data</button>;
}
if (state.status === STATES.LOADING) {
return <div>Loading...</div>;
}
if (state.status === STATES.ERROR) {
return (
<div>
Error: {state.error}
<button onClick={fetchData}>Retry</button>
</div>
);
}
return (
<div>
<pre>{JSON.stringify(state.data, null, 2)}</pre>
<button onClick={() => dispatch({ type: 'RESET' })}>Reset</button>
</div>
);
};
Example: State machine patterns with useReducer (Part 2/2)
// Complex state machine: Form wizard
const WIZARD_STATES = {
PERSONAL_INFO: 'personalInfo',
ADDRESS: 'address',
PAYMENT: 'payment',
REVIEW: 'review',
SUBMITTING: 'submitting',
SUCCESS: 'success',
ERROR: 'error'
};
const wizardReducer = (state, action) => {
switch (action.type) {
case 'NEXT':
const nextStates = {
[WIZARD_STATES.PERSONAL_INFO]: WIZARD_STATES.ADDRESS,
[WIZARD_STATES.ADDRESS]: WIZARD_STATES.PAYMENT,
[WIZARD_STATES.PAYMENT]: WIZARD_STATES.REVIEW
};
return { ...state, currentStep: nextStates[state.currentStep] };
case 'BACK':
const prevStates = {
[WIZARD_STATES.ADDRESS]: WIZARD_STATES.PERSONAL_INFO,
[WIZARD_STATES.PAYMENT]: WIZARD_STATES.ADDRESS,
[WIZARD_STATES.REVIEW]: WIZARD_STATES.PAYMENT
};
return { ...state, currentStep: prevStates[state.currentStep] };
case 'UPDATE_DATA':
return {
...state,
formData: { ...state.formData, ...action.payload }
};
case 'SUBMIT':
return { ...state, currentStep: WIZARD_STATES.SUBMITTING };
case 'SUBMIT_SUCCESS':
return { ...state, currentStep: WIZARD_STATES.SUCCESS };
case 'SUBMIT_ERROR':
return {
...state,
currentStep: WIZARD_STATES.ERROR,
error: action.payload
};
default:
return state;
}
};
// Benefits of state machines:
// ✓ Impossible states become impossible
// ✓ Clear transitions between states
// ✓ Easy to test each state
// ✓ Visual representation possible
// ✓ Predictable behavior
State Management Decision Guide
- Local component state: Use useState for simple values, useReducer for complex logic
- Sharing state: Lift to common parent, use composition to avoid prop drilling
- State location: Keep state close to where it's used (colocation)
- Derived values: Calculate from existing state, don't duplicate
- Complex data: Normalize structure with byId/allIds pattern
- State machines: Use for well-defined state transitions and workflows