React Development · Lesson 6 of 15

Redux State Management

When Do You Actually Need Redux?

Before reaching for Redux, ask these questions:

Is the state shared across many components that aren't parent/child?  → Maybe Redux
Does state need to be persisted across routes?                        → Maybe Redux
Do you have complex update logic with many transitions?               → Maybe Redux
Is it just fetched server data?                                       → Use React Query/SWR
Is it just UI state (open/closed, active tab)?                        → useState
Is it shared between 2-3 nearby components?                           → Lift state up

Most "I need Redux" decisions are actually "I need React Query + Context."

Redux Mental Model

UI Event → dispatch(action) → reducer(state, action) → new state → UI updates

Three core principles:

  1. Single source of truth — one store
  2. State is read-only — only change it by dispatching actions
  3. Changes via pure functions — reducers must be pure (no side effects)

Redux Toolkit (RTK) — The Modern Way

Never write vanilla Redux. RTK eliminates boilerplate and enforces best practices.

Bash
npm install @reduxjs/toolkit react-redux

Store Setup

TSX
// store/index.ts
import { configureStore } from "@reduxjs/toolkit";
import { authSlice } from "./authSlice";
import { cartSlice } from "./cartSlice";
import { productsApi } from "./productsApi";

export const store = configureStore({
  reducer: {
    auth: authSlice.reducer,
    cart: cartSlice.reducer,
    [productsApi.reducerPath]: productsApi.reducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(productsApi.middleware),
  devTools: process.env.NODE_ENV !== "production",
});

// Typed hooks — use these instead of raw useDispatch/useSelector
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

export const useAppSelector = useSelector.withTypes<RootState>();
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
TSX
// main.tsx
import { Provider } from "react-redux";
import { store } from "./store";

function App() {
  return (
    <Provider store={store}>
      <Router>
        <Routes>{/* ... */}</Routes>
      </Router>
    </Provider>
  );
}

createSlice — No More Action Creators

TSX
// store/cartSlice.ts
import { createSlice, PayloadAction } from "@reduxjs/toolkit";

interface CartItem {
  id: string;
  name: string;
  price: number;
  qty: number;
  image: string;
}

interface CartState {
  items: CartItem[];
  couponCode: string | null;
  discount: number;
}

const initialState: CartState = {
  items: [],
  couponCode: null,
  discount: 0,
};

// RTK uses Immer under the hood — you CAN mutate state here
export const cartSlice = createSlice({
  name: "cart",
  initialState,
  reducers: {
    addItem: (state, action: PayloadAction<Omit<CartItem, "qty">>) => {
      const existing = state.items.find((i) => i.id === action.payload.id);
      if (existing) {
        existing.qty += 1;
      } else {
        state.items.push({ ...action.payload, qty: 1 });
      }
    },

    removeItem: (state, action: PayloadAction<string>) => {
      state.items = state.items.filter((i) => i.id !== action.payload);
    },

    updateQty: (
      state,
      action: PayloadAction<{ id: string; qty: number }>
    ) => {
      const item = state.items.find((i) => i.id === action.payload.id);
      if (item) {
        if (action.payload.qty <= 0) {
          state.items = state.items.filter((i) => i.id !== action.payload.id);
        } else {
          item.qty = action.payload.qty;
        }
      }
    },

    applyCoupon: (
      state,
      action: PayloadAction<{ code: string; discount: number }>
    ) => {
      state.couponCode = action.payload.code;
      state.discount = action.payload.discount;
    },

    clearCart: (state) => {
      state.items = [];
      state.couponCode = null;
      state.discount = 0;
    },
  },
});

// Export actions
export const { addItem, removeItem, updateQty, applyCoupon, clearCart } =
  cartSlice.actions;

// Selectors — colocate with the slice
export const selectCartItems = (state: RootState) => state.cart.items;
export const selectCartTotal = (state: RootState) =>
  state.cart.items.reduce((sum, item) => sum + item.price * item.qty, 0);
export const selectCartCount = (state: RootState) =>
  state.cart.items.reduce((sum, item) => sum + item.qty, 0);
export const selectDiscountedTotal = (state: RootState) => {
  const total = selectCartTotal(state);
  return total - (total * state.cart.discount) / 100;
};
TSX
// Using the cart slice in components
function CartIcon() {
  const count = useAppSelector(selectCartCount);
  return (
    <button className="cart-btn">
      <ShoppingCartIcon />
      {count > 0 && <span className="badge">{count}</span>}
    </button>
  );
}

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

  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <button onClick={() => dispatch(addItem(product))}>
        Add to Cart
      </button>
    </div>
  );
}

function CartDrawer() {
  const items = useAppSelector(selectCartItems);
  const total = useAppSelector(selectCartTotal);
  const discounted = useAppSelector(selectDiscountedTotal);
  const dispatch = useAppDispatch();

  return (
    <aside className="cart-drawer">
      {items.map((item) => (
        <div key={item.id} className="cart-item">
          <img src={item.image} alt={item.name} />
          <span>{item.name}</span>
          <input
            type="number"
            value={item.qty}
            min="0"
            onChange={(e) =>
              dispatch(updateQty({ id: item.id, qty: +e.target.value }))
            }
          />
          <span>${(item.price * item.qty).toFixed(2)}</span>
          <button onClick={() => dispatch(removeItem(item.id))}></button>
        </div>
      ))}
      <div className="cart-total">
        <span>Total: ${discounted.toFixed(2)}</span>
      </div>
      <button onClick={() => dispatch(clearCart())}>Clear Cart</button>
    </aside>
  );
}

Async Thunks — Side Effects in Redux

TSX
// store/authSlice.ts
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";

interface AuthState {
  user: User | null;
  status: "idle" | "loading" | "succeeded" | "failed";
  error: string | null;
}

// createAsyncThunk generates pending/fulfilled/rejected actions automatically
export const loginThunk = createAsyncThunk(
  "auth/login",
  async (credentials: { email: string; password: string }, { rejectWithValue }) => {
    try {
      const response = await authApi.login(credentials);
      localStorage.setItem("token", response.token);
      return response.user;
    } catch (error: any) {
      // Return a known error payload instead of throwing
      return rejectWithValue(error.response?.data?.message ?? "Login failed");
    }
  }
);

export const logoutThunk = createAsyncThunk("auth/logout", async () => {
  await authApi.logout();
  localStorage.removeItem("token");
});

const authSlice = createSlice({
  name: "auth",
  initialState: { user: null, status: "idle", error: null } as AuthState,
  reducers: {
    clearError: (state) => { state.error = null; },
  },
  extraReducers: (builder) => {
    builder
      .addCase(loginThunk.pending, (state) => {
        state.status = "loading";
        state.error = null;
      })
      .addCase(loginThunk.fulfilled, (state, action) => {
        state.status = "succeeded";
        state.user = action.payload;
      })
      .addCase(loginThunk.rejected, (state, action) => {
        state.status = "failed";
        state.error = action.payload as string;
      })
      .addCase(logoutThunk.fulfilled, (state) => {
        state.user = null;
        state.status = "idle";
      });
  },
});

export const selectUser = (state: RootState) => state.auth.user;
export const selectAuthStatus = (state: RootState) => state.auth.status;
export const selectAuthError = (state: RootState) => state.auth.error;
TSX
// Using thunks in a component
function LoginForm() {
  const dispatch = useAppDispatch();
  const status = useAppSelector(selectAuthStatus);
  const error = useAppSelector(selectAuthError);
  const navigate = useNavigate();

  const handleSubmit = async (credentials) => {
    const result = await dispatch(loginThunk(credentials));
    // Check if the thunk succeeded
    if (loginThunk.fulfilled.match(result)) {
      navigate("/dashboard");
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* form fields */}
      {error && <Alert message={error} />}
      <button type="submit" disabled={status === "loading"}>
        {status === "loading" ? "Signing in..." : "Sign In"}
      </button>
    </form>
  );
}

RTK Query — Data Fetching Done Right

RTK Query eliminates the need to write thunks for data fetching. It handles caching, loading states, refetching, and cache invalidation automatically.

TSX
// store/productsApi.ts
import { createApi, fetchBaseQuery } from "@reduxjs/toolkit/query/react";

export const productsApi = createApi({
  reducerPath: "productsApi",
  baseQuery: fetchBaseQuery({
    baseUrl: "/api",
    prepareHeaders: (headers) => {
      const token = localStorage.getItem("token");
      if (token) headers.set("Authorization", `Bearer ${token}`);
      return headers;
    },
  }),
  tagTypes: ["Product", "User", "Order"],  // Cache tag system
  endpoints: (builder) => ({
    // Queries (GET)
    getProducts: builder.query<Product[], { category?: string; page?: number }>({
      query: ({ category, page = 1 } = {}) => ({
        url: "/products",
        params: { category, page },
      }),
      providesTags: ["Product"],
    }),

    getProductById: builder.query<Product, string>({
      query: (id) => `/products/${id}`,
      providesTags: (result, error, id) => [{ type: "Product", id }],
    }),

    // Mutations (POST/PUT/DELETE)
    createProduct: builder.mutation<Product, CreateProductDto>({
      query: (body) => ({
        url: "/products",
        method: "POST",
        body,
      }),
      invalidatesTags: ["Product"],  // Refetch all products after create
    }),

    updateProduct: builder.mutation<Product, { id: string; data: UpdateProductDto }>({
      query: ({ id, data }) => ({
        url: `/products/${id}`,
        method: "PUT",
        body: data,
      }),
      invalidatesTags: (result, error, { id }) => [{ type: "Product", id }],
    }),

    deleteProduct: builder.mutation<void, string>({
      query: (id) => ({
        url: `/products/${id}`,
        method: "DELETE",
      }),
      invalidatesTags: ["Product"],
    }),
  }),
});

// Auto-generated hooks
export const {
  useGetProductsQuery,
  useGetProductByIdQuery,
  useCreateProductMutation,
  useUpdateProductMutation,
  useDeleteProductMutation,
} = productsApi;
TSX
// Using RTK Query hooks — all state handled automatically
function ProductList() {
  const [category, setCategory] = useState("all");
  const [page, setPage] = useState(1);

  const { data: products, isLoading, isError, refetch } = useGetProductsQuery(
    { category: category !== "all" ? category : undefined, page },
    {
      pollingInterval: 60000,  // Refetch every minute
      refetchOnMountOrArgChange: true,
    }
  );

  const [deleteProduct, { isLoading: isDeleting }] = useDeleteProductMutation();

  const handleDelete = async (id) => {
    if (!confirm("Delete this product?")) return;
    try {
      await deleteProduct(id).unwrap();
      toast.success("Product deleted");
    } catch (error) {
      toast.error("Failed to delete product");
    }
  };

  if (isLoading) return <ProductSkeleton />;
  if (isError) return <ErrorState onRetry={refetch} />;

  return (
    <div>
      <CategoryFilter value={category} onChange={setCategory} />
      <div className="product-grid">
        {products?.map((product) => (
          <ProductCard
            key={product.id}
            product={product}
            onDelete={() => handleDelete(product.id)}
            isDeleting={isDeleting}
          />
        ))}
      </div>
      <Pagination page={page} onChange={setPage} />
    </div>
  );
}

// Optimistic updates
function LikeButton({ productId, initialLiked }) {
  const [updateProduct] = useUpdateProductMutation();
  const [liked, setLiked] = useState(initialLiked);

  const handleLike = async () => {
    setLiked(!liked);  // Optimistic update
    try {
      await updateProduct({ id: productId, data: { liked: !liked } }).unwrap();
    } catch {
      setLiked(liked);  // Rollback on error
      toast.error("Failed to update");
    }
  };

  return <button onClick={handleLike}>{liked ? "" : ""}</button>;
}

Selectors with Reselect — Memoized Derivations

TSX
import { createSelector } from "@reduxjs/toolkit";

