ReactJs
State Management

State Management in React

State management refers to handling and sharing state (data) across different components in a React application. It’s essential for building dynamic, interactive applications where components need to communicate and share data effectively.

As your React application grows, managing the state across components becomes more complex, and this is where state management tools come in. Different state management solutions provide various approaches to address this problem.

Here are some of the most popular state management tools in React:


1. React Context API

The Context API allows you to share state across the component tree without having to pass props manually at every level (also known as prop drilling). It’s built into React and works well for small-to-medium apps.

How it works:

  • Provider: Wraps the root component or subtree and makes the state available to child components.
  • Consumer: Any component that subscribes to the shared state via the useContext hook.

Example: Sharing Theme across Components

// ThemeContext.js
import { createContext, useState, useContext } from 'react';
 
const ThemeContext = createContext();
 
export const useTheme = () => useContext(ThemeContext);
 
export const ThemeProvider = ({ children }) => {
  const [theme, setTheme] = useState('light');
 
  const toggleTheme = () => {
    setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light'));
  };
 
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};
 
// App.js
import { ThemeProvider, useTheme } from './ThemeContext';
 
const ThemedComponent = () => {
  const { theme, toggleTheme } = useTheme();
 
  return (
    <div style={{ backgroundColor: theme === 'light' ? '#fff' : '#333', color: theme === 'light' ? '#000' : '#fff' }}>
      <p>Current theme: {theme}</p>
      <button onClick={toggleTheme}>Toggle Theme</button>
    </div>
  );
};
 
const App = () => (
  <ThemeProvider>
    <ThemedComponent />
  </ThemeProvider>
);
 
export default App;

When to use:

  • For simple global states like themes, user authentication, or language settings.
  • Best for small to moderately sized applications.

2. Redux

Redux is a predictable state container for JavaScript apps. It centralizes the state into a single store and allows for explicit control over state updates using actions and reducers. Redux is often used in larger applications with complex state interactions.

How it works:

  • Store: Holds the entire state of the application.
  • Actions: Describes what happened (e.g., INCREMENT, DECREMENT).
  • Reducers: Specifies how the state changes in response to actions.
  • Dispatch: Sends an action to update the state.

Example: Counter App with Redux

// actions.js
export const increment = () => ({ type: 'INCREMENT' });
export const decrement = () => ({ type: 'DECREMENT' });
 
// reducer.js
const initialState = { count: 0 };
 
export const counterReducer = (state = initialState, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return { count: state.count + 1 };
    case 'DECREMENT':
      return { count: state.count - 1 };
    default:
      return state;
  }
};
 
// store.js
import { createStore } from 'redux';
import { counterReducer } from './reducer';
 
export const store = createStore(counterReducer);
 
// App.js
import { Provider, useDispatch, useSelector } from 'react-redux';
import { increment, decrement } from './actions';
 
const Counter = () => {
  const count = useSelector((state) => state.count);
  const dispatch = useDispatch();
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => dispatch(increment())}>Increment</button>
      <button onClick={() => dispatch(decrement())}>Decrement</button>
    </div>
  );
};
 
const App = () => (
  <Provider store={store}>
    <Counter />
  </Provider>
);
 
export default App;

When to use:

  • Best for large applications with complex state interactions.
  • When you need strict control and debugging (e.g., using Redux DevTools).
  • Ideal for applications that require centralized state with many actions.

3. MobX

MobX is a state management library that makes it easy to manage and react to state changes. It uses observables and makes the components reactive. Unlike Redux, MobX is less rigid and focuses more on simplicity and automatic updates when state changes.

How it works:

  • Observables: Represents the state.
  • Actions: Modify the state.
  • Reactions: Automatically reflect state changes in the UI.

Example: Counter App with MobX

// store.js
import { makeAutoObservable } from 'mobx';
 
class CounterStore {
  count = 0;
 
  constructor() {
    makeAutoObservable(this);
  }
 
  increment() {
    this.count++;
  }
 
  decrement() {
    this.count--;
  }
}
 
export const counterStore = new CounterStore();
 
// App.js
import { observer } from 'mobx-react';
import { counterStore } from './store';
 
const Counter = observer(() => (
  <div>
    <p>Count: {counterStore.count}</p>
    <button onClick={() => counterStore.increment()}>Increment</button>
    <button onClick={() => counterStore.decrement()}>Decrement</button>
  </div>
));
 
const App = () => <Counter />;
 
export default App;

When to use:

  • For small to medium-sized apps where you need real-time reactive updates.
  • Useful when you prefer less boilerplate compared to Redux.
  • Excellent for apps that rely on dynamic, observable data.

4. Zustand

Zustand is a small, fast, and scalable state management library. It is lightweight and unopinionated, making it a great choice for developers who prefer simplicity without the overhead of Redux or the reactivity of MobX.

How it works:

  • State: Managed with a simple useStore hook.
  • Actions: Mutate the state within the store.
  • Subscribers: React to state changes automatically.

Example: Counter App with Zustand

// store.js
import create from 'zustand';
 
export const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}));
 
// App.js
import { useCounterStore } from './store';
 
const Counter = () => {
  const { count, increment, decrement } = useCounterStore();
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
    </div>
  );
};
 
const App = () => <Counter />;
 
export default App;

When to use:

  • For small to medium-sized applications where simplicity is key.
  • When Redux feels too heavy and MobX feels unnecessary.
  • Great for developers who want a minimalistic and fast state management solution.

Summary of State Management Tools

ToolBest ForProsCons
Context APISmall-to-medium appsBuilt into React, no external libraryNot suited for very large or complex apps
ReduxLarge, complex appsPredictable state management, strict flowVerbose boilerplate, learning curve
MobXReactive state with real-time updatesAutomatic updates, simple syntaxHarder to debug in large apps, less structure
ZustandSimple, lightweight state managementMinimal API, fast, no boilerplateLacks the structure of Redux for large apps

Each tool addresses different complexities of state management, and the choice depends on the needs of your application and the scale of the state you need to manage.

Redux Example

In this example, we'll recreate the functionality of fetching users from a dummy API (jsonplaceholder.typicode.com/users) using traditional Redux without Redux Toolkit. This will involve setting up actions, reducers, and a store manually.


Steps

  1. Install Redux and React-Redux:

    npm install redux react-redux axios
  2. Set up Redux with actions, reducers, and store.

  3. Dispatch actions and display data in React components.


Fetching Users from API

  1. Create Redux Store and API Integration

store.js - Setting up the Redux Store

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import rootReducer from './reducers';
 
const store = createStore(rootReducer, applyMiddleware(thunk));
 
export default store;

Here:

  • We are using redux-thunk middleware to handle asynchronous actions (like API requests).
  • createStore is used to create the Redux store, and applyMiddleware(thunk) allows us to write async logic in our action creators.

actions.js - Defining Action Types and Thunk Actions

import axios from 'axios';
 
// Action Types
export const FETCH_USERS_REQUEST = 'FETCH_USERS_REQUEST';
export const FETCH_USERS_SUCCESS = 'FETCH_USERS_SUCCESS';
export const FETCH_USERS_FAILURE = 'FETCH_USERS_FAILURE';
 
// Action Creators
export const fetchUsersRequest = () => ({
  type: FETCH_USERS_REQUEST,
});
 
export const fetchUsersSuccess = (users) => ({
  type: FETCH_USERS_SUCCESS,
  payload: users,
});
 
export const fetchUsersFailure = (error) => ({
  type: FETCH_USERS_FAILURE,
  payload: error,
});
 
// Async Action (Thunk)
export const fetchUsers = () => {
  return (dispatch) => {
    dispatch(fetchUsersRequest());
    axios
      .get('https://jsonplaceholder.typicode.com/users')
      .then((response) => {
        const users = response.data;
        dispatch(fetchUsersSuccess(users));
      })
      .catch((error) => {
        dispatch(fetchUsersFailure(error.message));
      });
  };
};

In this code:

  • Action types represent the different states of the API call (request, success, failure).
  • Action creators (fetchUsersRequest, fetchUsersSuccess, fetchUsersFailure) create actions to update the Redux state based on the API call.
  • The async action creator fetchUsers uses redux-thunk to make an API request and dispatches corresponding actions based on the outcome.

reducers.js - Defining Reducer to Handle State Changes

import {
  FETCH_USERS_REQUEST,
  FETCH_USERS_SUCCESS,
  FETCH_USERS_FAILURE,
} from './actions';
 
