DEV Community

Cover image for Managing State Effectively in Your React Single-Page Application
Elias Garcia
Elias Garcia

Posted on

Managing State Effectively in Your React Single-Page Application

You have already understood how to develop a single-page application (SPA) with React.js. I am sure your app must be looking good and navigating smoothly. However, there is one issue that you may face or may be facing: managing state effectively.

State management can be the most challenging part of scaling a React single-page application. I have written this article to explain why state management can be complex, and I have also written some practical strategies to resolve the issues of managing state.

State Management in React SPA

If you find the strategies and examples of state management helpful to keep your SPA easy to maintain, fast, and scalable, please leave a comment.

Why State Management Is a Challenge in React SPAs

In the React single-page application, state determines how your application behaves and what users see. Along with the growth of your app, the complexity of managing state across components also rises. These are a few of the common pain points around state management:

  • Prop Drilling: The code gets messy and complicated to maintain when we pass the state through several layers of components.
  • Complex State Logic: It becomes difficult to manage state updates between components, mainly when those components rely on each other or when they involve asynchronous tasks such as API calls.
  • Performance Issues: State changes trigger unnecessary re-renders that often slow down the SPA.
  • Scalability: Local state with useState works well with a small app, but often fails to scale for larger apps that have shared or global state needs.

State Mangement in single page application

But there is nothing to worry about, React provides built-in-tools, and the ecosystem offers powerful libraries to address these issues.

Let’s get started with the structured approach to effective state management.

Strategies for Effective State Management

1. Leverage React’s Built-in Hooks for Simplicity

If your SPAs are small or medium-sized, React’s built-in hooks like useState and useReducer would be sufficient.

useState: Ideal for simple, component-level state like form inputs or toggles.

import { useState } from 'react';

function Counter() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

useReducer: Better for complex state logic within a component, such as managing a form with multiple fields or a multi-step wizard.

import { useReducer } from 'react';

const initialState = { count: 0 };
function reducer(state, action) {
  switch (action.type) {
    case 'increment':
      return { count: state.count + 1 };
    case 'decrement':
      return { count: state.count - 1 };
    default:
      return state;
  }
}

function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <div>
      <p>Count: {state.count}</p>
      <button onClick={() => dispatch({ type: 'increment' })}>Increment</button>
      <button onClick={() => dispatch({ type: 'decrement' })}>Decrement</button>
    </div>
  );
} 
Enter fullscreen mode Exit fullscreen mode

Best Practice: Keep the state as local as possible. Only lift state to parent components or global stores when multiple components need access.

2. Use Context API to Avoid Prop Drilling

React’s Context API eliminates the need for prop drilling when multiple components need access to the same state, such as user authentication or theme settings.

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

const ThemeContext = createContext();

function App() {
  const [theme, setTheme] = useState('light');
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Toolbar />
    </ThemeContext.Provider>
  );
}

function Toolbar() {
  const { theme, setTheme } = useContext(ThemeContext);
  return (
    <div>
      <p>Current Theme: {theme}</p>
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
        Toggle Theme
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Best Practice: Combine Context with useReducer for complex state updates to ensure predictable state transitions. Make sure, you are not overusing Context for all state, as it often leads to unnecessary re-renders.

3. Adopt State Management Libraries for Large Apps

External libraries like Redux, Zustand, or Jotai provide robust solutions for complex singe-page applications with global state or intricate data flows.

Redux (with Redux Toolkit): Offers a centralized store and predictable state updates, ideal for large teams and apps requiring strict structure.

import { createSlice, configureStore } from '@reduxjs/toolkit';
import { Provider, useDispatch, useSelector } from 'react-redux';

const counterSlice = createSlice({
  name: 'counter',
  initialState: { value: 0 },
  reducers: {
    increment: (state) => { state.value += 1; },
    decrement: (state) => { state.value -= 1; },
  },
});

const store = configureStore({ reducer: counterSlice.reducer });
const { increment, decrement } = counterSlice.actions;

function Counter() {
  const count = useSelector((state) => state.value);
  const dispatch = useDispatch();
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => dispatch(increment())}>Increment</button>
      <button onClick={() => dispatch(decrement())}>Decrement</button>
    </div>
  );
}

function App() {
  return (
    <Provider store={store}>
      <Counter />
    </Provider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Zustand: Lightweight and flexible, perfect for simpler global state needs.

import create from 'zustand';

const useStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}));

