โ† Back to DevBytes

State Management in Redux: Patterns and Libraries

Introduction to Redux State Management

Redux is a predictable state container for JavaScript applications, originally designed for React but now used across many frameworks. At its core, Redux enforces a unidirectional data flow and a single source of truth, making application state easier to reason about, debug, and test. While the base library is intentionally minimal, the ecosystem around it has grown into a rich collection of patterns and libraries that solve common pain points such as boilerplate, async logic, and normalized data.

This tutorial walks through the fundamentals of Redux, explores established patterns for structuring state and logic, and introduces the most widely used libraries that complement Redux in modern applications.

Why Redux Matters

As applications grow, managing shared state through component props or context alone becomes brittle. Redux addresses this by centralizing state in a single store and requiring all changes to flow through dispatched actions and pure reducer functions. This gives you:

Core Concepts

Redux revolves around three principles: a single source of truth, read-only state, and changes made by pure functions. The data flow is: UI dispatches an action, the store passes the action and current state to the root reducer, the reducer returns new state, and the UI re-renders.

The Store, Actions, and Reducers

A minimal Redux setup looks like this:

import { createStore } from 'redux';

// Action type
const INCREMENT = 'counter/increment';

// Action creator
const increment = (payload = 1) => ({
  type: INCREMENT,
  payload,
});

// Reducer
const initialState = { count: 0 };

function counterReducer(state = initialState, action) {
  switch (action.type) {
    case INCREMENT:
      return { ...state, count: state.count + action.payload };
    default:
      return state;
  }
}

// Store
const store = createStore(counterReducer);

// Usage
store.dispatch(increment(5));
console.log(store.getState()); // { count: 5 }

While this classic pattern is instructive, modern Redux development rarely writes code this verbose. The community has converged on patterns and libraries that reduce boilerplate while preserving the underlying model.

Modern Redux with Redux Toolkit

Redux Toolkit (RTK) is the official, recommended way to write Redux logic. It bundles utilities like configureStore, createSlice, and createAsyncThunk to eliminate boilerplate and enforce best practices such as immutable updates via Immer.

Creating a Slice

A slice combines the reducer logic and action creators for a single feature:

import { createSlice } from '@reduxjs/toolkit';

const counterSlice = createSlice({
  name: 'counter',
  initialState: { count: 0, status: 'idle' },
  reducers: {
    increment(state, action) {
      // Immer allows "mutating" syntax for immutable updates
      state.count += action.payload;
    },
    decrement(state) {
      state.count -= 1;
    },
    reset(state) {
      state.count = 0;
    },
  },
});

export const { increment, decrement, reset } = counterSlice.actions;
export default counterSlice.reducer;

Configuring the Store

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

export const store = configureStore({
  reducer: {
    counter: counterReducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: true,
      immutableCheck: true,
    }),
});

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

Using Redux in React

The react-redux library provides hooks to connect components to the store:

import { useSelector, useDispatch } from 'react-redux';
import { increment, decrement } from './features/counter/counterSlice';
import type { RootState } from './store';

export function Counter() {
  const count = useSelector((state: RootState) => state.counter.count);
  const dispatch = useDispatch();

  return (
    <div>
      <button onClick={() => dispatch(decrement())}>-</button>
      <span>{count}</span>
      <button onClick={() => dispatch(increment(2))}>+</button>
    </div>
  );
}

For type-safe dispatch and selector hooks, create pre-typed versions once and reuse them across the app:

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

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

Handling Async Logic

Redux reducers must be pure, so async work belongs in middleware. The two dominant approaches are createAsyncThunk for promise-based flows and RTK Query for data fetching.

createAsyncThunk

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

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

export const fetchUser = createAsyncThunk<User, number>(
  'user/fetchUser',
  async (userId, { rejectWithValue }) => {
    try {
      const res = await fetch(`/api/users/${userId}`);
      if (!res.ok) throw new Error('Failed to fetch user');
      return (await res.json()) as User;
    } catch (err) {
      return rejectWithValue((err as Error).message);
    }
  }
);

const userSlice = createSlice({
  name: 'user',
  initialState: { data: null as User | null, status: 'idle', error: null as string | null },
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUser.pending, (state) => {
        state.status = 'loading';
        state.error = null;
      })
      .addCase(fetchUser.fulfilled, (state, action) => {
        state.status = 'succeeded';
        state.data = action.payload;
      })
      .addCase(fetchUser.rejected, (state, action) => {
        state.status = 'failed';
        state.error = action.payload as string;
      });
  },
});

export default userSlice.reducer;

