State Basics
Key Insight
State is the memory of your application—every piece of data that changes over time, from user inputs to API responses to UI toggles. Mastering state management means understanding when state should live locally in components versus globally in a store, how to keep state synchronized with reality, and how to structure state to make your application predictable, debuggable, and performant.
Detailed Description
Frontend state management is a crucial aspect of modern web development, focusing on efficiently handling and organizing data within an application. At its core, state represents any data that can change during the lifetime of your application—it’s what makes your app dynamic and interactive rather than static.
What is State?
State in frontend development refers to any information that an application needs to keep track of over time. This includes:
- User input data
- UI component states (e.g., open/closed modals)
- Data fetched from APIs
- Application-wide settings
Types of State
- Local State: Managed within individual components
- Global State: Shared across multiple components or routes
- Server State: Data fetched from external sources that needs synchronization
Core Concepts
Store
A centralized location where the application state is stored, typically as an object.
Properties
Individual data points within the store that represent specific pieces of state.
Actions
Functions or methods used to update properties in the store, similar to setter methods.
The distinction between local, global, and server state is fundamental to effective state management. Local state (component-specific data like input values or dropdown states) should remain close to where it’s used. Global state (data needed across multiple unrelated components like user authentication or app theme) benefits from centralization. Server state (data from APIs) requires synchronization patterns to handle loading, caching, and stale data. Conflating these categories leads to over-complicated components or bloated global stores.
In the Universal Frontend Architecture, state management follows framework-agnostic principles. Whether using Redux, MobX, Vuex, or Pinia, the patterns remain consistent: unidirectional data flow, immutable updates, and separation of state logic from UI components. This consistency enables developers to transfer knowledge across projects and frameworks.
The evolution from manual state management to sophisticated solutions like Redux Toolkit and React Query reflects growing application complexity. Modern apps must handle optimistic updates, offline functionality, real-time synchronization, and complex derived data—challenges that simple setState cannot address. Understanding state fundamentals helps you choose the right tool for each scenario.
Goals of State Management
- Centralize application state for easier tracking and management
- Simplify data flow within the application through predictable patterns
- Ensure UI consistency with underlying data through reactive updates
- Improve predictability with clear rules for how and when state changes
- Enable debugging through state snapshots, time-travel, and action logs
Code Examples
Basic Example: Local Component State
Simple state management within a single component:
// Counter.js - Local state example
import React, { useState } from 'react';
const Counter = () => {
// State lives in this component only
const [count, setCount] = useState(0);
const [history, setHistory] = useState([]);
const increment = () => {
const newCount = count + 1;
setCount(newCount);
setHistory([...history, `Incremented to ${newCount}`]);
};
const decrement = () => {
const newCount = count - 1;
setCount(newCount);
setHistory([...history, `Decremented to ${newCount}`]);
};
return (
<div>
<h2>Count: {count}</h2>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
<h3>History:</h3>
<ul>
{history.map((entry, index) => (
<li key={index}>{entry}</li>
))}
</ul>
</div>
);
};
export default Counter;
Practical Example: Global State with Context API
Sharing state across multiple components:
// ThemeContext.js - Global state with Context
import React, { createContext, useContext, useState } from 'react';
// 1. Create context
const ThemeContext = createContext();
// 2. Create provider component
export const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState('light');
const [fontSize, setFontSize] = useState(16);
const toggleTheme = () => {
setTheme(prev => prev === 'light' ? 'dark' : 'light');
};
const increaseFontSize = () => setFontSize(prev => Math.min(prev + 2, 24));
const decreaseFontSize = () => setFontSize(prev => Math.max(prev - 2, 12));
// Expose state and actions
const value = {
theme,
fontSize,
toggleTheme,
increaseFontSize,
decreaseFontSize
};
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
};
// 3. Create hook for consuming context
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within ThemeProvider');
}
return context;
};
Usage:
// App.js
import { ThemeProvider } from './ThemeContext';
import Header from './Header';
import Content from './Content';
const App = () => (
<ThemeProvider>
<Header />
<Content />
</ThemeProvider>
);
// Header.js - Consumes global state
import { useTheme } from './ThemeContext';
const Header = () => {
const { theme, toggleTheme, fontSize, increaseFontSize, decreaseFontSize } = useTheme();
return (
<header style={{ fontSize: `${fontSize}px` }}>
<h1>Current Theme: {theme}</h1>
<button onClick={toggleTheme}>Toggle Theme</button>
<button onClick={increaseFontSize}>A+</button>
<button onClick={decreaseFontSize}>A-</button>
</header>
);
};
Advanced Example: Derived State and Normalization
Complex state management with computed values:
// userState.js - Normalized state structure
const initialState = {
// Normalized by ID for efficient lookups
users: {
byId: {
'1': { id: '1', name: 'Alice', role: 'admin', departmentId: 'd1' },
'2': { id: '2', name: 'Bob', role: 'user', departmentId: 'd1' },
'3': { id: '3', name: 'Charlie', role: 'user', departmentId: 'd2' }
},
allIds: ['1', '2', '3']
},
departments: {
byId: {
'd1': { id: 'd1', name: 'Engineering' },
'd2': { id: 'd2', name: 'Marketing' }
},
allIds: ['d1', 'd2']
},
filters: {
role: null,
department: null,
searchTerm: ''
}
};
// Selectors - Derive state without storing it
const selectAllUsers = (state) =>
state.users.allIds.map(id => state.users.byId[id]);
const selectFilteredUsers = (state) => {
let users = selectAllUsers(state);
const { role, department, searchTerm } = state.filters;
// Apply role filter
if (role) {
users = users.filter(user => user.role === role);
}
// Apply department filter
if (department) {
users = users.filter(user => user.departmentId === department);
}
// Apply search filter
if (searchTerm) {
users = users.filter(user =>
user.name.toLowerCase().includes(searchTerm.toLowerCase())
);
}
return users;
};
const selectUserStats = (state) => {
const users = selectAllUsers(state);
return {
total: users.length,
admins: users.filter(u => u.role === 'admin').length,
byDepartment: users.reduce((acc, user) => {
acc[user.departmentId] = (acc[user.departmentId] || 0) + 1;
return acc;
}, {})
};
};
export { initialState, selectAllUsers, selectFilteredUsers, selectUserStats };
Common Mistakes
Common Mistakes
1. Storing Derived Data in State
Mistake: Duplicating data that can be calculated from existing state.
// ❌ BAD: Storing derived data
const [items, setItems] = useState([...]);
const [itemCount, setItemCount] = useState(0); // Redundant!
const addItem = (item) => {
setItems([...items, item]);
setItemCount(itemCount + 1); // Easy to forget, causes bugs
};
// ✅ GOOD: Calculate derived data
const [items, setItems] = useState([...]);
const itemCount = items.length; // Always synchronized
const addItem = (item) => {
setItems([...items, item]); // Single source of truth
};
Why it matters: Derived data creates synchronization bugs and increases complexity. Compute it from source data.
2. Mutating State Directly
Mistake: Modifying state objects instead of creating new ones.
// ❌ BAD: Mutating state
const [user, setUser] = useState({ name: 'Alice', age: 30 });
user.age = 31; // Direct mutation!
setUser(user); // React won't detect the change
// ✅ GOOD: Immutable updates
const [user, setUser] = useState({ name: 'Alice', age: 30 });
setUser({ ...user, age: 31 }); // New object, React detects change
Why it matters: React and most state libraries rely on reference equality to detect changes. Mutations break reactivity and cause subtle bugs.
3. Putting Everything in Global State
Mistake: Making all state global when most should be local.
// ❌ BAD: Global state for component-specific UI
// In Redux store
const state = {
modal1Open: false,
modal2Open: false,
dropdown1Expanded: false,
inputFocused: false, // UI state that doesn't need to be global
// ...
};
// ✅ GOOD: Keep UI state local
const Modal = () => {
const [isOpen, setIsOpen] = useState(false); // Local to this modal
// ...
};
const Dropdown = () => {
const [isExpanded, setIsExpanded] = useState(false); // Local
// ...
};
Why it matters: Global state adds complexity, hurts performance (more components re-render), and makes components less reusable. Only globalize state that’s truly shared. See Store for more details.