Chapter 3: Redux and Redux Toolkit

Why Redux?

Context is good for simple data (theme, user). But for complex applications:

  • State shared between many components
  • Complex update logic
  • Need for action history (debug)
  • Performance with many updates

Redux provides a structured and predictable solution.

Fundamental Concepts

1. Store

The store is the single source of truth. All application state is in one object.

{
    user: { id: 1, name: 'Jean' },
    products: [...],
    cart: { items: [], total: 0 },
    ui: { isLoading: false, error: null }
}

2. Actions

Actions describe what happened. They are objects with a type:

{ type: 'cart/addItem', payload: { id: 1, name: 'Product', price: 29.99 } }
{ type: 'user/login', payload: { id: 1, name: 'Jean' } }
{ type: 'ui/setLoading', payload: true }

3. Reducers

Reducers specify how the state changes in response to an action:

function cartReducer(state, action) {
    switch (action.type) {
        case 'cart/addItem':
            return {
                ...state,
                items: [...state.items, action.payload]
            };
        case 'cart/removeItem':
            return {
                ...state,
                items: state.items.filter(item => item.id !== action.payload)
            };
        default:
            return state;
    }
}

The Redux Flow

Action -> Reducer -> New State -> UI updated
  1. An event triggers an action
  2. The reducer calculates the new state
  3. The store notifies components
  4. Components re-render

Redux Toolkit: Redux Simplified

Classic Redux requires a lot of code. Redux Toolkit (RTK) simplifies everything:

npm install @reduxjs/toolkit react-redux

Create a Slice

A slice groups state, reducers and actions for a feature:

// features/counter/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';

const counterSlice = createSlice({
    name: 'counter',
    initialState: {
        value: 0
    },
    reducers: {
        increment: (state) => {
            // RTK uses Immer: you can "mutate" directly
            state.value += 1;
        },
        decrement: (state) => {
            state.value -= 1;
        },
        incrementByAmount: (state, action) => {
            state.value += action.payload;
        }
    }
});

// Export actions
export const { increment, decrement, incrementByAmount } = counterSlice.actions;

// Export reducer
export default counterSlice.reducer;

Configure the Store

// app/store.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';

export const store = configureStore({
    reducer: {
        counter: counterReducer
    }
});

Connect React to the Store

// index.js or App.js
import { Provider } from 'react-redux';
import { store } from './app/store';

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

Use Redux in a Component

// features/counter/Counter.js
import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement, incrementByAmount } from './counterSlice';

function Counter() {
    // Read state
    const count = useSelector(state => state.counter.value);

    // Get dispatch function
    const dispatch = useDispatch();

    return (
        <div>
            <p>{count}</p>
            <button onClick={() => dispatch(increment())}>+1</button>
            <button onClick={() => dispatch(decrement())}>-1</button>
            <button onClick={() => dispatch(incrementByAmount(5))}>+5</button>
        </div>
    );
}

Complete Example: Shopping Cart

The Slice

// features/cart/cartSlice.js
import { createSlice } from '@reduxjs/toolkit';

const cartSlice = createSlice({
    name: 'cart',
    initialState: {
        items: [],
        total: 0
    },
    reducers: {
        addItem: (state, action) => {
            const existingItem = state.items.find(
                item => item.id === action.payload.id
            );

            if (existingItem) {
                existingItem.quantity += 1;
            } else {
                state.items.push({ ...action.payload, quantity: 1 });
            }

            state.total = state.items.reduce(
                (sum, item) => sum + item.price * item.quantity,
                0
            );
        },

        removeItem: (state, action) => {
            state.items = state.items.filter(item => item.id !== action.payload);
            state.total = state.items.reduce(
                (sum, item) => sum + item.price * item.quantity,
                0
            );
        },

        updateQuantity: (state, action) => {
            const { id, quantity } = action.payload;
            const item = state.items.find(item => item.id === id);

            if (item) {
                item.quantity = quantity;
                if (item.quantity <= 0) {
                    state.items = state.items.filter(i => i.id !== id);
                }
            }

            state.total = state.items.reduce(
                (sum, item) => sum + item.price * item.quantity,
                0
            );
        },

        clearCart: (state) => {
            state.items = [];
            state.total = 0;
        }
    }
});

