Skip to content

State Management — Managing Application State Across SPA Components

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about State Management. We cover key concepts, practical examples, and best practices to help you master this topic.

State management in SPAs handles shared data across components, preventing prop drilling and maintaining consistent UI state through centralized stores or context patterns.

What You'll Learn

By the end of this tutorial, you will understand the challenges of SPA state management, common patterns (local state, lifted state, context, and stores), and when to use each approach.

Why It Matters

As SPAs grow, managing state becomes the primary source of complexity. Components need to share data, respond to changes from other components, and persist state across navigation. Poor state management leads to inconsistent UI, hard-to-find bugs, and unmaintainable code.

Real-World Use

An e-commerce SPA has a cart that must be accessible from the product page, header, and checkout page. Using a centralized store, any component can read cart items and dispatch actions to add/remove items. When the cart updates, all subscribing components re-render automatically.

State Management Patterns

State Management Spectrum
    ┌──────────────────────────────────────────────────────────────┐
    │                    State Management Options                 │
    ├────────────┬──────────────┬──────────────┬──────────────────┤
    │ Local      │ Lifted       │ Context      │ Centralized      │
    │ Component  │ State        │ (React) /    │ Store            │
    │ State      │ (Prop        │ Provide/     │ (Redux, Pinia,   │
    │ (useState) │ Drilling)    │ Inject (Vue) │ Zustand)         │
    ├────────────┼──────────────┼──────────────┼──────────────────┤
    │  Simple    │  Small       │  Medium      │  Large           │
    │  Single    │  component   │  app-wide    │  complex         │
    │  component │  tree        │  state       │  applications    │
    └────────────┴──────────────┴──────────────┴──────────────────┘

Think of state management like communication in an office. Local state is like personal notes on your desk. Lifted state is like a whiteboard in a meeting room. Context is like an office-wide announcement system. A store is like a central database everyone can query and update.

Local Component State

// React — useState for component-local state
function Counter() {
    const [count, setCount] = useState(0);

    return (
        <div>
            <p>Count: {count}</p>
            <button onClick={() => setCount(c => c + 1)}>Increment</button>
        </div>
    );
}

Lifted State (Prop Drilling)

// Lift state to common parent and pass down via props
function Parent() {
    const [user, setUser] = useState(null);
    const [theme, setTheme] = useState('light');

    return (
        <div>
            <Header user={user} theme={theme} onThemeChange={setTheme} />
            <Sidebar user={user} />
            <MainContent user={user} theme={theme} />
            <Footer theme={theme} />
        </div>
    );
}

// Problem: props passed through intermediate components
// Solution: Context or Store for deeply nested state

Context API (React)

import { createContext, useContext, useState } from 'react';

// Create context
const AuthContext = createContext(null);

// Provider component
function AuthProvider({ children }) {
    const [user, setUser] = useState(null);

    const login = async (email, password) => {
        const response = await fetch('/api/login', {
            method: 'POST',
            body: JSON.stringify({ email, password }),
            headers: { 'Content-Type': 'application/json' }
        });
        const userData = await response.json();
        setUser(userData);
    };

    const logout = () => {
        setUser(null);
    };

    return (
        <AuthContext.Provider value={{ user, login, logout }}>
            {children}
        </AuthContext.Provider>
    );
}

// Consumer hook
function useAuth() {
    const context = useContext(AuthContext);
    if (!context) {
        throw new Error('useAuth must be used within AuthProvider');
    }
    return context;
}

// Usage
function App() {
    return (
        <AuthProvider>
            <Header />
            <Main />
        </AuthProvider>
    );
}

function Header() {
    const { user, logout } = useAuth();
    return (
        <header>
            {user ? (
                <div>
                    <span>Welcome, {user.name}</span>
                    <button onClick={logout}>Logout</button>
                </div>
            ) : (
                <a href="/login">Login</a>
            )}
        </header>
    );
}

Centralized Store with Zustand

import { create } from 'zustand';

