Mastering Redux Toolkit’s createEntityAdapter

Read time: 4 minutes
Kagan Mert
Kagan Mert

Managing and updating data in modern web applications can become complex. Redux Toolkit offers many helpful tools to reduce this complexity. One of these tools, createEntityAdapter, generates ready-made reducer functions and selectors that keep data in a normalized structure and make CRUD (Create, Read, Update, Delete) operations easy. In this article, we will examine what createEntityAdapter does, how it is used and how you can apply it with examples.

What is createEntityAdapter?

createEntityAdapter is designed to perform state management of certain data types (e.g. User, Post, Comment) in an application with a normalized structure. The normalized state structure is usually in the following format:

{
  // Array containing all unique IDs
  ids: [],
  // Lookup table matching each entity by its ID
  entities: {}
}

Thanks to this structure, accessing, updating or deleting a given entity becomes much faster and more efficient.

In addition to creating this structure, Redux Toolkit's createEntityAdapter provides ready-made methods to perform the following CRUD operations:

  • addOne / addMany: Adds new entities, does not modify existing ones.
  • setOne / setMany: Replaces existing entities with completely new data.
  • upsertOne / upsertMany: Updates existing entities or adds new ones (with shallow merge).
  • setAll: Removes all existing entities and recreates the state with new data.
  • removeOne / removeMany / removeAll: Removes specified entities or all of them from the state.
  • updateOne / updateMany: Updates specific fields in existing entities.
  • Also, the getSelectors function included with the adapter generates memoized selectors to read the entity state.

Basic Concepts

Entity and Normalized State

In an application, the term “Entity” refers to a specific data type. For example, in a blog application, User, Post and Comment data types can be considered as entities. Each entity is expected to have a unique ID. Normalizing the state in this way enables fast operations on the data and avoids unnecessary data duplication.

selectId and sortComparer

createEntityAdapter uses the selectId function to specify the unique ID field in data objects. By default entity.id is used, but you can define a custom function if your data structure is different.

You can also use the sortComparer function to keep the ids array sorted. For example, localeCompare can be used to sort books by title.

Using Adapters with Small Examples

Example 1: Creating a Simple Entity Adapter

The following example creates an adapter that uses the default ID field id and sorts books by their titles:

import { createEntityAdapter } from '@reduxjs/toolkit'

type Book = { id: string; title: string }

const booksAdapter = createEntityAdapter<Book>({
  // Let's sort the books according to their titles
  sortComparer: (a, b) => a.title.localeCompare(b.title),
})

// Create initial state
const initialState = booksAdapter.getInitialState()

console.log(initialState)
// Output: { ids: [], entities: {} }

In this example, we initially create an empty state using the booksAdapter. The adapter prepares the normalized structure of the state before adding the books.

Example 2: Using addOne and updateOne

The following example shows how adapter's addOne and updateOne functions work:

import { createEntityAdapter } from '@reduxjs/toolkit'

type Product = { id: string; name: string; price: number }
const productsAdapter = createEntityAdapter<Product>()

// Initial state
let state = productsAdapter.getInitialState()

// Let's add a product
state = productsAdapter.addOne(state, { id: 'p1', name: 'Laptop', price: 1200 })
console.log(state)
// Output: { ids: ['p1'], entities: { p1: { id: 'p1', name: 'Laptop', price: 1200 } } }

// Let's update the product
state = productsAdapter.updateOne(state, { id: 'p1', changes: { price: 1100 } })
console.log(state)
// Output: { ids: ['p1'], entities: { p1: { id: 'p1', name: 'Laptop', price: 1100 } } }

Here, a product is added first, then updateOne is used to update the price information.

Example 3: Book Management with Redux Toolkit

Now let's look at a more detailed example where we will create a full-fledged Redux slice using createEntityAdapter. In this example, we will create a structure that adds, updates, deletes and sorts books.

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

// We define our book type. Here, the ID field will be set as `bookId`.
type Book = { bookId: string; title: string }

