A comprehensive guide to React state management best practices, covering patterns, anti-patterns, and modern approaches.
- React State Anti-patterns
- Modeling Your Application
- Avoiding Cascading Effects
- Server State Management with TanStack Query
- Combining and Optimizing State
- Form Handling with FormData and Server Actions
- External State Management Libraries
- Data Normalization
- State Management with Context and Finite State Machines
- Syncing with External Stores
- Testing App Logic
- URL Query State Management
- Rule: If you can calculate (derive) it, don't store it
- Anti-pattern: Using
useState+useEffectto sync derived data - Best practice: Calculate derived values directly in render or with
useMemo - Benefits:
- Eliminates synchronization bugs
- Reduces state complexity
- Automatically stays in sync with source data
- Examples of derived state:
- Filtered/sorted lists from original data + criteria
- Computed totals from item arrays
- Status calculations from multiple boolean flags
- Available items from excluded items + full list
- When to
useMemo: Only when the calculation is expensive and dependencies change infrequently
Before (Anti-pattern):
function TripSummary() {
const [tripItems] = useState([
{ name: 'Flight', cost: 500 },
{ name: 'Hotel', cost: 300 },
]);
const [totalCost, setTotalCost] = useState(0); // β Unnecessary state
useEffect(() => {
setTotalCost(tripItems.reduce((sum, item) => sum + item.cost, 0)); // β Sync effect
}, [tripItems]);
return <div>Total: ${totalCost}</div>;
}After (Best practice):
function TripSummary() {
const [tripItems] = useState([
{ name: 'Flight', cost: 500 },
{ name: 'Hotel', cost: 300 },
]);
// β
Derive the value directly
const totalCost = tripItems.reduce((sum, item) => sum + item.cost, 0);
return <div>Total: ${totalCost}</div>;
}- Rule: Use
useReffor values that don't affect rendering - Anti-pattern: Using
useStatefor mutable values that don't need re-renders - Best practice:
useReffor DOM references, timers, counters, previous values - Key differences:
useState: Triggers re-render when changeduseRef: No re-render when.currentchanges
- Common use cases:
- Timer IDs (
setInterval/setTimeout) - Scroll position tracking
- Analytics/tracking data
- Caching expensive calculations
- Storing previous prop values
- Timer IDs (
- Rule: Single source of truth for each piece of data
- Anti-pattern: Storing the same data in multiple places
- Best practice: Store minimal state, derive everything else
- Common redundancy patterns:
- Storing full objects when only ID is needed
- Duplicating data already available in props/context
- Storing both raw and formatted versions of same data
- Keeping derived calculations in separate state
This section teaches you to avoid the most common React state mistakes. The main idea is simple: don't store what you can calculate. Think of state like a database - you only store the essential information and calculate everything else when you need it. Many developers create extra state variables to hold computed values (like totals or filtered lists), but this creates bugs because you have to remember to update multiple places when data changes. Instead, just calculate these values directly in your component. Also, use useRef instead of useState for things that don't affect what the user sees (like timer IDs or tracking data) - this prevents unnecessary re-renders and improves performance.
- Incidental Complexity: The irreducible complexity that comes from the problem domain itself (unavoidable)
- Accidental Complexity: The complexity that comes from the way we implement the solution (avoidable; self-inflicted)
- Modeling is a way to map out the incidental complexity, so that there are no surprises when we start building
- Goal is to separate the incidental complexity from the accidental complexity
- Rule: Document your application's high-level structure and flows, even in simple text format
- Anti-pattern: Diving straight into code without modeling the problem domain
- Best practice: Create simple diagrams and flows to understand your application before building
- Benefits:
- Clearer understanding of system relationships and boundaries
- Easier onboarding for new team members
- Better communication with stakeholders
- Reduced bugs through upfront thinking
- Faster development once you understand the problem
- Entity Relationship Diagrams (ERD): Document your data model and relationships between entities
- Sequence Diagrams: Document the flow of interactions between different parts of your system
- State Diagrams: Document your application's states and what happens in each one
Text-based ERD Example:
# Booking System Entities
## User
- id: string (primary key)
- email: string (unique)
- name: string
- createdAt: datetime
## Flight
- id: string (primary key)
- airline: string
- departure: datetime
- arrival: datetime
- price: number
- availableSeats: number
## Booking
- id: string (primary key)
- userId: string (foreign key -> User.id)
- flightId: string (foreign key -> Flight.id)
- status: enum (pending, confirmed, cancelled)
- totalPrice: number
- createdAt: datetime
# Relationships
- User has many Bookings (1:n)
- Flight has many Bookings (1:n)
- Booking belongs to User and Flight
Before you write any code, spend time understanding what you're building. Think of this like drawing a map before taking a trip - it helps you understand where you're going and prevents getting lost. Create simple text documents that describe your data (what information you store), your flows (what happens when users do things), and your states (what different screens or modes your app has). This isn't about creating perfect diagrams - even rough text descriptions help you think through problems before you encounter them in code. Many bugs happen because developers start coding before they fully understand the problem they're solving. Taking 30 minutes to map out your application can save hours of debugging later.
- Rule: Think about why data changes (events), not when data changes (reactive)
- Anti-pattern: Using multiple
useEffecthooks that cascade and trigger each other - Best practice: Use events to represent user actions and business logic, with single effect for side effects
- Benefits:
- Predictable state flow and easier debugging
- No race conditions or timing issues
- Single source of truth for state transitions
- Better performance with fewer re-renders
- Clearer separation of concerns
Problems with cascading effects:
When building complex user interfaces, it's common to have multiple pieces of state that depend on each other. A naive approach might be to use multiple useEffect hooks that trigger in sequence, creating a "cascade" of effects. However, this pattern leads to several serious problems:
Difficult to Follow Logic Flow
// Effect 1: Trigger search when inputs change
useEffect(() => {
if (destination && startDate && endDate) {
setIsSearchingFlights(true);
}
}, [destination, startDate, endDate]);
// Effect 2: Perform flight search
useEffect(() => {
if (!isSearchingFlights) return;
// ... search logic
}, [isSearchingFlights]);
// Effect 3: Trigger hotel search when flight selected
useEffect(() => {
if (selectedFlight) {
setIsSearchingHotels(true);
}
}, [selectedFlight]);
// Effect 4: Perform hotel search
useEffect(() => {
if (!isSearchingHotels) return;
// ... search logic
}, [isSearchingHotels]);Before (Cascading Effects Anti-pattern):
// Multiple effects that cascade
useEffect(() => { /* Effect 1 */ }, [deps1]);
useEffect(() => { /* Effect 2 */ }, [deps2]);
useEffect(() => { /* Effect 3 */ }, [deps3]);
useEffect(() => { /* Effect 4 */ }, [deps4]);After (Event-Driven Best Practice):
type Action =
| { type: 'SET_INPUT'; inputs: Partial<SearchInputs> }
| { type: 'flightUpdated'; flight: Flight }
| { type: 'hotelUpdated'; hotel: Hotel }
| { type: 'SET_ERROR'; error: string };
function reducer(state: BookingState, action: Action): BookingState {
switch (action.type) {
case 'SET_INPUT':
const inputs = { ...state.inputs, ...action.inputs };
return {
...state,
inputs,
status: allInputsValid(inputs) ? 'searchingFlights' : state.status,
};
case 'flightUpdated':
return {
...state,
status: 'searchingHotels',
selectedFlight: action.flight,
};
// ... other cases
}
}
// Single effect handles all async operations based on status
useEffect(() => {
if (state.status === 'searchingFlights') {
searchFlights().then((flight) =>
dispatch({ type: 'flightUpdated', flight })
);
}
if (state.status === 'searchingHotels') {
searchHotels().then((hotel) => dispatch({ type: 'hotelUpdated', hotel }));
}
}, [state]);Imagine a row of dominoes where pushing one causes all the others to fall - this is what happens with cascading effects in React. When you have multiple useEffect hooks that trigger each other, it creates a chain reaction that's hard to follow and debug. Instead of thinking "when this state changes, do that", think "when this event happens, update state and handle side effects". Use a reducer to manage state changes in one place, and a single effect to handle async operations based on the current state. This is like having a central control room that handles all decisions, rather than having multiple automatic systems that react to each other. It makes your code more predictable and easier to understand.
- Rule: Use specialized libraries for server state management
- Anti-pattern: Using
useEffect+useStatefor data fetching - Best practice: Use TanStack Query for server state and caching
- Benefits:
- Automatic background refetching
- Caching and cache invalidation
- Loading and error states handled automatically
- Optimistic updates and mutations
- Request deduplication
- Offline support
Problems with useEffect + useState for data fetching:
- Boilerplate code: Every component needs loading, error, and data states
- Race conditions: Multiple requests can complete out of order
- No caching: Same data fetched multiple times
- Manual refetching: No automatic updates when data goes stale
- Complex error handling: Need to manually manage error recovery
- Memory leaks: Unmounted components can still set state
- No request deduplication: Multiple components making same request
Before (Anti-pattern):
function FlightSearchResults() {
const [flights, setFlights] = useState<FlightOption[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadFlights = async () => {
setIsLoading(true); // β Manual loading state
setError(null); // β Manual error reset
try {
const flightData = await fetchFlights(flightSearch);
setFlights(flightData); // β Could cause memory leak if unmounted
} catch (err) {
setError(
err instanceof Error ? err.message : 'Failed to fetch flights'
); // β Manual error handling
} finally {
setIsLoading(false); // β Manual loading cleanup
}
};
loadFlights(); // β No caching, refetches every time
}, [flightSearch]); // β Race condition if flightSearch changes rapidly
// β Lots of conditional rendering boilerplate
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return <div>{/* render flights */}</div>;
}After (Best practice with TanStack Query):
function FlightSearchResults() {
const {
data: flights,
isLoading,
error,
} = useQuery({
queryKey: ['flights', flightSearch], // β
Automatic caching by key
queryFn: () => fetchFlights(flightSearch), // β
Simple data fetching
staleTime: 5 * 60 * 1000, // β
Cache for 5 minutes
retry: 2, // β
Automatic retry on failure
});
// β
Same conditional rendering, but managed automatically
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{/* render flights */}</div>;
}Query keys should be arrays that uniquely identify the data:
// β
Good query keys
['flights', { destination: 'NYC', departure: '2024-01-01' }]
['hotels', { checkIn: '2024-01-01', checkOut: '2024-01-05' }]
['user', userId]
['posts', { page: 1, limit: 10 }]
// β Bad query keys
'flights' // Not specific enough
['flights', flightSearchObject] // Object reference changesWhen your app needs to fetch data from a server, don't try to manage it yourself with useEffect and useState - it's like trying to build your own car instead of buying one. TanStack Query is a specialized tool that handles all the complex parts of server data: caching (so you don't fetch the same data twice), loading states (so users see spinners), error handling (so failures are handled gracefully), and background updates (so data stays fresh). Think of it as a smart assistant that automatically manages all your server requests. You just tell it what data you want and it handles all the messy details like retrying failed requests, keeping data in sync, and preventing race conditions. This lets you focus on building your user interface instead of reinventing data fetching logic.
- Rule: Capture user intent and business logic through events, not direct state mutations
- Anti-pattern: Directly setting state values without expressing the underlying reason
- Best practice: Events capture intent and history, while state is just a snapshot derived from events
- Benefits:
- Clear audit trail of what happened and why
- Easier debugging and troubleshooting
- Better separation between "what" and "how"
- Enables time-travel debugging and replay functionality
- Rule: All business logic should be represented in pure functions
- Anti-pattern: Mixing side effects with state updates
- Best practice: Separate pure state transitions from side effects
- Benefits:
- Deterministic behavior - same input always produces same output
- Easy to test in isolation
- Composable and reusable logic
- Better performance through memoization
- Rule: Group related state variables into single objects for better organization
- Anti-pattern: Having many individual
useStatecalls for related data - Best practice: Combine related state into objects and use single state updates
- Benefits:
- Fewer state variables to manage
- Atomic updates ensure consistency
- Easier to understand relationships between data
- Less boilerplate code for state management
Before:
// β Multiple individual states for related data
const [destination, setDestination] = useState('');
const [departure, setDeparture] = useState('');
const [arrival, setArrival] = useState('');
const [passengers, setPassengers] = useState(1);
// Updating a single field
setDestination('Paris');After:
// β
Combined related state
const [searchForm, setSearchForm] = useState({
destination: '',
departure: '',
arrival: '',
passengers: 1,
});
// Updating a single field
setSearchForm((prev) => ({
...prev,
destination: 'Paris',
}));- Rule: Use discriminated unions to model different application states
- Anti-pattern: Using boolean flags that can create impossible states
- Best practice: Define explicit states with their associated data
- Benefits:
- Impossible states become impossible
- Type safety ensures correct data access
- Clearer component logic
- Better error handling
// β Boolean flags can create impossible states
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [data, setData] = useState(null);
// β
Type states prevent impossible combinations
type State =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'error'; error: string }
| { status: 'success'; data: FlightData };
const [state, setState] = useState<State>({ status: 'idle' });As your app grows, you'll have more and more state to manage. Instead of creating dozens of individual state variables, group related data together - like putting related files in the same folder. For example, combine all form fields into one object instead of having separate variables for each input. Also, replace confusing boolean flags (like isLoading, hasError, isSuccess that could all be true at once) with clear state types that prevent impossible combinations. Think of state like a traffic light - it can be red, yellow, or green, but never two colors at once. This approach makes your code more reliable because TypeScript can prevent you from accessing data that doesn't exist in certain states, and it makes debugging easier because you have fewer moving parts to track.
- Rule: Use FormData and server actions instead of managing multiple
useStatehooks - Anti-pattern: Creating individual state variables and change handlers for each form field
- Best practice: Let FormData automatically capture form values and use server actions for processing
- Benefits:
- Automatic data collection - no manual state management
- Web standard - works everywhere, not just React
- File upload support - handles files naturally
- Progressive enhancement - works without JavaScript
- Less boilerplate - no individual change handlers needed
Before (Anti-pattern):
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState({});
// Multiple handlers
const handleFirstNameChange = (e) => setFirstName(e.target.value);
const handleLastNameChange = (e) => setLastName(e.target.value);
// ... more handlersAfter (Best practice):
function handleSubmit(formData) {
const firstName = formData.get('firstName');
const lastName = formData.get('lastName');
// All form data captured automatically
}- Rule: Use server actions for form submission and server-side processing
- Anti-pattern: Creating separate API routes for every form submission
- Best practice: Define server functions that can be called directly from client components
- Benefits:
- Type safety - full TypeScript support
- No API routes needed - direct function calls
- Automatic serialization - FormData handled seamlessly
- Progressive enhancement - works without JavaScript
- Built-in loading states - framework handles pending states
- Rule: Use Zod for runtime validation and type safety
- Anti-pattern: Manual validation logic scattered throughout components
- Best practice: Define schemas once and use for both validation and TypeScript types
- Benefits:
- Type safety - automatic TypeScript types from schemas
- Runtime validation - catches invalid data at runtime
- Detailed error messages - field-specific validation errors
- Coercion - automatically converts strings to numbers/dates
- Reusable schemas - share validation logic between client/server
Forms are everywhere in web apps, but managing form state with individual useState hooks for each field becomes a nightmare as forms grow. Instead, use the web's built-in FormData API - it automatically collects all form values when submitted, just like forms worked before JavaScript existed. Combine this with server actions (functions that run on the server) and Zod validation (which ensures data is correct and provides TypeScript types) for a powerful, simple approach. Think of it like using a shopping cart that automatically tracks what you put in it, rather than manually writing down each item. The form handles data collection, server actions process it safely, and Zod makes sure the data is valid - all with much less code than the traditional React approach.
- Rule: Use external state management libraries when React's built-in patterns don't scale
- Anti-pattern: Over-relying on Context, prop drilling, and scattered state logic
- Best practice: Choose the right external library based on your application's complexity and needs
- Benefits:
- Single source of truth for complex state
- Better performance with selective subscriptions
- Excellent debugging and developer tools
- Framework-agnostic solutions
- Built-in persistence and middleware support
1. Prop Drilling Hell
// β Passing state through multiple components
function App() {
const [user, setUser] = useState(null);
const [cart, setCart] = useState([]);
const [notifications, setNotifications] = useState([]);
return <Header user={user} cart={cart} notifications={notifications} />;
}
function Header({ user, cart, notifications }) {
return <Navigation user={user} cart={cart} notifications={notifications} />;
}
function Navigation({ user, cart, notifications }) {
return <UserMenu user={user} cart={cart} notifications={notifications} />;
}2. Context Performance Problems
// β Single context causes all consumers to re-render
const AppContext = createContext({
user: null,
cart: [],
notifications: [],
orders: [],
settings: {},
});
// Any update to any property re-renders ALL consumersStore-based solutions (Zustand, Redux Toolkit, XState Store) use a centralized approach - all state lives in one or few stores.
Atomic solutions (Jotai, Recoil, XState Store) use a distributed approach - state is broken into independent atoms that can be composed.
Choose stores when you have:
- Complex state relationships - Many pieces of state depend on each other
- Clear data flow requirements - You need predictable, traceable state updates
- Team coordination needs - Multiple developers working on shared state logic
Choose atoms when you have:
- External state - State is updated from an external source
- Independent pieces of state - Most state doesn't depend on other state
- Component-specific concerns - State is primarily tied to specific UI components
- Performance-critical applications - Need fine-grained subscriptions
As your React app grows beyond a few components, managing state becomes challenging. React's built-in state works great for simple cases, but larger apps suffer from "prop drilling" (passing data through many component levels) and performance issues with Context. External state libraries solve these problems by providing a central place to store shared data that any component can access directly. Think of it like having a central warehouse (store) instead of passing boxes from person to person (prop drilling). Choose store-based libraries (like Zustand) when your app has complex business logic that needs to be centralized, or atomic libraries (like Jotai) when you need fine-grained control and performance optimization. These tools provide better debugging, performance, and developer experience compared to managing everything with React's built-in hooks.
- Rule: Flatten data structures by storing entities in separate collections with ID references
- Anti-pattern: Deep nesting creates complex dependencies and update patterns
- Best practice: Normalize data to avoid redundancy and ensure consistency
- Benefits:
- Simplified updates with O(1) lookups instead of O(nΓm) traversals
- Better performance with minimal re-renders
- Cleaner, more maintainable reducer logic
- Easier implementation of cross-entity operations
The current travel itinerary application stores data in a deeply nested structure where each destination contains an array of todos. This creates several problems:
Deeply Nested Updates
When updating or deleting a todo item, the reducer must:
- Find the correct destination by mapping through all destinations
- Find the correct todo within that destination's todos array
- Create a new nested structure preserving immutability
// β Complex nested update - hard to read and error-prone
destinations: state.destinations.map((dest) =>
dest.id === action.destinationId
? {
...dest,
todos: dest.todos.filter((todo) => todo.id !== action.todoId),
}
: dest
);Performance Issues
- O(nΓm) complexity: Every todo operation requires iterating through destinations AND todos
- Unnecessary re-renders: Updating one todo causes the entire destinations array to be recreated
- Memory overhead: Deeply nested objects are harder for JavaScript engines to optimize
Normalization flattens the data structure by storing entities in separate collections and using IDs to reference relationships:
Simplified Updates
// β
Normalized - direct and clear
case 'DELETE_TODO':
return {
...state,
todos: state.todos.filter(todo => todo.id !== action.todoId)
}Better Performance
- O(1) lookups: Direct access to entities by ID using objects/Maps
- Minimal re-renders: Only affected components re-render
- Efficient operations: No need to traverse nested structures
Data normalization is like organizing a library. Instead of storing books in random piles where you have to dig through everything to find one book, you organize them with a clear system where each book has a unique ID and location. In programming, this means storing your data in flat structures with ID references instead of deeply nested objects. For example, instead of storing todos inside destinations (nested), store todos and destinations separately and use IDs to connect them. This makes updates much faster because you can directly access any piece of data by its ID, rather than searching through multiple levels of nesting. It's like having a card catalog that instantly tells you where to find any book, instead of having to search every shelf.
- Rule: Use React Context to share state between components without prop drilling
- Anti-pattern: Passing state through multiple component layers as props
- Best practice: Create context providers for state that needs to be shared across component trees
- Benefits:
- Eliminates prop drilling through intermediate components
- Centralized state management for related functionality
- Cleaner component interfaces with less props
- Better separation of concerns between UI and state logic
Before (Anti-pattern):
// Prop drilling through multiple levels
function App() {
const [bookingState, setBookingState] = useState(initialState);
return (
<BookingPage
bookingState={bookingState}
setBookingState={setBookingState}
/>
);
}
function BookingPage({ bookingState, setBookingState }) {
return (
<FlightForm bookingState={bookingState} setBookingState={setBookingState} />
);
}
function FlightForm({ bookingState, setBookingState }) {
// Finally use the state here
}After (Best practice):
// Context eliminates prop drilling
const BookingContext = createContext();
function BookingProvider({ children }) {
const [state, dispatch] = useReducer(bookingReducer, initialState);
return (
<BookingContext.Provider value={{ state, dispatch }}>
{children}
</BookingContext.Provider>
);
}
function FlightForm() {
const { state, dispatch } = useBooking(); // Direct access to state
}- Rule: Replace boolean flags with explicit state machines to make impossible states impossible
- Anti-pattern: Using multiple boolean flags that can create invalid combinations
- Best practice: Use discriminated unions to define valid states and transitions
- Benefits:
- Prevents impossible states at compile time
- Makes state transitions clear and predictable
- Better error handling and edge case management
- Self-documenting state logic
- Each state contains exactly the data it needs
Before (Anti-pattern):
// Boolean flags can create impossible states
const [isSubmitting, setIsSubmitting] = useState(false);
const [isError, setIsError] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [flightOptions, setFlightOptions] = useState([]);
// What if both isError and isSuccess are true? π€After (Best practice):
// Discriminated union prevents impossible states
type State =
| { status: 'idle'; formData: FormData }
| { status: 'searching'; formData: FormData }
| { status: 'error'; formData: FormData; error: string }
| { status: 'results'; formData: FormData; flightOptions: FlightOption[] };
// Only valid states are possible
const [state, setState] = useState<State>({ status: 'idle', formData: {} });Context and state machines solve two important problems in larger apps. Context is like a public bulletin board that any component can read from, eliminating the need to pass data through every component layer (prop drilling). State machines are like a traffic control system that prevents impossible situations - instead of having multiple boolean flags that could create confusing combinations (like being both loading and errored at the same time), you define clear, mutually exclusive states. Think of a state machine like a step-by-step process where you can only be in one step at a time, and each step clearly defines what data is available and what can happen next. This prevents bugs by making impossible states literally impossible to create.
- Rule: Use
useSyncExternalStorefor subscribing to external stores and data sources - Anti-pattern: Using
useEffect+useStatefor external data synchronization - Best practice: Use
useSyncExternalStorefor atomic updates and hydration safety - Benefits:
- Eliminates the useEffect + useState dance for external data
- Handles hydration mismatches between server and client
- Optimizes performance with built-in subscription management
- Provides consistency across server-side rendering and client-side hydration
Before (Anti-pattern):
// β Anti-pattern: useEffect + useState for external data
function NetworkStatus() {
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
// Subscribe to online/offline events
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
// Set initial status
setIsOnline(navigator.onLine);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
return <div>{isOnline ? 'π’ Online' : 'π΄ Offline'}</div>;
}After (Best practice):
// β
Best practice: useSyncExternalStore
function NetworkStatus() {
const isOnline = useSyncExternalStore(
(callback) => {
// Subscribe function
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
},
() => navigator.onLine, // Get current snapshot (client)
() => true // Get server snapshot (assume online)
);
return <div>{isOnline ? 'π’ Online' : 'π΄ Offline'}</div>;
}Perfect for:
- Third-party libraries: Redux, Zustand, or any external state management
- Custom hooks: When building reusable hooks that sync with external data
- Browser APIs: Window size, network status, geolocation, etc.
- Real-time data: WebSocket connections, server-sent events
- Global stores: Any data source that exists outside React's component tree
Sometimes your React app needs to stay in sync with data that exists outside of React - like the browser's network status, window size, or third-party libraries. useSyncExternalStore is React's built-in tool for this job. Think of it like a smart bridge that connects your React components to external data sources. Instead of manually setting up event listeners and managing state with useEffect and useState (which can lead to timing issues and bugs), this hook handles all the complex synchronization automatically. It's particularly useful because it solves tricky problems like server/client mismatches (when the server thinks one thing but the browser shows another) and ensures your components always have the most up-to-date external data without race conditions.
- Rule: Test your business logic, not your UI implementation
- Anti-pattern: Testing components that mix UI concerns with business logic
- Best practice: Extract business logic into pure functions (reducers) that can be tested in isolation
- Benefits:
- Deterministic behavior - same input always produces same output
- No need to mock React hooks or DOM interactions
- Faster test execution without rendering components
- Clear separation between "what" (business logic) and "how" (UI)
When your state logic is mixed in with UI components, testing becomes complex and brittle. You have to render components, simulate user interactions, and make assertions about DOM state.
With explicit state modeling using reducers, you can test the core business logic as pure functions:
Anti-pattern (Testing mixed UI/logic):
// Hard to test - UI and logic are mixed
function BookingForm() {
const [step, setStep] = useState('search');
const [flight, setFlight] = useState(null);
const [hotel, setHotel] = useState(null);
const handleFlightSelect = (selectedFlight) => {
setFlight(selectedFlight);
setStep('hotel');
};
// Complex component with business logic embedded
}
// Test has to render component and simulate interactions
test('should move to hotel step after flight selection', () => {
render(<BookingForm />);
// Complex DOM interactions and assertions...
});Best practice (Testing pure business logic):
// Pure reducer - easy to test
function bookingReducer(state, action) {
switch (action.type) {
case 'flightSelected':
return {
...state,
selectedFlight: action.flight,
currentStep: 'hotel',
};
// ... other cases
}
}
// Simple, fast test of business logic
test('should move to hotel step after flight selection', () => {
const initialState = { currentStep: 'search', selectedFlight: null };
const action = { type: 'flightSelected', flight: mockFlight };
const newState = bookingReducer(initialState, action);
expect(newState.currentStep).toBe('hotel');
expect(newState.selectedFlight).toBe(mockFlight);
});Test the main user flows and expected behaviors:
- State transitions: Does selecting a flight move to the hotel step?
- Data updates: Is the selected flight stored correctly?
- Derived values: Are totals calculated correctly from selected items?
- Form validation: Are required fields validated properly?
Test boundary conditions and error scenarios:
- Invalid inputs: What happens with empty or malformed data?
- State consistency: Can you get into impossible states?
- Missing data: How does the system handle null/undefined values?
- Business rules: Are booking constraints enforced correctly?
Testing is like quality control in a factory - you want to catch problems before they reach customers. When you mix business logic with UI components, testing becomes slow and complicated because you have to simulate clicking buttons and checking what appears on screen. Instead, extract your core logic into pure functions (like reducers) that you can test directly. Think of it like testing a calculator's math separately from testing its buttons and display. Pure functions are predictable - give them the same input and they always produce the same output, making them easy to test. This approach lets you write fast, reliable tests that focus on your app's core behavior rather than UI details. You can quickly verify that your business rules work correctly without the complexity of rendering components and simulating user interactions.
- Rule: Store shareable and persistent state in URL query parameters
- Anti-pattern: Using
useStatefor state that should be bookmarkable or shareable - Best practice: Use query parameters for filters, search terms, pagination, and form data
- Benefits:
- Shareable URLs that preserve application state
- Browser back/forward navigation works naturally
- Bookmarkable state for better UX
- Eliminates "lost state" on page refresh
- SEO benefits for search and filter states
- Deep linking to specific application states
Examples of state that belongs in URL:
- Search filters and sorting options
- Pagination state
- Form input values
- Active tabs or views
- Selected items or categories
- Modal open/closed state
When to use query params: When the state affects what the user sees and should be shareable or persistent
- Rule: Use specialized libraries for type-safe URL state management
- Anti-pattern: Manual URL parsing and string manipulation
- Best practice: Use nuqs for automatic URL synchronization with type safety
- Benefits:
- Automatic URL synchronization
- Type-safe parsing and serialization
- SSR-compatible
- Optimistic updates
- Built-in validation
- Custom parsers for complex types
URLs are one of the web's most powerful features, but many apps don't use them effectively. When users fill out a search form or apply filters, that state should live in the URL so they can bookmark it, share it, or come back to it later. Think of URLs like addresses - they should take you to exactly the right place with the right information displayed. Instead of storing search terms or filter settings in component state (which disappears when you refresh), put them in the URL as query parameters. Libraries like nuqs make this easy by automatically keeping your component state and URL in sync, with TypeScript safety to prevent errors. This approach makes your app feel more like a traditional website where users can use browser navigation and sharing features naturally.
This guide covers the essential patterns for managing state in React applications, from avoiding common anti-patterns to implementing advanced state management solutions. Each section builds upon the previous ones, providing a comprehensive foundation for building maintainable and scalable React applications.
The key principles to remember:
- Keep state minimal - Only store what you can't derive
- Think in events - Model user actions and business logic explicitly
- Choose the right tool - Match your state management approach to your app's complexity
- Test your logic - Extract business logic for easy testing
- Use the URL - Store shareable state where users expect it
By following these patterns and understanding when to apply each technique, you'll be able to build React applications that are both powerful and maintainable.
Credits to @davidkpiano https://frontendmasters.com/courses/react-nextjs-state/)