// Define store
const useCartStore = create((set, get) => ({
    items: [],
    totalItems: 0,
    totalPrice: 0,
    isOpen: false,

    addItem: (product) => set((state) => {
        const existing = state.items.find(i => i.id === product.id);
        let newItems;

        if (existing) {
            newItems = state.items.map(i =>
                i.id === product.id
                    ? { ...i, quantity: i.quantity + 1 }
                    : i
            );
        } else {
            newItems = [...state.items, { ...product, quantity: 1 }];
        }

        return {
            items: newItems,
            totalItems: newItems.reduce((sum, i) => sum + i.quantity, 0),
            totalPrice: newItems.reduce((sum, i) => sum + i.price * i.quantity, 0)
        };
    }),

    removeItem: (productId) => set((state) => {
        const newItems = state.items.filter(i => i.id !== productId);
        return {
            items: newItems,
            totalItems: newItems.reduce((sum, i) => sum + i.quantity, 0),
            totalPrice: newItems.reduce((sum, i) => sum + i.price * i.quantity, 0)
        };
    }),

    toggleCart: () => set((state) => ({ isOpen: !state.isOpen })),

    clearCart: () => set({
        items: [],
        totalItems: 0,
        totalPrice: 0
    })
}));

// Usage in components
function ProductCard({ product }) {
    const addItem = useCartStore(state => state.addItem);

    return (
        <div>
            <h3>{product.name}</h3>
            <p>${product.price}</p>
            <button onClick={() => addItem(product)}>Add to Cart</button>
        </div>
    );
}

function CartIcon() {
    const totalItems = useCartStore(state => state.totalItems);
    const toggleCart = useCartStore(state => state.toggleCart);

    return (
        <button onClick={toggleCart}>
            Cart ({totalItems})
        </button>
    );
}

When to Use Each Pattern

const stateGuidelines = {
    localState: {
        useWhen: 'State used by only one component',
        example: 'Form input values, toggle states, animation progress',
        avoidWhen: 'State needed by siblings or distant components'
    },
    liftedState: {
        useWhen: 'State shared by 2-3 closely related components',
        example: 'Parent-child data flow, sibling coordination',
        avoidWhen: 'Deep component trees or many components need the state'
    },
    context: {
        useWhen: 'App-wide settings, themes, auth state',
        example: 'User authentication, theme preference, locale',
        avoidWhen: 'Frequently updating state (context causes re-renders)'
    },
    store: {
        useWhen: 'Complex state logic, many consumers, performance matters',
        example: 'Shopping cart, cached API data, real-time data',
        avoidWhen: 'Simple apps with minimal shared state'
    }
};

Common Mistakes

  1. Putting everything in a global store. Not all state needs to be global. Form input values, UI toggles, and component-specific state should stay local.
  2. Ignoring state immutability. Directly mutating state objects causes bugs. Always create new objects/arrays when updating state.
  3. Over-using Context for performance-critical state. Context triggers re-renders in all consumers when any value changes. Use a store with selectors.
  4. Storing derived state. Values that can be computed from existing state (like filtered lists) should be derived, not stored separately.
  5. Not persisting important state. User preferences, cart items, and draft data should survive page refreshes. Use localStorage or IndexedDB.

Practice Questions

  1. What are the four main state management patterns in SPAs?
  2. When should you use Context instead of a centralized store?
  3. What is prop drilling and why is it a problem?
  4. Why should derived data not be stored in state?
  5. How do you persist state across page refreshes?

Challenge: Build a small e-commerce SPA with: local state for product search input, lifted state for a product list filter, context for user authentication, and a Zustand store for the shopping cart. Each component should demonstrate the appropriate pattern.

FAQ

Should I use Redux or Context API?

Use Context for simple app-wide state (theme, auth). Use Redux (or Zustand, Pinia) for complex state with frequent updates, middleware, and devtools.

What is the difference between state management in React vs Vue?

React uses Context API or external stores (Redux, Zustand). Vue uses provide/inject or Pinia (official Vue store). The patterns are conceptually similar.

How do I test state management?

Test store logic independently of components. For Zustand: create store, call actions, assert state changes. For Context: wrap test component in provider and assert rendering.

Does state management affect performance?

Yes. Poorly managed state causes unnecessary re-renders. Use selectors (React.memo, useShallow) to minimize re-renders. Avoid storing large objects that change frequently.

Can I use multiple state management approaches in one app?

Yes. Use local state for component data, Context for app-wide settings, and a store for complex business logic. Each pattern serves different needs.

Mini Project

Build a project management SPA with three levels of state: local state for form inputs, lifted state for a task list within a project view, Context for user authentication across the app, and a Zustand store for projects and tasks with add/update/delete operations and localStorage persistence.

What's Next

You understand state management. Now explore the Redux pattern in depth — the most popular predictable state container for JavaScript applications.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro