Skip to content
All essays
WebMarch 25, 202428 min

Redux Toolkit Complete Guide

Master Redux Toolkit: modern state management with slices, async thunks, and best practices

Ü
Ümit Uz
Mobile & Full Stack Developer

Redux Toolkit (RTK) is the official, opinionated way to write Redux logic. Let's master it from setup to advanced patterns.

Why Redux Toolkit?

Traditional Redux had too much boilerplate. Redux Toolkit simplifies:

  • Less boilerplate: No more action creators, switch statements
  • Immutability: Built-in Immer for easy state updates
  • Async: Built-in createAsyncThunk for API calls
  • TypeScript: First-class TypeScript support

Installation

bash
npm install @reduxjs/toolkit react-redux

Basic Setup

Configure Store

typescript
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './slices/counterSlice';
import userReducer from './slices/userSlice';

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

// Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

Provide Store

typescript
// main.tsx
import { Provider } from 'react-redux';
import { store } from './store';

ReactDOM.createRoot(document.getElementById('root')!).render(
  <Provider store={store}>
    <App />
  </Provider>
);

Slices

Create Slice

typescript
// store/slices/counterSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CounterState {
  value: number;
}

const initialState: CounterState = {
  value: 0,
};

export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state) => {
      // Immer makes this safe
      state.value += 1;
    },
    decrement: (state) => {
      state.value -= 1;
    },
    incrementByAmount: (state, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
    reset: (state) => {
      state.value = 0;
    },
  },
});

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

// Export reducer
export default counterSlice.reducer;

// Selectors
export const selectCount = (state: RootState) => state.counter.value;

Using State in Components

Typed Hooks

typescript
// store/hooks.ts
import { useDispatch, useSelector } from 'react-redux';
import type { RootState, AppDispatch } from './store';

// Use throughout app instead of plain `useDispatch` and `useSelector`
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();

In Component

typescript
// components/Counter.tsx
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { increment, decrement, incrementByAmount } from '../store/slices/counterSlice';

export function Counter() {
  const count = useAppSelector((state) => state.counter.value);
  const dispatch = useAppDispatch();

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

Async Thunks

Create Async Thunk

typescript
// store/slices/userSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';

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

interface UserState {
  users: User[];
  loading: boolean;
  error: string | null;
}

const initialState: UserState = {
  users: [],
  loading: false,
  error: null,
};

// Async thunk
export const fetchUsers = createAsyncThunk(
  'users/fetchUsers',
  async () => {
    const response = await fetch('https://api.example.com/users');
    const data: User[] = await response.json();
    return data;
  }
);

export const createUser = createAsyncThunk(
  'users/createUser',
  async (userData: Omit<User, 'id'>) => {
    const response = await fetch('https://api.example.com/users', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(userData),
    });
    const data: User = await response.json();
    return data;
  }
);

const userSlice = createSlice({
  name: 'users',
  initialState,
  reducers: {
    clearError: (state) => {
      state.error = null;
    },
  },
  extraReducers: (builder) => {
    builder
      // Fetch users
      .addCase(fetchUsers.pending, (state) => {
        state.loading = true;
        state.error = null;
      })
      .addCase(fetchUsers.fulfilled, (state, action: PayloadAction<User[]>) => {
        state.loading = false;
        state.users = action.payload;
      })
      .addCase(fetchUsers.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message || 'Failed to fetch users';
      })
      // Create user
      .addCase(createUser.fulfilled, (state, action: PayloadAction<User>) => {
        state.users.push(action.payload);
      });
  },
});

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

Using Async Thunks

typescript
// components/Users.tsx
import { useEffect } from 'react';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { fetchUsers } from '../store/slices/userSlice';

