← Back to all blogs
Redux Toolkit Best Practices – Production Ready Setup
Sat Feb 28 20268 minIntermediate

Redux Toolkit Best Practices – Production Ready Setup

A deep dive into production‑ready Redux Toolkit setups, covering architecture, best practices, code examples, testing, and performance tuning.

#redux#redux toolkit#state management#react#performance#typescript

Introduction

Why Redux Toolkit Matters in Production

When a React application scales beyond a handful of components, managing global state becomes a non‑trivial challenge. Redux Toolkit (RTK) eliminates boilerplate, enforces best practices, and adds powerful utilities such as createSlice, createAsyncThunk, and the RTK Query data fetching layer. While RTK works flawlessly in small prototypes, production environments demand additional considerations: type safety, modular architecture, performance monitoring, and robust testing.

In this guide we’ll walk through a production‑ready setup that:

  • Organizes the store into feature‑based modules.
  • Leverages TypeScript for strong typing.
  • Utilizes createEntityAdapter for normalized data.
  • Implements RTK Query for caching and automatic refetching.
  • Adds middleware for logging, error handling, and immutability checks.
  • Provides a testing strategy for slices, thunks, and selectors.

By the end, you’ll have a clear blueprint you can copy into any medium‑to‑large React project.

Application Architecture

Feature‑Based Store Layout

A clean architecture separates concerns and keeps the codebase maintainable. The most common pattern is a feature‑first folder structure:

text src/ ├─ app/ │ ├─ store.ts # Redux store configuration │ └─ hooks.ts # Typed useDispatch & useSelector ├─ features/ │ ├─ auth/ │ │ ├─ authSlice.ts │ │ ├─ authAPI.ts # RTK Query endpoints │ │ └─ selectors.ts │ ├─ todos/ │ │ ├─ todosSlice.ts │ │ ├─ todosAPI.ts │ │ └─ selectors.ts │ └─ ... └─ utils/ └─ logger.ts

Store Configuration (app/store.ts)

ts import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit'; import { authApi } from '../features/auth/authAPI'; import authReducer from '../features/auth/authSlice'; import { todosApi } from '../features/todos/todosAPI'; import todosReducer from '../features/todos/todosSlice';

// Optional: custom logger middleware (production‑safe) import logger from '../utils/logger';

