Skip to content

Instantly share code, notes, and snippets.

@pdrolima
Created September 23, 2025 21:04
Show Gist options
  • Save pdrolima/ab52ea5acf6299985447c1d334cea317 to your computer and use it in GitHub Desktop.
Save pdrolima/ab52ea5acf6299985447c1d334cea317 to your computer and use it in GitHub Desktop.
Effective React State Management

A comprehensive guide to React state management best practices, covering patterns, anti-patterns, and modern approaches.

Table of Contents

  1. React State Anti-patterns
  2. Modeling Your Application
  3. Avoiding Cascading Effects
  4. Server State Management with TanStack Query
  5. Combining and Optimizing State
  6. Form Handling with FormData and Server Actions
  7. External State Management Libraries
  8. Data Normalization
  9. State Management with Context and Finite State Machines
  10. Syncing with External Stores
  11. Testing App Logic
  12. URL Query State Management

React State Anti-patterns

Core Concepts

Deriving State

  • Rule: If you can calculate (derive) it, don't store it
  • Anti-pattern: Using useState + useEffect to 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>;
}

Refs vs State

  • Rule: Use useRef for values that don't affect rendering
  • Anti-pattern: Using useState for mutable values that don't need re-renders
  • Best practice: useRef for DOM references, timers, counters, previous values
  • Key differences:
    • useState: Triggers re-render when changed
    • useRef: No re-render when .current changes
  • Common use cases:
    • Timer IDs (setInterval/setTimeout)
    • Scroll position tracking
    • Analytics/tracking data
    • Caching expensive calculations
    • Storing previous prop values

Redundant State

  • 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

Recap

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.


Modeling Your Application

Core Concepts

Incidental vs. Accidental Complexity

  • 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

The Power of Quick Documentation

  • 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

Three Essential Diagrams

  1. Entity Relationship Diagrams (ERD): Document your data model and relationships between entities
  2. Sequence Diagrams: Document the flow of interactions between different parts of your system
  3. 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

Recap

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.


Avoiding Cascading Effects

Core Concepts

Event-Driven vs Reactive State Management

  • Rule: Think about why data changes (events), not when data changes (reactive)
  • Anti-pattern: Using multiple useEffect hooks 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]);

Refactoring to useReducer + Single useEffect

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]);

Recap

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.


Server State Management with TanStack Query

Core Concepts

Specialized Libraries for Server State

  • Rule: Use specialized libraries for server state management
  • Anti-pattern: Using useEffect + useState for 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 Best Practices

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 changes

Recap

When 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.


Combining and Optimizing State

Principles

Events are the real source of truth

  • 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

Pure functions for app logic

  • 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

Core Concepts

Combining Related State

  • Rule: Group related state variables into single objects for better organization
  • Anti-pattern: Having many individual useState calls 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',
}));

Type States for Better Modeling

  • 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' });

Recap

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.


Form Handling with FormData and Server Actions

Core Concepts

FormData for Web-Standard Form Handling

  • Rule: Use FormData and server actions instead of managing multiple useState hooks
  • 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 handlers

After (Best practice):

function handleSubmit(formData) {
  const firstName = formData.get('firstName');
  const lastName = formData.get('lastName');
  // All form data captured automatically
}

Server Actions in Next.js

  • 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

Type-Safe Validation with Zod

  • 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

Recap

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.


External State Management Libraries

Core Concepts

When React's Built-in State Management Isn't Enough

  • 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

Problems with React's Built-in State at Scale

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 consumers

Stores vs. Atoms

Store-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

Recap

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.


Data Normalization

Core Concepts

Flat vs Nested Data Structures

  • 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

Problems with Nested Data

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:

  1. Find the correct destination by mapping through all destinations
  2. Find the correct todo within that destination's todos array
  3. 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

Benefits of Data Normalization

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

Recap

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.


State Management with Context and Finite State Machines

Core Concepts

React Context for State Sharing

  • 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
}

Finite State Machines

  • 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: {} });

Recap

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.


Syncing with External Stores

Core Concepts

useSyncExternalStore for External Data Sources

  • Rule: Use useSyncExternalStore for subscribing to external stores and data sources
  • Anti-pattern: Using useEffect + useState for external data synchronization
  • Best practice: Use useSyncExternalStore for 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>;
}

When to Use useSyncExternalStore

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

Recap

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.


Testing App Logic

Why explicit state modeling makes testing easier

  • 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)

Testing reducers vs testing components

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);
});

What to test in business logic

Happy paths

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?

Edge cases

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?

Recap

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.


URL Query State Management

Core Concepts

URL State for Shareable Application State

  • Rule: Store shareable and persistent state in URL query parameters
  • Anti-pattern: Using useState for 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

Type-Safe Query State with nuqs

  • 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

Recap

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.


Conclusion

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:

  1. Keep state minimal - Only store what you can't derive
  2. Think in events - Model user actions and business logic explicitly
  3. Choose the right tool - Match your state management approach to your app's complexity
  4. Test your logic - Extract business logic for easy testing
  5. 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/)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment