...
...
#state management #react #frontend #architecture #redux

State Management Guide: Modern Applications 2025

Master state management in modern apps. Compare Redux, MobX, Zustand, Bloc patterns, and best practices for scalable applications.

V
VooStack Team
October 2, 2025
13 min read

State Management in Modern Applications: Choosing the Right Solution

State management might be the most over-engineered aspect of modern frontend development. I’ve seen teams adopt Redux for a simple todo app and others manage complex dashboards with just React hooks. Both extremes cause pain.

The truth? State management isn’t about choosing the “best” library. It’s about understanding your state, where it lives, and who needs it. Pick the simplest tool that solves your actual problem.

Understanding Your State

Before choosing a tool, categorize your state:

Server State

Data fetched from APIs. The source of truth is on the server.

Examples:

  • User profiles
  • Product listings
  • Order history
  • Analytics data

Characteristics:

  • Asynchronous
  • Can be stale
  • Needs caching and synchronization
  • Shared across components

Best Tools: React Query, SWR, Apollo Client (for GraphQL)

Client State

Data that only exists in the browser. The source of truth is the client.

Examples:

  • Form input values
  • UI state (modals, dropdowns, tabs)
  • Selected items
  • Filters and sorting preferences

Characteristics:

  • Synchronous
  • Ephemeral (doesn’t persist)
  • Component-specific or app-wide

Best Tools: useState, useReducer, Context, Zustand, Jotai

Local State

State used by a single component.

Examples:

  • Input field value
  • Hover state
  • Collapsed/expanded state

Best Tool: useState, useReducer

When to Use What

Local Component State: useState/useReducer

If only one component needs it, keep it local:

function SearchInput() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  const handleSearch = async () => {
    const data = await searchAPI(query);
    setResults(data);
  };

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <button onClick={handleSearch}>Search</button>
      <ResultsList results={results} />
    </div>
  );
}

When to lift state up: When multiple components need the same state. But don’t lift prematurely!

Prop Drilling: React Context

When passing props through many layers becomes painful:

// Bad - prop drilling
function App() {
  const [user, setUser] = useState(null);

  return (
    <Header user={user} />
    <MainContent user={user}>
      <Sidebar user={user}>
        <UserProfile user={user} />  {/* Finally used here */}
      </Sidebar>
    </MainContent>
  );
}

// Good - Context
const UserContext = createContext(null);

function App() {
  const [user, setUser] = useState(null);

  return (
    <UserContext.Provider value={user}>
      <Header />
      <MainContent>
        <Sidebar>
          <UserProfile />
        </Sidebar>
      </MainContent>
    </UserContext.Provider>
  );
}

function UserProfile() {
  const user = useContext(UserContext);
  return <div>{user.name}</div>;
}

Context Limitations:

  • Every context change rerenders all consumers
  • No built-in optimization
  • Fine for infrequent updates (theme, auth)
  • Not great for frequent updates (form values, cursor position)

Server State: React Query or SWR

For data from APIs:

import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

function UserProfile({ userId }) {
  // Fetching
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
    staleTime: 5 * 60 * 1000,  // Fresh for 5 minutes
  });

  // Mutations
  const queryClient = useQueryClient();

  const updateUser = useMutation({
    mutationFn: (updates) => api.updateUser(userId, updates),
    onSuccess: () => {
      // Invalidate and refetch
      queryClient.invalidateQueries({ queryKey: ['user', userId] });
    },
  });

  if (isLoading) return <Loading />;
  if (error) return <Error error={error} />;

  return (
    <div>
      <h1>{data.name}</h1>
      <button onClick={() => updateUser.mutate({ name: 'New Name' })}>
        Update
      </button>
    </div>
  );
}

Why React Query:

  • Automatic caching
  • Background refetching
  • Stale-while-revalidate
  • Request deduplication
  • Offline support

Don’t put server state in Redux/Zustand. React Query does it better.

Complex Client State: Zustand

When Context rerenders are problematic and you need global client state:

import create from 'zustand';

// Define store
const useStore = create((set, get) => ({
  // State
  filters: {
    category: 'all',
    priceRange: [0, 1000],
    inStock: false,
  },
  selectedItems: [],

  // Actions
  setFilter: (key, value) =>
    set((state) => ({
      filters: { ...state.filters, [key]: value },
    })),

  toggleItem: (id) =>
    set((state) => {
      const selected = state.selectedItems;
      return {
        selectedItems: selected.includes(id)
          ? selected.filter((item) => item !== id)
          : [...selected, id],
      };
    }),

  clearFilters: () =>
    set({
      filters: {
        category: 'all',
        priceRange: [0, 1000],
        inStock: false,
      },
    }),

  // Computed values
  get hasActiveFilters() {
    const filters = get().filters;
    return (
      filters.category !== 'all' ||
      filters.priceRange[0] !== 0 ||
      filters.priceRange[1] !== 1000 ||
      filters.inStock
    );
  },
}));

// Use in components
function ProductFilters() {
  const filters = useStore((state) => state.filters);
  const setFilter = useStore((state) => state.setFilter);

  return (
    <div>
      <select
        value={filters.category}
        onChange={(e) => setFilter('category', e.target.value)}
      >
        <option value="all">All Categories</option>
        <option value="electronics">Electronics</option>
      </select>
    </div>
  );
}

function ProductList() {
  // Only rerenders when selectedItems changes
  const selectedItems = useStore((state) => state.selectedItems);
  const toggleItem = useStore((state) => state.toggleItem);

  return (
    <div>
      {products.map((product) => (
        <ProductCard
          key={product.id}
          product={product}
          selected={selectedItems.includes(product.id)}
          onToggle={() => toggleItem(product.id)}
        />
      ))}
    </div>
  );
}

Zustand Advantages:

  • Minimal boilerplate
  • No providers needed
  • Selective subscriptions (no unnecessary rerenders)
  • Simple API
  • Small bundle size (1kb)

Atomic State: Jotai

For fine-grained reactivity:

import { atom, useAtom } from 'jotai';

// Define atoms
const countAtom = atom(0);
const doubleCountAtom = atom((get) => get(countAtom) * 2);