export const store = configureStore({ reducer: { auth: authReducer, todos: todosReducer, // RTK Query automatically adds a reducer for each API slice [authApi.reducerPath]: authApi.reducer, [todosApi.reducerPath]: todosApi.reducer, }, middleware: (getDefault) => getDefault() .concat(authApi.middleware, todosApi.middleware) .concat(process.env.NODE_ENV === 'development' ? logger : []), devTools: process.env.NODE_ENV !== 'production', });

// Types for strong typing throughout the app export type RootState = ReturnType<typeof store.getState>; export type AppDispatch = typeof store.dispatch;

Typed Hooks (app/hooks.ts)

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

export const useAppDispatch = () => useDispatch<AppDispatch>(); export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Benefits of This Architecture

  • Scalability - Adding a new domain only requires a folder with a slice, API, and selectors.
  • Encapsulation - Each feature owns its state shape, making it easier to test in isolation.
  • Performance - Normalized entities via createEntityAdapter reduce re‑renders.
  • Type Safety - TypeScript infers action types and payloads automatically.

Best Practices & Code Examples

1. Normalize State with createEntityAdapter

Large collections (e.g., a todo list) should be stored in a normalized shape. This enables cheap updates and O(1) lookups.

ts import { createSlice, createEntityAdapter, PayloadAction } from '@reduxjs/toolkit'; import { RootState } from '../../app/store';

type Todo = { id: string; title: string; completed: boolean; };

const todosAdapter = createEntityAdapter<Todo>();

const initialState = todosAdapter.getInitialState({ loading: false, error: null as string | null });

export const todosSlice = createSlice({ name: 'todos', initialState, reducers: { addTodo: todosAdapter.addOne, updateTodo: todosAdapter.updateOne, removeTodo: todosAdapter.removeOne, setAll: todosAdapter.setAll, }, extraReducers: (builder) => { // Add async thunk handling here (see section 2) }, });

export const { addTodo, updateTodo, removeTodo, setAll } = todosSlice.actions; export default todosSlice.reducer;

// Selectors generated by the adapter - memoized for performance export const todosSelectors = todosAdapter.getSelectors<RootState>((state) => state.todos);

2. Asynchronous Logic with createAsyncThunk

When you need side‑effects that are not covered by RTK Query (e.g., complex orchestration), use createAsyncThunk.

ts import { createAsyncThunk } from '@reduxjs/toolkit'; import { RootState } from '../../app/store'; import { Todo } from './todosSlice'; import api from '../../utils/api'; // a thin wrapper around fetch/axios

export const fetchTodos = createAsyncThunk<Todo[], void, { state: RootState }>( 'todos/fetchTodos', async (_, { rejectWithValue }) => { try { const response = await api.get<Todo[]>('/todos'); return response.data; } catch (err: any) { return rejectWithValue(err.message); } } );

Add handling in the slice:

ts extraReducers: (builder) => { builder .addCase(fetchTodos.pending, (state) => { state.loading = true; state.error = null; }) .addCase(fetchTodos.fulfilled, (state, action) => { state.loading = false; todosAdapter.setAll(state, action.payload); }) .addCase(fetchTodos.rejected, (state, action) => { state.loading = false; state.error = action.payload as string; }); },

3. Data Fetching with RTK Query

For typical CRUD operations, RTK Query removes boilerplate and provides built‑in caching, pagination, and optimistic updates.

ts import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; import { Todo } from './todosSlice';

export const todosApi = createApi({ reducerPath: 'todosApi', baseQuery: fetchBaseQuery({ baseUrl: '/api' }), tagTypes: ['Todo'], endpoints: (builder) => ({ getTodos: builder.query<Todo[], void>({ query: () => '/todos', providesTags: (result) => result ? [...result.map(({ id }) => ({ type: 'Todo' as const, id })), { type: 'Todo', id: 'LIST' }] : [{ type: 'Todo', id: 'LIST' }], }), addTodo: builder.mutation<Todo, Partial<Todo>>({ query: (body) => ({ url: '/todos', method: 'POST', body }), invalidatesTags: [{ type: 'Todo', id: 'LIST' }], }), updateTodo: builder.mutation<Todo, Partial<Todo> & { id: string }>({ query: ({ id, ...patch }) => ({ url: /todos/${id}, method: 'PATCH', body: patch }), invalidatesTags: (result, error, { id }) => [{ type: 'Todo', id }], }), deleteTodo: builder.mutation<{ success: boolean; id: string }, string>({ query: (id) => ({ url: /todos/${id}, method: 'DELETE' }), invalidatesTags: (result, error, id) => [{ type: 'Todo', id }], }), }), });

export const { useGetTodosQuery, useAddTodoMutation, useUpdateTodoMutation, useDeleteTodoMutation, } = todosApi;

When to Prefer RTK Query vs. Thunks

  • RTK Query excels for standard RESTful data - caching, polling, and automatic refetching are handled for you.
  • Thunks are better for multi‑step flows, side‑effects that span several slices, or when you need conditional logic before a request.

4. Middleware for Production Safety

Immutable State Checks

Only enable in development, but the pattern demonstrates how to add environment‑specific middleware.

ts import { immutableStateInvariantMiddleware } from '@reduxjs/toolkit';

const middleware = getDefaultMiddleware({ immutableCheck: process.env.NODE_ENV !== 'production', serializableCheck: false, // disable if you store non‑serializable values intentionally });

Custom Error Logger

ts // utils/logger.ts import { Middleware } from '@reduxjs/toolkit';

const logger: Middleware = (storeAPI) => (next) => (action) => { const result = next(action); if (process.env.NODE_ENV === 'development') { console.group(action.type); console.info('dispatching', action); console.log('next state', storeAPI.getState()); console.groupEnd(); } return result; };

export default logger;

Integrating the Middleware

(See store configuration above - logger is conditionally added.)

5. Testing Strategies

Slice Unit Tests

ts import reducer, { addTodo, removeTodo } from './todosSlice';

test('should handle initial state', () => { expect(reducer(undefined, { type: 'unknown' })).toEqual({ ids: [], entities: {}, loading: false, error: null }); });

test('should add a todo', () => { const previousState = { ids: [], entities: {}, loading: false, error: null }; const newState = reducer(previousState, addTodo({ id: '1', title: 'Test', completed: false })); expect(newState.entities['1']?.title).toBe('Test'); });

Async Thunk Tests with Mock Service Worker (MSW)

ts import { fetchTodos } from './todosThunks'; import { server, rest } from '../../testServer';

beforeAll(() => server.listen()); afterEach(() => server.resetHandlers()); afterAll(() => server.close());

test('fetchTodos resolves with data', async () => { server.use( rest.get('/todos', (req, res, ctx) => { return res(ctx.json([{ id: '1', title: 'Mock', completed: false }])); }) );

const dispatch = jest.fn(); const getState = jest.fn(); await fetchTodos()(dispatch, getState, undefined); expect(dispatch).toHaveBeenCalledWith(expect.objectContaining({ type: fetchTodos.fulfilled.type })); });

Component Integration Tests Using RTK Query Hooks

tsx import { render, screen, waitFor } from '@testing-library/react'; import { Provider } from 'react-redux'; import { store } from '../../app/store'; import TodoList from './TodoList';

test('renders fetched todos', async () => { render( <Provider store={store}> <TodoList /> </Provider> );

await waitFor(() => expect(screen.getByText(/Mock Todo/i)).toBeInTheDocument()); });

These patterns guarantee that both pure Redux logic and React‑layer interactions stay reliable in CI pipelines.

FAQs

Frequently Asked Questions

1. Do I still need combineReducers with Redux Toolkit?

Yes, but only when you have multiple top‑level slice reducers that are not managed by RTK Query. The configureStore helper internally calls combineReducers for you, so you typically just provide the { reducer: { sliceA, sliceB } } object as shown in the store configuration.

2. How does RTK Query differ from react-query?

Both libraries aim to simplify data fetching, yet RTK Query is tightly integrated with Redux. It stores cache data in the Redux store, enabling you to share the same normalized state across UI components and other slices. react-query lives outside Redux and manages its own cache, which can be preferable when you want a completely decoupled solution.

3. Is it safe to store non‑serializable values (e.g., Date objects) in the Redux store?

Redux’s core philosophy encourages serializable state for time‑travel debugging and predictable persistence. If you must store non‑serializable data, disable the serializable check in configureStore (serializableCheck: false) and document the decision clearly. For dates, a common practice is to store ISO strings and convert to Date objects in selectors.

Conclusion

Bringing It All Together

A production‑ready Redux Toolkit setup is more than a collection of slices-it’s an architecture that promotes type safety, performance, and maintainability. By:

  1. Organizing code around feature folders,
  2. Normalizing large collections with createEntityAdapter,
  3. Leveraging createAsyncThunk for complex side‑effects, and
  4. Using RTK Query for efficient data fetching and caching,

you lay a solid foundation that scales with your application.

Adding targeted middleware, rigorous testing, and thoughtful documentation ensures that the state layer remains reliable as the team grows and new requirements emerge. Implement the patterns illustrated in this guide, adapt them to your domain, and you’ll enjoy a smoother development experience, faster runtime performance, and confidence that your Redux implementation can handle production workloads.

Happy coding!