// Basic selector
const selectAllProducts = (state: RootState) => state.products.items;
const selectCategoryFilter = (state: RootState) => state.products.filter.category;
const selectPriceRange = (state: RootState) => state.products.filter.priceRange;

// Memoized derived selector — only recomputes when inputs change
export const selectFilteredProducts = createSelector(
  [selectAllProducts, selectCategoryFilter, selectPriceRange],
  (products, category, priceRange) => {
    return products.filter((p) => {
      const inCategory = !category || p.category === category;
      const inRange = p.price >= priceRange.min && p.price <= priceRange.max;
      return inCategory && inRange;
    });
  }
);

// Parameterized selector factory
export const makeSelectProductsByCategory = () =>
  createSelector(
    [selectAllProducts, (_: RootState, category: string) => category],
    (products, category) => products.filter((p) => p.category === category)
  );

// Each component instance gets its own memoized selector
function CategoryRow({ category }) {
  const selectCategoryProducts = useMemo(makeSelectProductsByCategory, []);
  const products = useAppSelector((state) =>
    selectCategoryProducts(state, category)
  );
  return <ProductRow products={products} />;
}

Zustand — Simpler State for Medium-Scale Apps

When Redux feels like too much overhead, Zustand provides a minimal API:

TSX
import { create } from "zustand";
import { persist } from "zustand/middleware";

interface CartStore {
  items: CartItem[];
  addItem: (item: Omit<CartItem, "qty">) => void;
  removeItem: (id: string) => void;
  updateQty: (id: string, qty: number) => void;
  clearCart: () => void;
  total: () => number;
}

export const useCartStore = create<CartStore>()(
  persist(
    (set, get) => ({
      items: [],

      addItem: (item) =>
        set((state) => {
          const existing = state.items.find((i) => i.id === item.id);
          if (existing) {
            return {
              items: state.items.map((i) =>
                i.id === item.id ? { ...i, qty: i.qty + 1 } : i
              ),
            };
          }
          return { items: [...state.items, { ...item, qty: 1 }] };
        }),

      removeItem: (id) =>
        set((state) => ({ items: state.items.filter((i) => i.id !== id) })),

      updateQty: (id, qty) =>
        set((state) => ({
          items:
            qty <= 0
              ? state.items.filter((i) => i.id !== id)
              : state.items.map((i) => (i.id === id ? { ...i, qty } : i)),
        })),

      clearCart: () => set({ items: [] }),

      total: () =>
        get().items.reduce((sum, item) => sum + item.price * item.qty, 0),
    }),
    {
      name: "cart-storage",  // localStorage key
      partialize: (state) => ({ items: state.items }),  // Only persist items
    }
  )
);

// Usage — no Provider needed
function CartIcon() {
  // Subscribe to specific slice to avoid unnecessary re-renders
  const count = useCartStore((state) =>
    state.items.reduce((sum, i) => sum + i.qty, 0)
  );
  return <button>{count}</button>;
}

function AddToCartButton({ product }) {
  const addItem = useCartStore((state) => state.addItem);
  return <button onClick={() => addItem(product)}>Add to Cart</button>;
}

Interview Questions Answered

Q: What is the flux architecture?

Flux is the one-way data flow pattern that inspired Redux: Action → Dispatcher → Store → View → Action. Redux simplifies this by replacing the Dispatcher with a single function (the reducer) and making state immutable.

Q: What is the difference between mapStateToProps and useSelector?

mapStateToProps is the class component / connect() API — a function that maps Redux state to component props. useSelector is the modern hooks equivalent — takes a selector function, returns the selected value, and re-renders when the value changes.

Q: How does RTK Query differ from using thunks for data fetching?

RTK Query is a complete data fetching and caching layer. It automatically handles: loading/error states, deduplication of requests, cache management, automatic refetching, and cache invalidation. Thunks require you to manually manage all of this. Use RTK Query for server state, thunks for complex business logic or actions that don't fit the query/mutation model.

Q: What is the purpose of the Redux DevTools?

Redux DevTools lets you inspect every dispatched action, view state before and after, diff state changes, and time-travel — jump back to any previous state. It works automatically with configureStore in RTK (disabled in production by default).