// We create an adapter: 
// - selectId to specify the unique ID field,
// - sortComparer to sort the books by title.
const booksAdapter = createEntityAdapter<Book>({
  selectId: (book) => book.bookId,
  sortComparer: (a, b) => a.title.localeCompare(b.title),
})

// Slice creation
const booksSlice = createSlice({
  name: 'books',
  // We create the initial state via the adapter and add an additional loading area.
  initialState: booksAdapter.getInitialState({ loading: 'idle' }),
  reducers: {
    // addOne: We use the direct adapter function to add books.
    bookAdded: booksAdapter.addOne,
    // We update the loading state to indicate that the books have started loading.
    booksLoading(state) {
      if (state.loading === 'idle') {
        state.loading = 'pending'
      }
    },
    // When books are received, we completely replace existing books with new arrivals.
    booksReceived(state, action) {
      if (state.loading === 'pending') {
        // update the books in the state using setAll
        booksAdapter.setAll(state, action.payload)
        state.loading = 'idle'
      }
    },
    // UpdateOne function to update one book
    bookUpdated: booksAdapter.updateOne,
  },
})

// Destructure the created actions
const { bookAdded, booksLoading, booksReceived, bookUpdated } = booksSlice.actions

// Store configuration
const store = configureStore({
  reducer: {
    books: booksSlice.reducer,
  },
})

// We create selectors. 
// These selectors will read the books state from the global state.
const booksSelectors = booksAdapter.getSelectors((state) => state.books)

// Let's check the initial state:
console.log(store.getState().books)
// Expected output: { ids: [], entities: {}, loading: 'idle' }

// Let's add a new book:
store.dispatch(bookAdded({ bookId: 'a', title: 'İlk Kitap' }))
console.log(store.getState().books)
// Expected output: { ids: ['a'], entities: { a: { bookId: 'a', title: 'First Book' } } }, loading: 'idle' }

// Let's update a book:
store.dispatch(bookUpdated({ id: 'a', changes: { title: 'İlk Kitap (Güncellendi)' } }))

// Let's change the loading state to simulate the loading process:
store.dispatch(booksLoading())
console.log(store.getState().books)
// Output: { ids: ['a'], entities: { a: { bookId: 'a', title: 'First Book (Updated)' } } }, loading: 'pending' }

// Let's use booksReceived to add new books collectively and delete old data:
store.dispatch(
  booksReceived([
    { bookId: 'b', title: 'Üçüncü Kitap' },
    { bookId: 'c', title: 'İkinci Kitap' },
  ]),
)

// Let's use the selectors to get the IDs and books in order:
console.log(booksSelectors.selectIds(store.getState()))
// "a" was removed, and books were sorted by title: For example: ['c', 'b']

console.log(booksSelectors.selectAll(store.getState()))
// Output: [{ bookId: 'c', title: 'Second Book' }, { bookId: 'b', title: 'Book Three' }]

Adapter Definition:

  • selectId function sets bookId as the unique ID field of books.
  • sortComparer function keeps the ids array organized by sorting books by title.

Slice and Reducers:

  • bookAdded adds a single book,
  • booksReceived sets the incoming book list to state (deleting previous books),
  • bookUpdated updates an existing book.
  • In addition, the booksLoading reducer manages the loading state.

Store and Selector Usage:

When configuring the Redux store, the books state was created using booksSlice.reducer. Thanks to the selectors created with getSelectors, it is easier to access the entities in the state. ❤️

This detailed example shows how normalized state management can be done in a simple and effective way with the functions provided by createEntityAdapter.

Conclusion

Redux Toolkit's createEntityAdapter is an ideal solution for normalizing state and simplifying CRUD operations when working with large and complex datasets. You can both learn the basics with small examples and apply it in your own projects by studying the detailed example above. Using this structure, it is possible to create cleaner, more readable and performant Redux logic.

For more information about the Redux Toolkit and createEntityAdapter, check out the official documentation.

Happy coding! 👨🏻‍💻