DEV Community

JSGuruJobs
JSGuruJobs

Posted on

Stop Putting Everything in Redux. A 4-Bucket State Architecture for React Apps.

I keep opening React codebases that look like this. A single Redux store managing API responses, UI toggles, form inputs, filter selections, and URL parameters all at once. Thousands of lines of reducers, actions, and selectors for state that has fundamentally different needs.

Then someone asks "why is our app so slow" and "why does adding a feature take three weeks."

The problem is not Redux. The problem is treating all state the same.

Here is a simple architecture that fixes this. Every piece of state in your app belongs in one of four buckets. Each bucket has different characteristics and a different ideal tool.

Bucket 1. Server State

This is data from your backend. Users, products, orders, comments. It has an authoritative source, it can go stale, and multiple clients might mutate it simultaneously.

Stop managing this in Redux. Use React Query.

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

function useUser(userId) {
  return useQuery({
    queryKey: ['user', userId],
    queryFn: () => api.getUser(userId),
    staleTime: 5 * 60 * 1000,
  })
}

function useUpdateUser() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: (data) => api.updateUser(data),
    onSuccess: (updatedUser) => {
      queryClient.setQueryData(
        ['user', updatedUser.id],
        updatedUser
      )
    },
  })
}
Enter fullscreen mode Exit fullscreen mode

You get caching, background refetching, optimistic updates, and stale data management for free. Replicating this in Redux takes hundreds of lines of boilerplate.

Bucket 2. Shared Client State

Data that multiple components across your app need but has no server counterpart. Theme, shopping cart before checkout, notification count, feature flags.

Zustand handles this with almost zero boilerplate.

import { create } from 'zustand'

const useCartStore = create((set, get) => ({
  items: [],

  addItem: (product) => set((state) => {
    const existing = state.items.find(i => i.id === product.id)
    if (existing) {
      return {
        items: state.items.map(i =>
          i.id === product.id
            ? { ...i, quantity: i.quantity + 1 }
            : i
        ),
      }
    }
    return { items: [...state.items, { ...product, quantity: 1 }] }
  }),

  removeItem: (id) => set((state) => ({
    items: state.items.filter(i => i.id !== id),
  })),

  total: () => get().items.reduce(
    (sum, i) => sum + i.price * i.quantity, 0
  ),
}))
Enter fullscreen mode Exit fullscreen mode

No providers. No context wrappers. No action types. Just a hook.

Bucket 3. Local UI State

Which modal is open. Whether the sidebar is collapsed. What the user typed into a search input. This state belongs to one component or a small component tree.

useState is all you need.

function SearchBar({ onSearch }) {
  const [query, setQuery] = useState('')
  const [isFocused, setIsFocused] = useState(false)

  const handleSubmit = (e) => {
    e.preventDefault()
    onSearch(query)
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        onFocus={() => setIsFocused(true)}
        onBlur={() => setIsFocused(false)}
        placeholder="Search products..."
      />
      {isFocused && query.length > 0 && (
        <SearchSuggestions query={query} />
      )}
    </form>
  )
}
Enter fullscreen mode Exit fullscreen mode

Do not put this in a global store. Nobody else needs it.

Bucket 4. URL State

Filters, pagination, search queries, active tabs. Anything a user should be able to bookmark or share.

The URL is your state manager.

import { useSearchParams } from 'react-router-dom'

function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams()

  const category = searchParams.get('category') || 'all'
  const sort = searchParams.get('sort') || 'newest'
  const page = parseInt(searchParams.get('page') || '1')

  const { data: products } = useQuery({
    queryKey: ['products', { category, sort, page }],
    queryFn: () => api.getProducts({ category, sort, page }),
  })

  const updateFilter = (key, value) => {
    setSearchParams((prev) => {
      prev.set(key, value)
      if (key !== 'page') prev.set('page', '1')
      return prev
    })
  }

  return (
    <div>
      <CategoryFilter
        value={category}
        onChange={(v) => updateFilter('category', v)}
      />
      <SortSelect
        value={sort}
        onChange={(v) => updateFilter('sort', v)}
      />
      <ProductGrid products={products?.items} />
      <Pagination
        current={page}
        total={products?.totalPages}
        onChange={(p) => updateFilter('page', String(p))}
      />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Notice how URL state and server state work together. The URL drives the query key. Change a filter, URL updates, React Query refetches. One source of truth. No sync bugs.

The Anti-Pattern

Here is what the same filter logic looks like when someone puts URL state in Redux.

// store/filtersSlice.js
const filtersSlice = createSlice({
  name: 'filters',
  initialState: { category: 'all', sort: 'newest', page: 1 },
  reducers: {
    setCategory: (state, action) => {
      state.category = action.payload
      state.page = 1
    },
    setSort: (state, action) => {
      state.sort = action.payload
      state.page = 1
    },
    setPage: (state, action) => {
      state.page = action.payload
    },
  },
})

// Now you need middleware to sync Redux with URL
// And a listener to sync URL back to Redux on navigation
// And you need to handle initial load from URL
// And browser back/forward buttons
// And you have two sources of truth that drift apart
// And you spend a week debugging why filters reset on refresh
Enter fullscreen mode Exit fullscreen mode

Two sources of truth. Sync logic everywhere. Bugs on every browser navigation. All because state ended up in the wrong bucket.

The Rule

Before you write any state management code, ask one question. Where does this data come from?

From the server → React Query.
Shared across the app, client only → Zustand.
Local to one component → useState.
Should survive in a URL → useSearchParams.

Four buckets. Four tools. Zero confusion.

I wrote a longer piece covering component architecture, API layer design, error handling patterns, and how all of this shows up in system design interviews. You can find it at jsgurujobs.com.

Top comments (0)