RTK Query

RTK Query is a powerful data-fetching and caching layer built into Redux Toolkit. It eliminates the need to manually write thunks and cache logic for API calls:

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const api = createApi({
  reducerPath: 'api',
  baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
  tagTypes: ['User'],
  endpoints: (builder) => ({
    getUser: builder.query<User, number>({
      query: (id) => `/users/${id}`,
      providesTags: (result, error, id) => [{ type: 'User', id }],
    }),
    updateUser: builder.mutation<User, { id: number; patch: Partial<User> }>({
      query: ({ id, patch }) => ({
        url: `/users/${id}`,
        method: 'PATCH',
        body: patch,
      }),
      invalidatesTags: (result, error, { id }) => [{ type: 'User', id }],
    }),
  }),
});

export const { useGetUserQuery, useUpdateUserMutation } = api;

Components consume these hooks directly, gaining automatic caching, refetching, and loading state:

function UserProfile({ userId }: { userId: number }) {
  const { data: user, isLoading } = useGetUserQuery(userId);
  const [updateUser] = useUpdateUserMutation();

  if (isLoading) return <p>Loading...</p>;
  if (!user) return <p>No user</p>;

  return (
    <div>
      <h3>{user.name}</h3>
      <button onClick={() => updateUser({ id: userId, patch: { name: 'Updated' } })}>
        Rename
      </button>
    </div>
  );
}

State Normalization

Storing lists of entities as arrays leads to inefficient lookups and duplicate data. Normalization stores entities in a dictionary keyed by ID, alongside an array of IDs that preserves ordering. Redux Toolkit provides createEntityAdapter for this:

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

interface Post {
  id: string;
  title: string;
  body: string;
}

const postsAdapter = createEntityAdapter<Post>({
  sortComparer: (a, b) => a.title.localeCompare(b.title),
});

const postsSlice = createSlice({
  name: 'posts',
  initialState: postsAdapter.getInitialState(),
  reducers: {
    postAdded: postsAdapter.addOne,
    postUpdated: postsAdapter.updateOne,
    postRemoved: postsAdapter.removeOne,
    postsReceived(state, action: PayloadAction<Post[]>) {
      postsAdapter.setAll(state, action.payload);
    },
  },
});

export const { postAdded, postUpdated, postRemoved, postsReceived } = postsSlice.actions;

// Pre-built selectors
export const {
  selectAll: selectAllPosts,
  selectById: selectPostById,
  selectIds: selectPostIds,
} = postsAdapter.getSelectors((state: RootState) => state.posts);

export default postsSlice.reducer;

Selector Patterns

Selectors encapsulate the shape of state, keeping components decoupled from the store's internal structure. For derived state that involves filtering or mapping, use reselect (bundled with RTK) to memoize:

import { createSelector } from '@reduxjs/toolkit';
import type { RootState } from './store';

const selectPosts = (state: RootState) => state.posts.entities;
const selectFilter = (state: RootState) => state.filters.title;

export const selectFilteredPosts = createSelector(
  [selectPosts, selectFilter],
  (posts, filter) => {
    const all = Object.values(posts);
    return filter
      ? all.filter((p) => p.title.toLowerCase().includes(filter.toLowerCase()))
      : all;
  }
);

Memoized selectors only recompute when their inputs change, preventing unnecessary renders when unrelated state updates occur.

Middleware Patterns

Middleware sits between dispatching an action and the reducer reaching it, making it ideal for logging, analytics, crash reporting, and side effects. RTK's getDefaultMiddleware already includes thunk, immutability, and serializability checks. You can add custom middleware:

import { configureStore, Middleware } from '@reduxjs/toolkit';

const loggerMiddleware: Middleware = (storeApi) => (next) => (action) => {
  console.log('Dispatching:', action);
  const result = next(action);
  console.log('Next state:', storeApi.getState());
  return result;
};

export const store = configureStore({
  reducer: rootReducer,
  middleware: (getDefault) => getDefault().concat(loggerMiddleware),
});

Best Practices

Complementary Libraries

Conclusion

Redux remains a robust choice for managing complex application state, and Redux Toolkit has transformed the developer experience by removing boilerplate and baking in best practices. By combining slices for local feature state, normalized entity adapters for collections, memoized selectors for derived data, and RTK Query for server state, you can build scalable applications with predictable, debuggable state transitions. The key is to reach for Redux when you genuinely need centralized, shared state, and to lean on the ecosystem's patterns and libraries rather than reinventing them. With these tools and conventions in place, Redux scales gracefully from small dashboards to large enterprise applications.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles