Learn Redux in 10 DaysDay 10: Real-World Patterns
books.chapter 10Learn Redux in 10 Days

Day 10: Real-World Patterns

What You'll Learn Today

  • Authentication pattern (login flow, token storage, protected routes)
  • Feature-based project structure
  • Redux DevTools for debugging
  • Migration guide from React Context to Redux
  • When NOT to use Redux
  • Code splitting with combineSlices
  • Error boundary integration
  • 10-day journey recap and next steps

Authentication Pattern

Almost every web application needs authentication. Here is a robust pattern for handling it with Redux.

The authSlice Implementation

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

export const login = createAsyncThunk(
  'auth/login',
  async ({ email, password }, { rejectWithValue }) => {
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      });
      if (!response.ok) {
        const error = await response.json();
        return rejectWithValue(error.message);
      }
      const data = await response.json();
      localStorage.setItem('token', data.token);
      return data;
    } catch (error) {
      return rejectWithValue('Network error');
    }
  }
);

export const logout = createAsyncThunk(
  'auth/logout',
  async () => {
    localStorage.removeItem('token');
    return null;
  }
);

export const checkAuth = createAsyncThunk(
  'auth/checkAuth',
  async (_, { rejectWithValue }) => {
    const token = localStorage.getItem('token');
    if (!token) {
      return rejectWithValue('No token');
    }
    try {
      const response = await fetch('/api/auth/me', {
        headers: { Authorization: `Bearer ${token}` },
      });
      if (!response.ok) {
        localStorage.removeItem('token');
        return rejectWithValue('Invalid token');
      }
      return await response.json();
    } catch (error) {
      return rejectWithValue('Network error');
    }
  }
);

const authSlice = createSlice({
  name: 'auth',
  initialState: {
    user: null,
    token: null,
    status: 'idle', // 'idle' | 'loading' | 'succeeded' | 'failed'
    error: null,
  },
  reducers: {
    clearError(state) {
      state.error = null;
    },
  },
  extraReducers: (builder) => {
    builder
      // Login
      .addCase(login.pending, (state) => {
        state.status = 'loading';
        state.error = null;
      })
      .addCase(login.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.user = action.payload.user;
        state.token = action.payload.token;
      })
      .addCase(login.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.payload;
      })
      // Logout
      .addCase(logout.fulfilled, (state) => {
        state.user = null;
        state.token = null;
        state.status = 'idle';
      })
      // Check Auth
      .addCase(checkAuth.fulfilled, (state, action) => {
        state.user = action.payload;
        state.status = 'succeeded';
      })
      .addCase(checkAuth.rejected, (state) => {
        state.user = null;
        state.token = null;
        state.status = 'idle';
      });
  },
});

export const { clearError } = authSlice.actions;
export default authSlice.reducer;
TypeScript version
// features/auth/authSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';

interface User {
  id: string;
  email: string;
  name: string;
}

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

interface LoginCredentials {
  email: string;
  password: string;
}

interface LoginResponse {
  user: User;
  token: string;
}

export const login = createAsyncThunk<
  LoginResponse,
  LoginCredentials,
  { rejectValue: string }
>(
  'auth/login',
  async ({ email, password }, { rejectWithValue }) => {
    try {
      const response = await fetch('/api/auth/login', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ email, password }),
      });
      if (!response.ok) {
        const error = await response.json();
        return rejectWithValue(error.message);
      }
      const data: LoginResponse = await response.json();
      localStorage.setItem('token', data.token);
      return data;
    } catch {
      return rejectWithValue('Network error');
    }
  }
);

const initialState: AuthState = {
  user: null,
  token: null,
  status: 'idle',
  error: null,
};

const authSlice = createSlice({
  name: 'auth',
  initialState,
  reducers: {
    clearError(state) {
      state.error = null;
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(login.pending, (state) => {
        state.status = 'loading';
        state.error = null;
      })
      .addCase(login.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.user = action.payload.user;
        state.token = action.payload.token;
      })
      .addCase(login.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.payload ?? 'Unknown error';
      });
  },
});

