Dwarves
Memo
Type ESC to close search bar

State management strategy in React

State management is a core architectural topic in React, especially as applications grow in complexity. While local component state (using useState or useReducer) is suitable for small to medium apps, more sophisticated state management strategies become essential as your app scales.

Local component state with hooks

React’s native useState and useReducer are sufficient for managing state at the component level and are efficient for isolated, reusable components. However, challenges arise when dealing with deeply nested or cross-component data dependencies.

Example use case

Use useReducer for managing local form state with multiple dependent fields.

const initialFormState = { name: '', email: '', password: '' }

function formReducer(state, action) {
  switch (action.type) {
    case 'UPDATE_FIELD':
      return { ...state, [action.field]: action.value }
    case 'RESET':
      return initialFormState
    default:
      return state
  }
}

function SignupForm() {
  const [state, dispatch] = useReducer(formReducer, initialFormState)

  const handleChange = (e) => {
    dispatch({
      type: 'UPDATE_FIELD',
      field: e.target.name,
      value: e.target.value,
    })
  }

  return (
    <form>
      <input name="name" value={state.name} onChange={handleChange} />
      <input name="email" value={state.email} onChange={handleChange} />
      <input name="password" type="password" value={state.password} onChange={handleChange} />
      <button type="button" onClick={() => dispatch({ type: 'RESET' })}>
        Reset
      </button>
    </form>
  )
}

When to use local state:

Global state with context API

The React Context API is suitable for small to medium global state needs, such as user authentication or theme settings. It’s lightweight but can cause re-rendering issues if used improperly in large applications.

Example of centralized authentication state

const AuthContext = React.createContext()

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

  const login = (userData) => setUser(userData)
  const logout = () => setUser(null)

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

function useAuth() {
  return useContext(AuthContext)
}

// Usage:
function Navbar() {
  const { user, logout } = useAuth()
  return user ? <button onClick={logout}>Logout</button> : <button>Login</button>
}

When to use context API:

Redux or Zustand for complex global state

Redux is well-suited for applications with highly structured, complex, or cross-cutting state needs. It provides predictable state management via a single store and supports middleware for logging, async actions, and more. Alternatively, Zustand is a lightweight state management library that’s simpler to set up and more flexible than Redux.

Example of global cart management with Redux Toolkit

Using Redux Toolkit, you can simplify Redux by automatically generating action creators and reducers.

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

const cartSlice = createSlice({
  name: 'cart',
  initialState: [],
  reducers: {
    addItem: (state, action) => {
      state.push(action.payload)
    },
    removeItem: (state, action) => {
      return state.filter((item) => item.id !== action.payload)
    },
  },
})

const store = configureStore({ reducer: { cart: cartSlice.reducer } })

// Actions for dispatching:
export const { addItem, removeItem } = cartSlice.actions
export default store

Redux vs. Zustand:

When to use Redux or Zustand:

Async data and server state with React Query or SWR

Tools like React Query and SWR are ideal for handling server data. They help manage caching, re-fetching, and synchronization with server data, which is particularly useful in data-intensive applications.

Example use case: React Query simplifies handling server state by caching data and re-fetching when necessary. It also manages states like loading, error, and refetching automatically.

import { useQuery, QueryClient, QueryClientProvider } from 'react-query'

const queryClient = new QueryClient()

function fetchUser(userId) {
  return fetch(`/api/user/${userId}`).then((res) => res.json())
}

function UserProfile({ userId }) {
  const { data, error, isLoading } = useQuery(['user', userId], () => fetchUser(userId), {
    staleTime: 5 * 60 * 1000, // Data remains fresh for 5 minutes
  })

  if (isLoading) return <LoadingSpinner />
  if (error) return <ErrorDisplay message={error.message} />
  return <div>User: {data.name}</div>
}

// Usage in App:
;<QueryClientProvider client={queryClient}>
  <UserProfile userId={1} />
</QueryClientProvider>

React Query vs. SWR:

When to use React Query or SWR:

Combined approach with context + React Query

For scalable applications, a hybrid approach works well, where:

Example hybrid structure:

const UserContext = React.createContext()

function AppProvider({ children }) {
  const [user, setUser] = useState(null)
  return <UserContext.Provider value={{ user, setUser }}>{children}</UserContext.Provider>
}

function useUserData(userId) {
  return useQuery(['user', userId], () => fetchUser(userId), { staleTime: 5 * 60 * 1000 })
}

function UserComponent() {
  const { user, setUser } = useContext(UserContext)
  const { data: userData } = useUserData(user.id)

  useEffect(() => {
    if (userData) setUser(userData)
  }, [userData, setUser])

  return <div>Welcome, {user ? user.name : 'Guest'}!</div>
}

// Usage:
;<AppProvider>
  <UserComponent />
</AppProvider>

Benefits of the combined approach:

Key takeaways