Mastering React State Management: From Basics to Advanced Techniques

Venkata Ramana Seelam
IceApple Tech Talks
4 min readJul 10, 2024

State management is a cornerstone of building robust applications in React. As your application grows, effectively managing state becomes increasingly crucial. In this blog post, we’ll explore state management in React, starting from the basics with useState and progressing to advanced techniques like the Context API, Redux, and Redux Toolkit. We'll also discuss common problems associated with each stage and how to address them.

Basic State Management with useState

React’s useState hook is the simplest way to manage state in a functional component. It allows you to add state variables to your components easily.

Example:

import React, { useState } from 'react';

function Counter() {
const [count, setCount] = useState(0);

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

export default Counter;

useState is perfect for managing simple, localized state. However, as your application grows, you might encounter the issue of prop drilling.

Prop Drilling Problem

Prop drilling occurs when you pass state and functions down through multiple layers of components. This can make your code harder to read and maintain.

Example:

function App() {
const [user, setUser] = useState({ name: 'John', age: 30 });

return <Profile user={user} />;
}

function Profile({ user }) {
return <ProfileDetails user={user} />;
}

function ProfileDetails({ user }) {
return <p>{user.name}</p>;
}

As you can see, the user prop needs to be passed through each component even if it is not directly used by them. This is where the Context API comes in handy.

Solving Prop Drilling with Context API

The Context API provides a way to share values like state across the component tree without passing props down manually at every level.

Example:

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

const UserContext = createContext();

function App() {
const [user, setUser] = useState({ name: 'John', age: 30 });

return (
<UserContext.Provider value={user}>
<Profile />
</UserContext.Provider>
);
}

function Profile() {
return <ProfileDetails />;
}

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

While the Context API is powerful for solving prop drilling, it can become cumbersome when dealing with complex state logic and updates. This is where Redux comes into play.

Advanced State Management with Redux

Redux is a popular library for managing global state in a predictable way. It adheres to the principles of having a single source of truth, state being read-only, and changes being made with pure functions.

Problems with Context API:

  • Complexity in Updates: Managing complex state logic with the Context API can lead to deeply nested providers and consumers, making the code harder to follow.
  • Performance Issues: Frequent state updates can lead to unnecessary re-renders of components consuming the context.

Example:

// actions.js
export const increment = () => ({ type: 'INCREMENT' });

// reducer.js
const initialState = { count: 0 };

function counterReducer(state = initialState, action) {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 };
default:
return state;
}
}

// store.js
import { createStore } from 'redux';
import counterReducer from './reducer';

const store = createStore(counterReducer);

// App.js
import React from 'react';
import { Provider, useSelector, useDispatch } from 'react-redux';
import store from './store';
import { increment } from './actions';

function Counter() {
const count = useSelector((state) => state.count);
const dispatch = useDispatch();

return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch(increment())}>Increment</button>
</div>
);
}

function App() {
return (
<Provider store={store}>
<Counter />
</Provider>
);
}

export default App;

Simplifying Redux with Redux Toolkit

Redux Toolkit is the official, opinionated, and batteries-included toolset for efficient Redux development. It simplifies the configuration and reduces boilerplate code.

Example:

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

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

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

export const { increment } = counterSlice.actions;
export default store;

// App.js
import React from 'react';
import { Provider, useSelector, useDispatch } from 'react-redux';
import store, { increment } from './store';

function Counter() {
const count = useSelector((state) => state.count);
const dispatch = useDispatch();

return (
<div>
<p>Count: {count}</p>
<button onClick={() => dispatch(increment())}>Increment</button>
</div>
);
}

function App() {
return (
<Provider store={store}>
<Counter />
</Provider>
);
}

export default App;

Additional Topics in Redux Toolkit

Redux Toolkit provides several advanced features that make state management even more powerful and efficient.

Async Thunk

Redux Toolkit includes createAsyncThunk, which simplifies handling asynchronous actions like fetching data from an API. It helps manage the lifecycle of an async request by generating action types for pending, fulfilled, and rejected states. This reduces the boilerplate code and makes it easier to handle loading states and errors within your application.

Example:

// store.js
import { configureStore, createSlice, createAsyncThunk } from '@reduxjs/toolkit';
import axios from 'axios';

export const fetchUser = createAsyncThunk('user/fetchUser', async (userId) => {
const response = await axios.get(`/api/user/${userId}`);
return response.data;
});

const userSlice = createSlice({
name: 'user',
initialState: { user: null, status: 'idle' },
reducers: {},
extraReducers: (builder) => {
builder
.addCase(fetchUser.pending, (state) => {
state.status = 'loading';
})
.addCase(fetchUser.fulfilled, (state, action) => {
state.status = 'succeeded';
state.user = action.payload;
})
.addCase(fetchUser.rejected, (state) => {
state.status = 'failed';
});
},
});

const store = configureStore({
reducer: userSlice.reducer,
});

export default store;

Mutating State

One of the notable features of Redux Toolkit is that it uses Immer under the hood. This allows you to write “mutating” logic in reducers, which actually produces immutable updates. Immer provides a draft state that can be mutated directly without affecting the original state. When the mutations are complete, Immer produces a new immutable state based on the changes made to the draft state.

Example:

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

Conclusion

Effective state management is key to building scalable React applications. Starting with useState for simple cases, you can address prop drilling with the Context API. For more complex state logic and global state management, Redux and Redux Toolkit provide powerful solutions. With features like async thunks, middleware, and the ability to write mutating logic safely, Redux Toolkit makes managing state in your React applications more efficient and maintainable.

Learn more about Redux Toolkit here.

Thanks for reading : ) Happy coding!

--

--