// Use atoms
function Counter() {
  const [count, setCount] = useAtom(countAtom);
  const [doubleCount] = useAtom(doubleCountAtom);

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

// Derived atoms
const userIdAtom = atom('123');
const userAtom = atom(async (get) => {
  const userId = get(userIdAtom);
  return await fetchUser(userId);
});

function UserProfile() {
  const [user] = useAtom(userAtom);

  return <div>{user.name}</div>;
}

Jotai Advantages:

  • Bottom-up approach (compose atoms)
  • TypeScript-first
  • Async support built-in
  • Minimal rerenders

Redux: When You Actually Need It

Redux gets a bad rap, but it’s still the right choice for certain scenarios:

import { configureStore, createSlice } from '@reduxjs/toolkit';

// Define slice
const cartSlice = createSlice({
  name: 'cart',
  initialState: {
    items: [],
    total: 0,
  },
  reducers: {
    addItem: (state, action) => {
      const item = action.payload;
      const existing = state.items.find((i) => i.id === item.id);

      if (existing) {
        existing.quantity += 1;
      } else {
        state.items.push({ ...item, quantity: 1 });
      }

      state.total = state.items.reduce(
        (sum, item) => sum + item.price * item.quantity,
        0
      );
    },

    removeItem: (state, action) => {
      state.items = state.items.filter((item) => item.id !== action.payload);
      state.total = state.items.reduce(
        (sum, item) => sum + item.price * item.quantity,
        0
      );
    },
  },
});

// Configure store
const store = configureStore({
  reducer: {
    cart: cartSlice.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(logger, analytics),
});

// Use in components
function Cart() {
  const dispatch = useDispatch();
  const items = useSelector((state) => state.cart.items);
  const total = useSelector((state) => state.cart.total);

  return (
    <div>
      {items.map((item) => (
        <div key={item.id}>
          {item.name} x {item.quantity}
          <button onClick={() => dispatch(cartSlice.actions.removeItem(item.id))}>
            Remove
          </button>
        </div>
      ))}
      <p>Total: ${total}</p>
    </div>
  );
}

Use Redux when:

  • You need middleware (logging, analytics, persistence)
  • You need time-travel debugging
  • You have complex state logic with multiple actions
  • You’re building a large app with many developers

Don’t use Redux when:

  • Your app is small or medium-sized
  • State is mostly server state
  • You don’t need advanced features

Form State: Special Case

Forms deserve special attention:

Simple Forms: Controlled Components

function LoginForm() {
  const [email, setEmail] = useState('');
  const [password, setPassword] = useState('');

  const handleSubmit = async (e) => {
    e.preventDefault();
    await login(email, password);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="email"
        value={email}
        onChange={(e) => setEmail(e.target.value)}
      />
      <input
        type="password"
        value={password}
        onChange={(e) => setPassword(e.target.value)}
      />
      <button type="submit">Login</button>
    </form>
  );
}

Complex Forms: React Hook Form

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';

const schema = z.object({
  email: z.string().email(),
  password: z.string().min(8),
  age: z.number().min(18),
});

function SignupForm() {
  const {
    register,
    handleSubmit,
    formState: { errors, isSubmitting },
  } = useForm({
    resolver: zodResolver(schema),
  });

  const onSubmit = async (data) => {
    await signup(data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('email')} />
      {errors.email && <span>{errors.email.message}</span>}

      <input type="password" {...register('password')} />
      {errors.password && <span>{errors.password.message}</span>}

      <input type="number" {...register('age', { valueAsNumber: true })} />
      {errors.age && <span>{errors.age.message}</span>}

      <button type="submit" disabled={isSubmitting}>
        Sign Up
      </button>
    </form>
  );
}

React Hook Form advantages:

  • Minimal rerenders (uncontrolled inputs)
  • Built-in validation
  • TypeScript support
  • Small bundle size

Persistence Strategies

State often needs to survive page refreshes:

Local Storage

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

const useStore = create(
  persist(
    (set) => ({
      theme: 'light',
      toggleTheme: () =>
        set((state) => ({
          theme: state.theme === 'light' ? 'dark' : 'light',
        })),
    }),
    {
      name: 'app-storage', // Key in localStorage
    }
  )
);

Session Storage

const useSessionStore = create(
  persist(
    (set) => ({
      searchHistory: [],
      addSearch: (query) =>
        set((state) => ({
          searchHistory: [query, ...state.searchHistory].slice(0, 10),
        })),
    }),
    {
      name: 'session-storage',
      storage: createJSONStorage(() => sessionStorage),
    }
  )
);

IndexedDB for Large Data

import { openDB } from 'idb';

const dbPromise = openDB('my-app', 1, {
  upgrade(db) {
    db.createObjectStore('cache');
  },
});

async function getCached(key) {
  return (await dbPromise).get('cache', key);
}

async function setCached(key, value) {
  return (await dbPromise).put('cache', value, key);
}

Performance Optimization

Memoization

Prevent unnecessary rerenders:

import { memo, useMemo, useCallback } from 'react';

const ExpensiveComponent = memo(({ data, onSelect }) => {
  const processed = useMemo(() => {
    return data.map((item) => expensiveTransform(item));
  }, [data]);

  return (
    <div>
      {processed.map((item) => (
        <div key={item.id} onClick={() => onSelect(item.id)}>
          {item.name}
        </div>
      ))}
    </div>
  );
});

function Parent() {
  const [selected, setSelected] = useState(null);

  // Without useCallback, onSelect changes every render
  const handleSelect = useCallback((id) => {
    setSelected(id);
  }, []);

  return <ExpensiveComponent data={items} onSelect={handleSelect} />;
}

Selector Optimization

// Bad - creates new array every render, causes rerenders
const items = useSelector((state) =>
  state.items.filter((item) => item.active)
);

// Good - use reselect for memoized selectors
import { createSelector } from 'reselect';

const selectActiveItems = createSelector(
  [(state) => state.items],
  (items) => items.filter((item) => item.active)
);

const items = useSelector(selectActiveItems);

Zustand Selective Subscription

// Bad - rerenders on any store change
const { filters, items, ui } = useStore();

// Good - only rerenders when filters change
const filters = useStore((state) => state.filters);

// Even better - shallow comparison for objects
import shallow from 'zustand/shallow';

const { category, priceRange } = useStore(
  (state) => ({
    category: state.filters.category,
    priceRange: state.filters.priceRange,
  }),
  shallow
);

Testing State Management

Testing Zustand Stores

import { renderHook, act } from '@testing-library/react';

describe('useStore', () => {
  beforeEach(() => {
    useStore.setState({ count: 0 }, true); // Reset store
  });

  it('increments count', () => {
    const { result } = renderHook(() => useStore());

    act(() => {
      result.current.increment();
    });

    expect(result.current.count).toBe(1);
  });
});

Testing with React Query

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { renderHook, waitFor } from '@testing-library/react';

const createWrapper = () => {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
    },
  });

  return ({ children }) => (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
};

