Jobnik

Dss

React Zero to Hero: Part 3 – State Management with Redux and Redux Toolkit

Welcome to Part 3 of our “React Zero to Hero” series! In the previous parts, we mastered the core of React, including JSX, components, props, `useState`, `useEffect`, `useContext`, `useRef`, `useReducer`, and React Router. As your applications grow in size and complexity, managing state across many components can become challenging. This is where Redux comes in.

The Challenge of State Management in Large React Apps

While `useState` and `useContext` are excellent for local and component-tree-scoped state, they can lead to issues in larger applications:

  • Prop Drilling: Passing props down multiple levels of the component tree can become tedious and make your code harder to maintain.
  • Scattered State: When related state lives in different, unrelated components, it becomes difficult to track and synchronize.
  • Complex Interactions: As user interactions become more intricate, managing state transitions and side effects across many components can get messy.

This is precisely the problem Redux aims to solve by providing a predictable state container.

Introducing Redux: A Predictable State Container

Redux is a standalone JavaScript library (not React-specific, though commonly used with it) that helps you manage application state. It enforces a strict unidirectional data flow and three core principles to make state changes predictable:

  1. Single Source of Truth: The entire state of your application is stored in a single JavaScript object tree within a single Store.
  2. State is Read-Only: The only way to change the state is by emitting an Action, a plain JavaScript object describing what happened.
  3. Changes are Made with Pure Functions: To specify how the state tree is transformed by actions, you write pure Reducers.

Core Concepts of Redux

1. Store

The Redux store is the single source of truth for your application’s state. It brings together the actions and reducers. The store has a few responsibilities:

  • Holds the application state.
  • Allows access to the state via getState().
  • Allows the state to be updated via dispatch(action).
  • Registers listeners via subscribe(listener).
  • Handles unregistering of listeners via the function returned by subscribe(listener).

2. Actions

Actions are plain JavaScript objects that represent an event or a command. They are the only way to send data from your application to the Redux store. Actions must have a type property, which is a string constant that describes the action. They can also contain a payload with any necessary data.


// Action example
{
  type: 'ADD_TODO',
  payload: {
    id: 1,
    text: 'Learn Redux'
  }
}

// Action creator (function that returns an action)
function addTodo(text) {
  return {
    type: 'ADD_TODO',
    payload: {
      id: Date.now(), // Unique ID generation
      text
    }
  };
}

3. Reducers

Reducers are pure functions that take the current state and an action as arguments, and return the new state. They are the heart of Redux, as they specify how the application’s state changes in response to actions. Importantly, reducers must be pure:

  • They should not mutate the arguments (`state` or `action`).
  • They should not perform side effects (e.g., API calls, routing changes).
  • Given the same arguments, they should always return the same result.

const initialState = {
  todos: []
};

function todosReducer(state = initialState, action) {
  switch (action.type) {
    case 'ADD_TODO':
      return {
        ...state,
        todos: [...state.todos, action.payload]
      };
    case 'TOGGLE_TODO':
      return {
        ...state,
        todos: state.todos.map(todo =>
          todo.id === action.payload.id ? { ...todo, completed: !todo.completed } : todo
        )
      };
    default:
      return state;
  }
}

4. Dispatch

dispatch is the method available on the store that you use to send actions to the reducer. It’s the only way to trigger a state change.


// Assuming `store` is your Redux store instance
store.dispatch(addTodo('Build a React app'));

5. Selectors (Good Practice)

Selectors are functions that take the Redux state as an argument and return derived data. They help encapsulate state logic and promote reusability.


const selectTodos = state => state.todos;
const selectCompletedTodos = state => state.todos.filter(todo => todo.completed);

Setting up Redux with React (Traditional Way)

To integrate Redux with React, you typically use the react-redux library. This library provides bindings to connect your React components to the Redux store.

Installation (Conceptual)


npm install redux react-redux
# or
yarn add redux react-redux

Basic Counter Example with Traditional Redux

Let’s build a simple counter application.


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

// Action Types
const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';

// Action Creators
export const increment = () => ({ type: INCREMENT });
export const decrement = () => ({ type: DECREMENT });

// Initial State
const initialState = {
  count: 0
};

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

// Create Store
const store = createStore(counterReducer);

export default store;

// src/index.js (or equivalent entry file)
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import App from './App';
import store from './redux/store';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <Provider store={store}> {/* Wrap your app with Provider */}
      <App />
    </Provider>
  </React.StrictMode>
);

// src/components/Counter.js
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement } from '../redux/store'; // Import action creators

function Counter() {
  // Access state from the Redux store
  const count = useSelector(state => state.count);
  // Get the dispatch function to send actions
  const dispatch = useDispatch();

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

export default Counter;

// src/App.js
import React from 'react';
import Counter from './components/Counter';

function App() {
  return (
    <div className="App">
      <h1>Redux Counter Example</h1>
      <Counter />
    </div>
  );
}

export default App;

Redux DevTools: Your Debugging Friend

The Redux DevTools Extension is an invaluable tool for debugging Redux applications. It allows you to inspect state changes, dispatched actions, and even time-travel debug your application by replaying actions.

Installation: Add the extension to your browser (Chrome/Firefox). For your Redux store, you might need to apply a simple enhancer for it to connect, but Redux Toolkit handles this automatically.

Redux Toolkit (RTK): The Modern & Recommended Way

Redux Toolkit is the official, opinionated, batteries-included toolset for efficient Redux development. It simplifies common Redux tasks, reduces boilerplate, and encourages best practices. It’s the recommended way to write Redux code today.

Key Features of Redux Toolkit:

  • configureStore: Wraps `createStore` to provide good defaults and simplify store setup.
  • createSlice: Generates action creators and action types that correspond to the reducers and state.
  • createAsyncThunk: Simplifies working with asynchronous logic (like API calls).

Refactoring Counter Example with Redux Toolkit


// src/redux/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';

export const counterSlice = createSlice({
  name: 'counter',
  initialState: {
    count: 0,
  },
  reducers: {
    increment: (state) => {
      // Redux Toolkit allows us to write "mutating" logic in reducers;
      // it doesn't actually mutate the state because it uses the Immer library,
      // which detects changes to a "draft state" and produces a brand new
      // immutable state based off those changes.
      state.count += 1;
    },
    decrement: (state) => {
      state.count -= 1;
    },
    incrementByAmount: (state, action) => {
      state.count += action.payload;
    },
  },
});

// Action creators are generated for each case reducer function
export const { increment, decrement, incrementByAmount } = counterSlice.actions;

export default counterSlice.reducer;

// src/redux/store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer, // Assign the counter reducer to the 'counter' slice of state
  },
});

// src/components/CounterRTK.js (Updated Counter component)
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement, incrementByAmount } from '../redux/counterSlice';

function CounterRTK() {
  // Access state from the Redux store
  // Note: we access state.counter.count because we named the slice 'counter' in configureStore
  const count = useSelector(state => state.counter.count);
  const dispatch = useDispatch();

  return (
    <div>
      <h2>RTK Counter: {count}</h2>
      <button onClick={() => dispatch(increment())}>Increment</button>
      <button onClick={() => dispatch(decrement())}>Decrement</button>
      <button onClick={() => dispatch(incrementByAmount(5))}>Increment by 5</button>
    </div>
  );
}

export default CounterRTK;

// src/App.js (update to use CounterRTK)
import React from 'react';
import CounterRTK from './components/CounterRTK';

function App() {
  return (
    <div className="App">
      <h1>Redux Toolkit Counter Example</h1>
      <CounterRTK />
    </div>
  );
}

export default App;

Asynchronous Operations with Redux (`createAsyncThunk`)

Handling asynchronous logic (like fetching data from an API) is a common pattern in web applications. Redux Toolkit’s `createAsyncThunk` simplifies this greatly.

Example: Fetching Users with createAsyncThunk


// src/redux/usersSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