function Counter() {
  const { count, increment, decrement } = useStore();
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Jotai: Atomic state management for fine-grained updates, integrating seamlessly with React’s component model.

import { atom, useAtom } from 'jotai';

const countAtom = atom(0);

function Counter() {
  const [count, setCount] = useAtom(countAtom);
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <button onClick={() => setCount(count - 1)}>Decrement</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Best Practice: Choose based on your app’s needs:

  • Redux for strict structure and debugging (use Redux Toolkit to reduce boilerplate).
  • Zustand for simplicity and minimal setup.
  • Jotai for fine-grained control and scalability.

4. Optimize Performance with Memoization

State changes can trigger unnecessary re-renders, slowing down your SPA. Use React’s memoization tools to optimize performance:

React.memo: Prevents re-rendering of components when props haven’t changed.

const MemoizedComponent = React.memo(({ data }) => <div>{data}</div>);
Enter fullscreen mode Exit fullscreen mode

useMemo: Memoizes expensive computations.

const expensiveValue = useMemo(() => computeExpensiveValue(data), [data]);
Enter fullscreen mode Exit fullscreen mode

useCallback: Memoizes functions to prevent re-creation on every render.

const handleClick = useCallback(() => doSomething(), []);
Enter fullscreen mode Exit fullscreen mode

Best Practice: Apply memoization only when performance issues arise, as premature optimization can add complexity.

5. Handle Asynchronous State with Data Fetching Libraries

SPAs often rely on API data, requiring state synchronization. Libraries like React Query or SWR simplify async state management.

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

function UserProfile() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user'],
    queryFn: async () => {
      const res = await fetch('https://api.example.com/user');
      return res.json();
    },
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  return <div>Welcome, {data.name}!</div>;
}
Enter fullscreen mode Exit fullscreen mode

6. Structure State Logically

Group Related State: Store related data (e.g., form fields) in a single object or reducer to simplify updates.

Normalize Data: For lists or nested data, normalize state to avoid deeply nested structures.

Separate UI and Data State: Keep UI state (e.g., isModalOpen) local and data state (e.g., API results) global.

Example (Normalized State with useReducer):

const initialState = {
  users: {},
  userIds: [],
};

function reducer(state, action) {
  switch (action.type) {
    case 'addUser':
      return {
        users: { ...state.users, [action.payload.id]: action.payload },
        userIds: [...state.userIds, action.payload.id],
      };
    default:
      return state;
  }
}
Enter fullscreen mode Exit fullscreen mode

Best Practice: Plan state structure upfront to avoid refactoring. Use TypeScript for type safety.

7. Encapsulate Logic in Custom Hooks

Custom hooks make stateful logic reusable and testable.

import { useState } from 'react';

function useCounter(initialValue = 0) {
  const [count, setCount] = useState(initialValue);
  const increment = () => setCount(count + 1);
  const decrement = () => setCount(count - 1);
  return { count, increment, decrement };
}

function Counter() {
  const { count, increment, decrement } = useCounter();
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Best Practice: Name hooks clearly (e.g., useCounter) and keep them focused on a single concern.

8. Plan for Scalability

  • Small Apps: Start with useState or useReducer for local state.
  • Medium Apps: Use Context for shared state, combined with useReducer or custom hooks.
  • Large Apps: Adopt Redux, Zustand, or Jotai, and use React Query for async data.

Best Practice: Refactor to a more robust solution only when complexity justifies it. Avoid over-engineering early.

Example: Putting It All Together

Here’s a sample SPA structure combining these strategies:

Local State: Use useState for a form component.
Shared State: Use Context for theme settings.
Async Data: Use React Query for fetching user data.
Custom Hooks: Encapsulate form logic.

import { createContext, useContext, useState } from 'react';
import { useQuery } from '@tanstack/react-query';

const ThemeContext = createContext();

function useForm() {
  const [input, setInput] = useState('');
  const handleChange = (e) => setInput(e.target.value);
  return { input, handleChange };
}

function App() {
  const [theme, setTheme] = useState('light');
  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <div className={theme === 'light' ? 'bg-white' : 'bg-gray-800'}>
        <UserProfile />
        <Form />
      </div>
    </ThemeContext.Provider>
  );
}

function UserProfile() {
  const { data, isLoading } = useQuery({
    queryKey: ['user'],
    queryFn: async () => {
      const res = await fetch('https://api.example.com/user');
      return res.json();
    },
  });
  if (isLoading) return <div>Loading...</div>;
  return <div>Welcome, {data.name}!</div>;
}

function Form() {
  const { input, handleChange } = useForm();
  const { theme, setTheme } = useContext(ThemeContext);
  return (
    <div>
      <input value={input} onChange={handleChange} className="border p-2" />
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
        Toggle Theme
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

State management doesn’t have to be a roadblock in your React SPA. By understanding your app’s needs and leveraging the right tools, you can create a maintainable, performant, and scalable application. If you need to outsource this project to avoid such complexity, always connect with a reliable React.js development company with a good record.

Plan your state structure early, keep components focused, and optimize only when necessary. With these strategies, you’ll turn state management from a challenge into a strength, ensuring your SPA delivers a seamless user experience.

Keep a note of the following takeaways:

Start Simple: Use useState or useReducer for small apps, and scale to Context or libraries as needed.

Optimize Wisely: Apply memoization (React.memo, useMemo, useCallback) only when performance issues arise.

Handle Async Gracefully: Use React Query or SWR for API-driven state.

Structure Thoughtfully: Normalize data, separate UI and data state, and use custom hooks for reusability.

Debug Efficiently: Use React DevTools or Redux DevTools to inspect state changes.

Type Safety: Consider TypeScript to catch state-related errors early.

Top comments (0)