it('fetches user data', async () => {
  const { result } = renderHook(() => useUser('123'), {
    wrapper: createWrapper(),
  });

  await waitFor(() => expect(result.current.isSuccess).toBe(true));

  expect(result.current.data).toEqual({ id: '123', name: 'Alice' });
});

Common Pitfalls

Over-Globalizing State

Not everything needs to be global:

// Bad - global state for modal
const useModalStore = create((set) => ({
  isOpen: false,
  openModal: () => set({ isOpen: true }),
  closeModal: () => set({ isOpen: false }),
}));

// Good - local state
function MyComponent() {
  const [isModalOpen, setIsModalOpen] = useState(false);

  return (
    <>
      <button onClick={() => setIsModalOpen(true)}>Open</button>
      <Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)} />
    </>
  );
}

Mixing Server and Client State

// Bad - mixing concerns
const useStore = create((set) => ({
  user: null,  // Server state
  theme: 'light',  // Client state
  fetchUser: async () => {
    const user = await api.getUser();
    set({ user });
  },
}));

// Good - separate concerns
const { data: user } = useQuery(['user'], api.getUser);  // Server state
const theme = useStore((state) => state.theme);  // Client state

Premature Optimization

// Don't do this unless you have actual performance problems
const MemoizedEverything = memo(({ a, b, c, d, e }) => {
  const result1 = useMemo(() => a + b, [a, b]);
  const result2 = useMemo(() => c + d, [c, d]);
  const handler = useCallback(() => doSomething(e), [e]);

  return <div onClick={handler}>{result1 + result2}</div>;
});

// Just do this
function SimpleComponent({ a, b, c, d, e }) {
  return <div onClick={() => doSomething(e)}>{a + b + c + d}</div>;
}

Decision Framework

Here’s how to choose:

Local component state → useState/useReducer

Prop drilling → Context (infrequent updates) or lift state up

Server data → React Query or SWR

Global client state → Zustand or Jotai

Complex logic + middleware → Redux Toolkit

Forms → React Hook Form

Real-time sync → Zustand + WebSockets or Replicache

Conclusion

State management isn’t about tools—it’s about understanding where state lives and who needs access. Start with the simplest solution and only add complexity when the simple solution fails.

React’s built-in hooks solve 80% of state management needs. React Query handles most server state. For the remaining client state, Zustand provides global state with minimal boilerplate.

Redux still has its place in large applications with complex workflows, but most teams overuse it. Choose tools that match your team’s skill level and your app’s actual complexity.

Struggling with state management in your application? VooStack helps teams architect scalable frontend applications with the right state management patterns. Let’s chat about your specific needs.

Topics

state management react frontend architecture redux
V

Written by VooStack Team

Contact author

Share this article