const initialState = {
  loading: false,
  users: [],
  error: '',
};
 
const userReducer = (state = initialState, action) => {
  switch (action.type) {
    case FETCH_USERS_REQUEST:
      return {
        ...state,
        loading: true,
      };
    case FETCH_USERS_SUCCESS:
      return {
        loading: false,
        users: action.payload,
        error: '',
      };
    case FETCH_USERS_FAILURE:
      return {
        loading: false,
        users: [],
        error: action.payload,
      };
    default:
      return state;
  }
};
 
export default userReducer;

In this reducer:

  • Initial state holds the loading, users, and error properties.
  • The reducer listens for the action types (FETCH_USERS_REQUEST, FETCH_USERS_SUCCESS, FETCH_USERS_FAILURE) and updates the state accordingly.

reducers/index.js - Combine Reducers (if needed)

import { combineReducers } from 'redux';
import userReducer from './reducers';
 
const rootReducer = combineReducers({
  users: userReducer,
});
 
export default rootReducer;

This file combines the reducers if there are multiple reducers in your app. Here, we only have one reducer, but it's a good practice to use combineReducers for scalability.


Dispatch Actions,Display Data

UserList.js - Fetching and Displaying Users

import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchUsers } from './actions';
 
const UserList = () => {
  const dispatch = useDispatch();
  const { loading, users, error } = useSelector((state) => state.users);
 
  useEffect(() => {
    dispatch(fetchUsers());
  }, [dispatch]);
 
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
 
  return (
    <div>
      <h2>User List</h2>
      <ul>
        {users.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
};
 
export default UserList;

In this component:

  • We use useDispatch to trigger the fetchUsers action when the component mounts.
  • The useSelector hook retrieves the loading, users, and error states from the Redux store.
  • The UI conditionally renders a loading message, error message, or the list of users based on the state.

Connecting Redux to React

App.js - Providing the Redux Store

import React from 'react';
import { Provider } from 'react-redux';
import store from './store';
import UserList from './UserList';
 
const App = () => (
  <Provider store={store}>
    <div>
      <h1>Redux Dummy API Example</h1>
      <UserList />
    </div>
  </Provider>
);
 
export default App;

In this setup:

  • We use the Provider component from react-redux to pass the Redux store to the entire React app, making it accessible via useSelector and useDispatch.

Key Concepts

  1. Action Types: Strings that define the types of actions to be dispatched (e.g., 'FETCH_USERS_REQUEST').

  2. Action Creators: Functions that return action objects to trigger state updates (e.g., { type: 'FETCH_USERS_SUCCESS', payload: users }).

  3. Reducers: Pure functions that determine how the state changes based on dispatched actions.

  4. Thunk Middleware: Allows async logic like API requests by dispatching multiple actions (e.g., fetchUsers() with API calls).

  5. useDispatch: Hook to dispatch actions in React components.

  6. useSelector: Hook to access the Redux state within React components.

  7. Store: Centralized state container where all app data is managed and updated based on dispatched actions.

  8. Provider: React component that wraps the app, giving access to the Redux store throughout the component tree.

Redux Toolkit

Redux Toolkit simplifies state management in React applications. It provides utilities to streamline the process of working with Redux, including setting up the store, creating reducers, and handling async logic.

Here's a detailed explanation with a better example that includes an API call:

Overview

  1. Redux Toolkit: A set of tools to simplify Redux development, including createSlice, configureStore, and createAsyncThunk.
  2. createSlice: Generates action creators and reducers based on a slice of state.
  3. configureStore: Sets up the Redux store with good defaults.
  4. createAsyncThunk: Handles async actions and integrates them into the Redux lifecycle.

Fetching Data from an API

Let's build a small app that fetches a list of users from an API using Redux Toolkit.

** Step 1: Install Dependencies**

Make sure you have Redux Toolkit and React-Redux installed:

npm install @reduxjs/toolkit react-redux

Step 2: Set Up the Redux Slice

Create a slice using createSlice to handle user data.

// src/features/users/userSlice.js
 
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
 
// Initial state
const initialState = {
  users: [],
  status: 'idle', // or 'loading' or 'succeeded' or 'failed'
  error: null,
};
 
// Async thunk to fetch users
export const fetchUsers = createAsyncThunk('users/fetchUsers', async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  if (!response.ok) {
    throw new Error('Failed to fetch');
  }
  return response.json();
});
 
// Create the slice
const userSlice = createSlice({
  name: 'users',
  initialState,
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.users = action.payload;
      })
      .addCase(fetchUsers.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.error.message;
      });
  },
});
 