export const { clearError } = authSlice.actions;
export default authSlice.reducer;

Protected Routes

// components/ProtectedRoute.jsx
import React from 'react';
import { useSelector } from 'react-redux';
import { Navigate, useLocation } from 'react-router-dom';

function ProtectedRoute({ children }) {
  const { user, status } = useSelector((state) => state.auth);
  const location = useLocation();

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

  if (!user) {
    return <Navigate to="/login" state={{ from: location }} replace />;
  }

  return children;
}

export default ProtectedRoute;
// App.jsx - Routing setup
import { Routes, Route } from 'react-router-dom';
import ProtectedRoute from './components/ProtectedRoute';
import LoginPage from './pages/LoginPage';
import Dashboard from './pages/Dashboard';

function App() {
  return (
    <Routes>
      <Route path="/login" element={<LoginPage />} />
      <Route
        path="/dashboard"
        element={
          <ProtectedRoute>
            <Dashboard />
          </ProtectedRoute>
        }
      />
    </Routes>
  );
}

Feature-Based Project Structure

For medium to large Redux applications, organizing code by feature dramatically improves maintainability and discoverability.

graph TB
    subgraph Project["Recommended Project Structure"]
        SRC["src/"]
        APP["app/<br/>store.js, hooks.js"]
        FEAT["features/"]
        AUTH["auth/<br/>authSlice.js<br/>authApi.js<br/>LoginForm.jsx<br/>AuthGuard.jsx"]
        POSTS["posts/<br/>postsSlice.js<br/>postsApi.js<br/>PostList.jsx<br/>PostDetail.jsx"]
        USERS["users/<br/>usersSlice.js<br/>usersApi.js<br/>UserProfile.jsx"]
        COMP["components/<br/>Header.jsx<br/>Layout.jsx<br/>Button.jsx"]
    end

    SRC --> APP
    SRC --> FEAT
    SRC --> COMP
    FEAT --> AUTH
    FEAT --> POSTS
    FEAT --> USERS

    style Project fill:transparent,stroke:#666
    style APP fill:#3b82f6,color:#fff
    style AUTH fill:#8b5cf6,color:#fff
    style POSTS fill:#8b5cf6,color:#fff
    style USERS fill:#8b5cf6,color:#fff
    style COMP fill:#22c55e,color:#fff

Detailed Directory Layout

src/
β”œβ”€β”€ app/
β”‚   β”œβ”€β”€ store.js          # Store configuration
β”‚   β”œβ”€β”€ hooks.js          # Typed hooks (useAppDispatch, useAppSelector)
β”‚   └── rootReducer.js    # Combine all reducers
β”œβ”€β”€ features/
β”‚   β”œβ”€β”€ auth/
β”‚   β”‚   β”œβ”€β”€ authSlice.js
β”‚   β”‚   β”œβ”€β”€ authApi.js    # RTK Query or async thunks
β”‚   β”‚   β”œβ”€β”€ LoginForm.jsx
β”‚   β”‚   β”œβ”€β”€ AuthGuard.jsx
β”‚   β”‚   └── auth.test.js
β”‚   β”œβ”€β”€ posts/
β”‚   β”‚   β”œβ”€β”€ postsSlice.js
β”‚   β”‚   β”œβ”€β”€ postsApi.js
β”‚   β”‚   β”œβ”€β”€ PostList.jsx
β”‚   β”‚   β”œβ”€β”€ PostDetail.jsx
β”‚   β”‚   └── posts.test.js
β”‚   └── users/
β”‚       β”œβ”€β”€ usersSlice.js
β”‚       β”œβ”€β”€ usersApi.js
β”‚       β”œβ”€β”€ UserProfile.jsx
β”‚       └── users.test.js
β”œβ”€β”€ components/           # Shared/presentational components
β”‚   β”œβ”€β”€ Header.jsx
β”‚   β”œβ”€β”€ Layout.jsx
β”‚   └── Button.jsx
β”œβ”€β”€ pages/               # Route-level components
β”‚   β”œβ”€β”€ HomePage.jsx
β”‚   β”œβ”€β”€ LoginPage.jsx
β”‚   └── DashboardPage.jsx
└── utils/               # Shared utilities
    β”œβ”€β”€ test-utils.js
    └── api.js

Benefits of this structure:

  • Related code lives together in the same directory
  • Adding a new feature is straightforward (just create a new directory)
  • Test files sit next to the code they test
  • Removing a feature is as simple as deleting a directory

Redux DevTools

Redux DevTools is the most powerful debugging tool in the Redux ecosystem.

Key Features

1. Action Log

View every dispatched action in chronological order. For each action, you can inspect the type, payload, and resulting state.

2. State Diff

See exactly which parts of state changed for each action. This makes it easy to spot unintended mutations.

3. Time Travel

Rewind state to any previous point or skip specific actions. Invaluable for tracking down the root cause of bugs.

4. State Import/Export

Export the current state as JSON and import it later. Perfect for attaching state snapshots to bug reports.

Configuration

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

const store = configureStore({
  reducer: {
    // reducers...
  },
  // DevTools is enabled by default in development
  devTools: process.env.NODE_ENV !== 'production',
});

Note: When using configureStore, DevTools is automatically enabled in development. No additional setup is required.

Customizing DevTools

const store = configureStore({
  reducer: rootReducer,
  devTools: {
    name: 'My App',
    trace: true,        // Action dispatch stack trace
    traceLimit: 25,     // Stack trace depth limit
    maxAge: 50,         // Max stored actions
  },
});

Migrating from React Context to Redux

A step-by-step guide for migrating existing Context-based state management to Redux.

Step 1: Assess the Current Code

// Before: Context-based state management
const AuthContext = React.createContext();

function AuthProvider({ children }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);

  const login = async (email, password) => {
    setLoading(true);
    try {
      const res = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify({ email, password }),
      });
      const data = await res.json();
      setUser(data.user);
    } finally {
      setLoading(false);
    }
  };

  const logout = () => {
    setUser(null);
    localStorage.removeItem('token');
  };

  return (
    <AuthContext.Provider value={{ user, loading, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

Step 2: Create the Slice

// After: Redux slice (same logic, different structure)
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';

export const loginUser = createAsyncThunk(
  'auth/login',
  async ({ email, password }) => {
    const res = await fetch('/api/login', {
      method: 'POST',
      body: JSON.stringify({ email, password }),
    });
    return await res.json();
  }
);

const authSlice = createSlice({
  name: 'auth',
  initialState: { user: null, loading: false },
  reducers: {
    logoutUser(state) {
      state.user = null;
      localStorage.removeItem('token');
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(loginUser.pending, (state) => {
        state.loading = true;
      })
      .addCase(loginUser.fulfilled, (state, action) => {
        state.loading = false;
        state.user = action.payload.user;
      })
      .addCase(loginUser.rejected, (state) => {
        state.loading = false;
      });
  },
});

Step 3: Migrate Components Incrementally

// Transitional: Support both Context and Redux
function useAuth() {
  // Phase 1: Still using Context
  // return useContext(AuthContext);

  // Phase 2: Using Redux
  const dispatch = useDispatch();
  const { user, loading } = useSelector((state) => state.auth);

  return {
    user,
    loading,
    login: (email, password) => dispatch(loginUser({ email, password })),
    logout: () => dispatch(logoutUser()),
  };
}

Migration tips:

  1. Abstracting behind custom hooks makes switching the implementation painless
  2. You don't have to migrate everything at once -- go feature by feature
  3. Write tests first to ensure behavior stays the same after migration

When NOT to Use Redux

Redux is not the right tool for every kind of state. Use this decision guide.

flowchart TD
    START["Need state management"] --> Q1{"Accessed by<br/>multiple components?"}
    Q1 -- "No" --> LOCAL["Local State<br/>useState / useReducer"]
    Q1 -- "Yes" --> Q2{"Server data?"}
    Q2 -- "Yes" --> Q3{"Need caching<br/>& revalidation?"}
    Q3 -- "Yes" --> SERVER["Server State<br/>RTK Query / React Query<br/>/ SWR"]
    Q3 -- "No" --> REDUX["Redux"]
    Q2 -- "No" --> Q4{"Should live in URL?"}
    Q4 -- "Yes" --> URL["URL State<br/>React Router<br/>/ searchParams"]
    Q4 -- "No" --> Q5{"Complex logic<br/>or action history?"}
    Q5 -- "Yes" --> REDUX
    Q5 -- "No" --> CONTEXT["React Context"]

    style LOCAL fill:#22c55e,color:#fff
    style SERVER fill:#3b82f6,color:#fff
    style URL fill:#f59e0b,color:#fff
    style REDUX fill:#8b5cf6,color:#fff
    style CONTEXT fill:#ef4444,color:#fff

State Categories and Recommended Tools

State Category Examples Recommended Tool
Local UI Modal open/close, input fields useState
Forms Validation, field values React Hook Form, Formik
Server Data API responses, cache RTK Query, React Query
URL Page, filters, search React Router
Global UI Theme, language Context API
Business Logic Auth, cart, complex flows Redux

Code Splitting with combineSlices

In large applications, bundling all reducers upfront increases the initial load time. RTK 2.0's combineSlices enables lazy loading of reducers.

Basic combineSlices Usage

// app/rootReducer.js
import { combineSlices } from '@reduxjs/toolkit';
import { authSlice } from '../features/auth/authSlice';

// Start with core slices that are always needed
const rootReducer = combineSlices(authSlice);

export default rootReducer;

Lazy-Loaded Slices

// features/admin/adminSlice.js
import { createSlice } from '@reduxjs/toolkit';
import rootReducer from '../../app/rootReducer';

const adminSlice = createSlice({
  name: 'admin',
  initialState: { users: [], stats: null },
  reducers: {
    setUsers(state, action) {
      state.users = action.payload;
    },
  },
});

// Inject this slice when the admin feature is loaded
const injectedReducer = rootReducer.inject(adminSlice);

export const { setUsers } = adminSlice.actions;
export default adminSlice.reducer;

Combining with React.lazy

// App.jsx
import React, { lazy, Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';

// Admin page is lazy-loaded; its slice gets injected on load
const AdminPage = lazy(() => import('./pages/AdminPage'));

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <Routes>
        <Route path="/admin" element={<AdminPage />} />
      </Routes>
    </Suspense>
  );
}
TypeScript version
// app/rootReducer.ts
import { combineSlices } from '@reduxjs/toolkit';
import { authSlice } from '../features/auth/authSlice';

const rootReducer = combineSlices(authSlice);

// Declare lazy-loaded slices for type safety
declare module './rootReducer' {
  export interface LazyLoadedSlices {
    admin: AdminState;
  }
}

export type RootState = ReturnType<typeof rootReducer>;
export default rootReducer;

Error Boundary Integration

Integrate Redux error state with React error boundaries for comprehensive error handling.

// components/ReduxErrorBoundary.jsx
import React, { Component } from 'react';
import { connect } from 'react-redux';

class ReduxErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasRenderError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasRenderError: true };
  }

  componentDidCatch(error, errorInfo) {
    console.error('Render error:', error, errorInfo);
  }

  render() {
    // Catch render errors
    if (this.state.hasRenderError) {
      return (
        <div role="alert">
          <h2>Something went wrong</h2>
          <button onClick={() => this.setState({ hasRenderError: false })}>
            Try Again
          </button>
        </div>
      );
    }

    // Catch Redux state errors
    if (this.props.globalError) {
      return (
        <div role="alert">
          <h2>Application Error</h2>
          <p>{this.props.globalError}</p>
          <button onClick={this.props.clearError}>
            Dismiss
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

const mapStateToProps = (state) => ({
  globalError: state.app?.globalError,
});

const mapDispatchToProps = (dispatch) => ({
  clearError: () => dispatch({ type: 'app/clearGlobalError' }),
});

export default connect(mapStateToProps, mapDispatchToProps)(ReduxErrorBoundary);

Global Error Handling Middleware

// app/errorMiddleware.js
const errorMiddleware = (store) => (next) => (action) => {
  // Catch all rejected async thunks
  if (action.type.endsWith('/rejected')) {
    const status = action.payload?.status;

    if (status === 401) {
      // Auto logout on unauthorized
      store.dispatch({ type: 'auth/logout' });
    }

    if (status === 500) {
      store.dispatch({
        type: 'app/setGlobalError',
        payload: 'A server error occurred. Please try again later.',
      });
    }
  }

  return next(action);
};

export default errorMiddleware;

10-Day Journey Recap

Let's review everything we covered over these 10 days.

Day Topic Key Concepts
1 The Problem with State Props drilling, why Redux exists
2 Three Principles of Redux Single source of truth, immutability, pure functions
3 Redux Toolkit Basics configureStore, createSlice, Immer
4 React-Redux Integration useSelector, useDispatch, Provider
5 Async Operations createAsyncThunk, loading/error state
6 RTK Query createApi, caching, automatic refetching
7 Selectors and Performance createSelector, memoization, re-render optimization
8 Middleware Listener Middleware, custom middleware
9 Testing Integration tests, MSW, renderWithProviders
10 Real-World Patterns Auth, project structure, migration, decision guide

Next Steps

Now that you have a solid foundation, here are resources to deepen your knowledge.

Official Documentation

Advanced Topics

  1. Redux Saga -- for complex async flows beyond what Listener Middleware can handle
  2. Normalized State -- entity management with createEntityAdapter
  3. Server-Side Rendering -- integrating Redux with Next.js or Remix
  4. State Machines -- combining XState with Redux
  5. Offline First -- offline support with Redux Persist

Project Ideas for Practice

Project Skills Practiced
Full-featured Todo App CRUD, filtering, persistence
Blog Platform RTK Query, auth, pagination
E-commerce Site Cart management, checkout flow
Chat Application WebSocket integration, real-time updates
Dashboard Data visualization, multiple API sources

Summary

Pattern Use Case Key Tool
Auth Flow Login/logout createAsyncThunk + ProtectedRoute
Feature-Based Structure Medium to large apps features/ directory layout
Code Splitting Large apps combineSlices + React.lazy
Error Handling All apps Error Boundary + middleware
DevTools Development Redux DevTools Extension

Final takeaways:

  1. Redux is a tool, not a goal -- choose the right tool for the problem
  2. Start small -- don't add Redux until you actually need it
  3. Always use RTK -- there is almost no reason to write vanilla Redux
  4. Write tests -- integration tests give the best return on investment
  5. Read the official docs -- they are the most accurate and up-to-date source

Exercises

Exercise 1: Implement an Auth Flow

Build an authentication system with:

  • Email and password login
  • Persisted login state (survives page reload)
  • Redirect for unauthenticated users
  • Logout functionality

Exercise 2: Design a Project Structure

Design the directory structure for an e-commerce app with:

  • User authentication
  • Product listing and search
  • Shopping cart
  • Order management
  • User profile

Exercise 3: Choose the Right State Tool

For each of the following, pick the best state management approach (useState, Context, Redux, React Query, etc.) and explain why:

  • Sidebar open/close state
  • Current user information
  • Product listing data
  • Shopping cart contents
  • Form input values
  • Dark mode setting

Exercise 4: Create a Migration Plan

Write a step-by-step migration plan for converting a Todo app built with React Context and useReducer to Redux Toolkit. Ensure the app remains functional throughout the migration process.