export const { addItem, removeItem, updateQuantity, clearCart } = cartSlice.actions;
export default cartSlice.reducer;

The Components

// components/ProductCard.js
import { useDispatch } from 'react-redux';
import { addItem } from '../features/cart/cartSlice';

function ProductCard({ product }) {
    const dispatch = useDispatch();

    const handleAddToCart = () => {
        dispatch(addItem(product));
    };

    return (
        <div className="product-card">
            <h3>{product.name}</h3>
            <p>{product.price} €</p>
            <button onClick={handleAddToCart}>Add to cart</button>
        </div>
    );
}
// components/Cart.js
import { useSelector, useDispatch } from 'react-redux';
import { removeItem, updateQuantity, clearCart } from '../features/cart/cartSlice';

function Cart() {
    const { items, total } = useSelector(state => state.cart);
    const dispatch = useDispatch();

    if (items.length === 0) {
        return <p>Empty cart</p>;
    }

    return (
        <div className="cart">
            <h2>Cart</h2>
            {items.map(item => (
                <div key={item.id} className="cart-item">
                    <span>{item.name}</span>
                    <input
                        type="number"
                        value={item.quantity}
                        onChange={(e) => dispatch(updateQuantity({
                            id: item.id,
                            quantity: parseInt(e.target.value)
                        }))}
                        min="0"
                    />
                    <span>{item.price * item.quantity} €</span>
                    <button onClick={() => dispatch(removeItem(item.id))}>
                        Remove
                    </button>
                </div>
            ))}
            <p><strong>Total: {total} €</strong></p>
            <button onClick={() => dispatch(clearCart())}>Clear cart</button>
        </div>
    );
}

Async Actions with createAsyncThunk

For API calls:

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

// Async action
export const fetchUsers = createAsyncThunk(
    'users/fetchUsers',
    async () => {
        const response = await fetch('/api/users');
        return response.json();
    }
);

const usersSlice = createSlice({
    name: 'users',
    initialState: {
        items: [],
        loading: false,
        error: null
    },
    reducers: {},
    extraReducers: (builder) => {
        builder
            .addCase(fetchUsers.pending, (state) => {
                state.loading = true;
                state.error = null;
            })
            .addCase(fetchUsers.fulfilled, (state, action) => {
                state.loading = false;
                state.items = action.payload;
            })
            .addCase(fetchUsers.rejected, (state, action) => {
                state.loading = false;
                state.error = action.error.message;
            });
    }
});

export default usersSlice.reducer;
// Usage
function UserList() {
    const { items, loading, error } = useSelector(state => state.users);
    const dispatch = useDispatch();

    useEffect(() => {
        dispatch(fetchUsers());
    }, [dispatch]);

    if (loading) return <p>Loading...</p>;
    if (error) return <p>Error: {error}</p>;

    return (
        <ul>
            {items.map(user => (
                <li key={user.id}>{user.name}</li>
            ))}
        </ul>
    );
}

Selectors

Selectors are functions to extract data from the store:

// features/cart/cartSelectors.js

// Simple selector
export const selectCartItems = state => state.cart.items;
export const selectCartTotal = state => state.cart.total;

// Computed selector
export const selectCartItemCount = state =>
    state.cart.items.reduce((count, item) => count + item.quantity, 0);

// Usage
function CartIcon() {
    const itemCount = useSelector(selectCartItemCount);
    return <span className="cart-icon">🛒 {itemCount}</span>;
}

When to Use Redux vs Context?

Criteria Context Redux
Simple global data (theme, user) ❌ Overkill
Shared state between distant components
Complex update logic
Async actions Manual ✅ createAsyncThunk
DevTools (time-travel debug)
Large application ❌ Performance
src/
  app/
    store.js
  features/
    cart/
      cartSlice.js
      cartSelectors.js
      Cart.js
      CartIcon.js
    users/
      usersSlice.js
      UserList.js
    products/
      productsSlice.js
      ProductList.js

Key Points to Remember

  1. Single store: one single source of truth
  2. Actions: describe what happened
  3. Reducers: calculate the new state
  4. Redux Toolkit: simplifies Redux with createSlice
  5. useSelector: read state
  6. useDispatch: send actions
  7. createAsyncThunk: async actions

<- Previous Chapter | Back to module | Next Module: SmartMaker Architecture ->