export function Users() {
  const dispatch = useAppDispatch();
  const { users, loading, error } = useAppSelector((state) => state.users);

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

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

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

Normalized State

Why Normalize?

Avoid data duplication and improve performance:

typescript
// ❌ BAD: Denormalized
interface BadState {
  users: Array<{
    id: number;
    name: string;
    posts: Array<{
      id: number;
      title: string;
      comments: Array<{...}>
    }>
  }>;
}

// ✅ GOOD: Normalized
interface GoodState {
  users: {
    ids: number[];
    entities: Record<number, { id: number; name: string; postIds: number[] }>;
  };
  posts: {
    ids: number[];
    entities: Record<number, { id: number; title: string; userId: number; commentIds: number[] }>;
  };
  comments: {
    ids: number[];
    entities: Record<number, { id: number; text: string; postId: number }>;
  };
}

Create Adapters

typescript
import { createEntityAdapter, createSlice } from '@reduxjs/toolkit';

// Create adapter
const usersAdapter = createEntityAdapter<User>({
  selectId: (user) => user.id,
  sortComparer: (a, b) => a.name.localeCompare(b.name),
});

// Get selectors
export const {
  selectAll: selectAllUsers,
  selectById: selectUserById,
  selectIds: selectUserIds,
  selectTotal: selectUserTotal,
} = usersAdapter.getSelectors();

// Create slice
const usersSlice = createSlice({
  name: 'users',
  initialState: usersAdapter.getInitialState({
    loading: false,
    error: null,
  }),
  reducers: {
    addUser: usersAdapter.addOne,
    updateUser: usersAdapter.updateOne,
    removeUser: usersAdapter.removeOne,
    setUsers: usersAdapter.setAll,
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.fulfilled, (state, action) => {
        usersAdapter.setAll(state, action.payload);
      });
  },
});

Middleware

Logger Middleware

typescript
// store/middleware/logger.ts
import { Middleware } from '@reduxjs/toolkit';

export const loggerMiddleware: Middleware<{}, RootState> =
  (store) => (next) => (action) => {
    console.log('Dispatching:', action);
    const result = next(action);
    console.log('Next State:', store.getState());
    return result;
  };

Custom Middleware

typescript
// store/middleware/analytics.ts
import { Middleware } from '@reduxjs/toolkit';

export const analyticsMiddleware: Middleware<{}, RootState> =
  (store) => (next) => (action) => {
    // Track specific actions
    if (action.type.startsWith('users/')) {
      // Send to analytics
      console.log('Analytics:', action.type);
    }

    return next(action);
  };

Add Middleware to Store

typescript
// store/index.ts
import { configureStore } from '@reduxjs/toolkit';
import { loggerMiddleware } from './middleware/logger';
import { analyticsMiddleware } from './middleware/analytics';