// First, define the thunk
export const fetchUsers = createAsyncThunk(
  'users/fetchUsers',
  async () => {
    // Simulate API call
    const response = await new Promise(resolve => setTimeout(() => {
      resolve([
        { id: 1, name: 'Alice' },
        { id: 2, name: 'Bob' },
        { id: 3, name: 'Charlie' },
      ]);
    }, 1500));
    return response; // This will be the `action.payload` in `fulfilled`
  }
);

export const usersSlice = createSlice({
  name: 'users',
  initialState: {
    list: [],
    status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
    error: null,
  },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.list = action.payload; // Add fetched users to the state
      })
      .addCase(fetchUsers.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.error.message;
      });
  },
});

export default usersSlice.reducer;

// src/redux/store.js (Add usersReducer)
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
import usersReducer from './usersSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    users: usersReducer, // Add the users reducer
  },
});

// src/components/UserList.js
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { fetchUsers } from '../redux/usersSlice';

function UserList() {
  const users = useSelector(state => state.users.list);
  const userStatus = useSelector(state => state.users.status);
  const error = useSelector(state => state.users.error);
  const dispatch = useDispatch();

  useEffect(() => {
    if (userStatus === 'idle') {
      dispatch(fetchUsers());
    }
  }, [userStatus, dispatch]);

  if (userStatus === 'loading') {
    return <div>Loading users...</div>;
  }

  if (userStatus === 'failed') {
    return <div>Error: {error}</div>;
  }

  return (
    <div>
      <h2>Users</h2>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

export default UserList;

// src/App.js (Update to include UserList)
import React from 'react';
import CounterRTK from './components/CounterRTK';
import UserList from './components/UserList';

function App() {
  return (
    <div className="App">
      <h1>Redux Toolkit Example</h1>
      <CounterRTK />
      <hr />
      <UserList />
    </div>
  );
}

export default App;

Best Practices for Redux

  • Always use Redux Toolkit: Unless you have a very specific reason not to, RTK simplifies Redux development significantly.
  • Keep Reducers Pure: Never mutate state directly (RTK’s `createSlice` handles this immutability for you behind the scenes). Avoid side effects.
  • Normalize State: For relational data, store entities in a lookup table (an object) by ID, and store arrays of IDs for relationships. This prevents duplication and simplifies updates. (More advanced topic, but crucial for complex data).
  • Structure Your Redux Code:
    • Feature Folders: Organize Redux files (slices, selectors) by feature, rather than by type (e.g., all reducers in one folder, all actions in another). This is the recommended approach with RTK.
    • “Ducks” Pattern (Legacy): A pattern that co-locates actions, action types, and reducers in a single file. `createSlice` effectively implements a more structured version of this.
  • Selector Usage: Use `reselect` (comes with RTK) for memoized selectors to optimize performance by avoiding unnecessary re-renders when the derived data hasn’t actually changed.
  • Avoid Complex Logic in Components: Delegate complex state manipulation, data fetching, and side effects to your Redux actions (especially `createAsyncThunk`) and reducers. Components should primarily focus on rendering UI based on props and dispatching actions.
  • Type Your Redux (TypeScript): For large projects, using TypeScript with Redux provides excellent type safety and developer experience. RTK has great TypeScript support.

Conclusion of Part 3

You’ve now been introduced to Redux, a powerful tool for managing global application state in React applications. We covered its core principles (Store, Actions, Reducers), and most importantly, learned how to leverage Redux Toolkit to write concise and efficient Redux code, including handling asynchronous operations with `createAsyncThunk`. The examples provided a practical guide to integrating Redux into your React projects.

While Redux has a steeper learning curve than `useState` and `useContext`, its benefits in large, complex applications regarding predictability, debuggability, and maintainability are immense. With Redux Toolkit, much of the traditional boilerplate is gone, making it far more approachable.

In our final part of the “React Zero to Hero” series, we will focus on more advanced best practices, performance optimizations, testing strategies, and deploying your React applications. Stay tuned for the grand finale!

Leave a Reply

Your email address will not be published. Required fields are marked *