export default userSlice.reducer;

Step 3: Configure the Store

Set up the Redux store with the slice reducer.

// src/app/store.js
 
import { configureStore } from '@reduxjs/toolkit';
import userReducer from '../features/users/userSlice';
 
export const store = configureStore({
  reducer: {
    users: userReducer,
  },
});

Step 4: Provide the Store to React

Wrap your application with the Provider component to make the store available.

// src/index.js
 
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { store } from './app/store';
import App from './App';
 
ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

Step 5: Create a Component to Display Users

Use useDispatch and useSelector to interact with the Redux store in your component.

// src/components/UserList.js
 
import React, { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { fetchUsers } from '../features/users/userSlice';
 
const UserList = () => {
  const dispatch = useDispatch();
  const { users, status, error } = useSelector((state) => state.users);
 
  useEffect(() => {
    if (status === 'idle') {
      dispatch(fetchUsers());
    }
  }, [status, dispatch]);
 
  let content;
 
  if (status === 'loading') {
    content = <div>Loading...</div>;
  } else if (status === 'succeeded') {
    content = (
      <ul>
        {users.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    );
  } else if (status === 'failed') {
    content = <div>{error}</div>;
  }
 
  return <div>{content}</div>;
};
 
export default UserList;

Step 6: Use the Component in Your App

Include the UserList component in your main app.

// src/App.js
 
import React from 'react';
import UserList from './components/UserList';
 
const App = () => {
  return (
    <div>
      <h1>User List</h1>
      <UserList />
    </div>
  );
};
 
export default App;

Summary

  • Slice: Defines the state, reducers, and async logic using createSlice and createAsyncThunk.
  • Store: Configured with configureStore and provided to the React app.
  • Component: Uses Redux state and dispatches actions to fetch and display data.

This example demonstrates how to set up Redux Toolkit with async logic for API calls and integrate it with a React application.

Persist Redux State

To maintain Redux state across page reloads, follow these steps to save and load the state using localStorage.

1. Set Up Functions for State Persistence

1.1. Load State from localStorage

  • Purpose: Retrieve the saved state when initializing the Redux store.
  • Code:
const loadStateFromLocalStorage = () => {
  try {
    const serializedState = localStorage.getItem('reduxState');
    return serializedState ? JSON.parse(serializedState) : undefined;
  } catch {
    return undefined; // Handle errors gracefully
  }
};

1.2. Save State to localStorage

  • Purpose: Save the current state to localStorage whenever the state changes.
  • Code:
const saveStateToLocalStorage = (state) => {
  try {
    const serializedState = JSON.stringify(state);
    localStorage.setItem('reduxState', serializedState);
  } catch {
    // Handle errors if any
  }
};

2. Configure the Redux Store

2.1. Create the Store with Persisted State

  • Purpose: Initialize the Redux store with any previously saved state.
  • Code:
import { createStore } from 'redux';
import rootReducer from './reducers'; // Import your rootReducer
 
// Load persisted state
const persistedState = loadStateFromLocalStorage();
 
// Create the store with the persisted state
const store = createStore(rootReducer, persistedState);

2.2. Subscribe to Store Changes

  • Purpose: Ensure that any updates to the Redux state are saved to localStorage.
  • Code:
store.subscribe(() => {
  saveStateToLocalStorage(store.getState());
});

3. Integrate the Store with React

3.1. Provide the Store to Your React Application

  • Purpose: Wrap your React application in a <Provider> component to pass the store down to your components.
  • Code:
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import store from './store'; // Import the configured store
import App from './App'; // Import your main App component
 
ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
);

4. Optional: Clear State on Events

4.1. Remove State from localStorage

  • Purpose: Clear persisted state (e.g., upon user logout).
  • Code:
const clearStateFromLocalStorage = () => {
  try {
    localStorage.removeItem('reduxState');
  } catch {
    // Handle errors if any
  }
};
 
// Call clearStateFromLocalStorage when needed