export const store = configureStore({
  reducer: {
    // ...reducers
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware()
      .concat(loggerMiddleware)
      .concat(analyticsMiddleware),
});

Persistence

LocalStorage Persistence

typescript
// store/middleware/persistence.ts
import { Middleware } from '@reduxjs/toolkit';

export const persistenceMiddleware: Middleware<{}, RootState> =
  (store) => (next) => (action) => {
    const result = next(action);

    // Save to localStorage
    const state = store.getState();
    localStorage.setItem('reduxState', JSON.stringify(state));

    return result;
  };

// Load from localStorage
const loadState = () => {
  try {
    const serializedState = localStorage.getItem('reduxState');
    if (serializedState) {
      return JSON.parse(serializedState);
    }
  } catch (err) {
    return undefined;
  }
};

// Use in store configuration
export const store = configureStore({
  preloadedState: loadState(),
  // ...rest of config
});

Best Practices

1. Use Slices

Organize state by feature:

typescript
// store/
//   index.ts
//   slices/
//     authSlice.ts
//     usersSlice.ts
//     postsSlice.ts
//   middleware/
//     logger.ts
//     analytics.ts

2. Normalize State

Use entity adapters for collections:

typescript
const usersAdapter = createEntityAdapter<User>();

3. Type Everything

Leverage TypeScript:

typescript
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
export const useAppSelector = useSelector.withTypes<RootState>();

4. Handle Async Properly

Use createAsyncThunk:

typescript
export const fetchData = createAsyncThunk(
  'data/fetch',
  async (params: { id: string }) => {
    const response = await fetch(`/api/data/${params.id}`);
    return response.json();
  }
);

5. Selectors for Derived Data

Create memoized selectors:

typescript
import { createSelector } from '@reduxjs/toolkit';

export const selectActiveUsers = createSelector(
  [(state: RootState) => state.users.entities],
  (users) => Object.values(users).filter(user => user.active)
);

Performance Optimization

Memoized Selectors

typescript
import { createSelector } from '@reduxjs/toolkit';

export const selectUserPosts = createSelector(
  [(state: RootState) => state.users.entities,
   (state: RootState) => state.posts.entities,
   (state: RootState, userId: number) => userId],
  (users, posts, userId) => {
    const user = users[userId];
    if (!user) return [];
    return user.postIds.map(postId => posts[postId]);
  }
);

Lazy Loading Slices

typescript
// store/index.ts
export const store = configureStore({
  reducer: {
    // Eagerly loaded
    auth: authReducer,
    // Lazy loaded
    users: (state, action) => {
      if (action.type === 'users/inject') {
        return usersReducer(state, action);
      }
      return state || {};
    },
  },
});

// Inject slice later
import { injectUsersReducer } from './store/slices/usersSlice';
injectUsersReducer();

Testing

Test Slice

typescript
// tests/slices/counterSlice.test.ts
import reducer, { increment, incrementByAmount } from '../counterSlice';

describe('counterSlice', () => {
  const initialState = { value: 0 };

  it('should handle initial state', () => {
    expect(reducer(undefined, { type: 'unknown' })).toEqual(initialState);
  });

  it('should handle increment', () => {
    const actual = reducer(initialState, increment());
    expect(actual.value).toEqual(1);
  });

  it('should handle incrementByAmount', () => {
    const actual = reducer(initialState, incrementByAmount(5));
    expect(actual.value).toEqual(5);
  });
});

Test Async Thunk

typescript
// tests/thunks/fetchUsers.test.ts
import { fetchUsers } from '../userSlice';
import { configureStore } from '@reduxjs/toolkit';

describe('fetchUsers', () => {
  it('should fetch users successfully', async () => {
    // Mock fetch
    global.fetch = jest.fn(() =>
      Promise.resolve({
        json: () => Promise.resolve([{ id: 1, name: 'John' }]),
      })
    ) as jest.Mock;

    const store = configureStore({
      reducer: {
        users: userReducer,
      },
    });

    await store.dispatch(fetchUsers());

    const state = store.getState();
    expect(state.users.users).toEqual([{ id: 1, name: 'John' }]);
    expect(state.users.loading).toBe(false);
  });
});

Common Patterns

Optimistic Updates

typescript
export const updateUser = createAsyncThunk(
  'users/update',
  async (user: User, { dispatch }) => {
    // Optimistic update
    dispatch(userSlice.actions.updateUser(user));

    try {
      const response = await fetch(`/api/users/${user.id}`, {
        method: 'PUT',
        body: JSON.stringify(user),
      });
      return await response.json();
    } catch (error) {
      // Rollback on error
      dispatch(userSlice.actions.revertUpdate(user));
      throw error;
    }
  }
);

Cache Invalidation

typescript
export const invalidateCache = () => ({
  type: 'cache/invalidate',
  payload: Date.now(),
});

const cacheSlice = createSlice({
  name: 'cache',
  initialState: {
    lastInvalidated: null,
  },
  reducers: {
    invalidate: (state) => {
      state.lastInvalidated = Date.now();
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(createUser.fulfilled, (state) => {
        state.lastInvalidated = Date.now();
      });
  },
});

Conclusion

Redux Toolkit simplifies Redux significantly. Use slices for organization, createAsyncThunk for async, and entity adapters for collections. Type everything and use memoized selectors for performance. The result is clean, maintainable state management.

Enjoyed this essay? Share it.

Related essays

Next essay
Building a Strong Code Review Culture