CtrlK
BlogDocsLog inGet started
Tessl Logo

tessl/npm-reduxjs--toolkit

The official, opinionated, batteries-included toolset for efficient Redux development

Pending
Overview
Eval results
Files

entity-adapters.mddocs/

Entity Management

Redux Toolkit's entity adapter provides a standardized way to manage normalized entity state with prebuilt CRUD operations and selectors.

Capabilities

Create Entity Adapter

Creates an adapter for managing normalized entity collections with automatic CRUD operations.

/**
 * Creates adapter for managing normalized entity state
 * @param options - Configuration options for entity management
 * @returns EntityAdapter with state methods and selectors
 */
function createEntityAdapter<T, Id extends EntityId = EntityId>(options?: {
  selectId?: IdSelector<T, Id>;
  sortComparer?: false | Comparer<T>;
}): EntityAdapter<T, Id>;

type EntityId = number | string;

type IdSelector<T, Id extends EntityId> = (entity: T) => Id;

type Comparer<T> = (a: T, b: T) => number;

interface EntityAdapter<T, Id extends EntityId> {
  // State manipulation methods
  addOne<S extends EntityState<T, Id>>(state: S, entity: T): void;
  addMany<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;
  setOne<S extends EntityState<T, Id>>(state: S, entity: T): void;
  setMany<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;
  setAll<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;
  removeOne<S extends EntityState<T, Id>>(state: S, key: Id): void;
  removeMany<S extends EntityState<T, Id>>(state: S, keys: readonly Id[]): void;
  removeAll<S extends EntityState<T, Id>>(state: S): void;
  updateOne<S extends EntityState<T, Id>>(state: S, update: Update<T, Id>): void;
  updateMany<S extends EntityState<T, Id>>(state: S, updates: ReadonlyArray<Update<T, Id>>): void;
  upsertOne<S extends EntityState<T, Id>>(state: S, entity: T): void;
  upsertMany<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;
  
  // State creation and selector methods
  getInitialState(): EntityState<T, Id>;
  getInitialState<S extends Record<string, any>>(state: S): EntityState<T, Id> & S;
  getSelectors(): EntitySelectors<T, EntityState<T, Id>, Id>;
  getSelectors<V>(selectState: (state: V) => EntityState<T, Id>): EntitySelectors<T, V, Id>;
}

interface EntityState<T, Id extends EntityId> {
  /** Array of entity IDs in order */
  ids: Id[];
  /** Normalized entity lookup table */
  entities: Record<Id, T>;
}

interface Update<T, Id extends EntityId> {
  id: Id;
  changes: Partial<T>;
}

Usage Examples:

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

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

// Create entity adapter
const usersAdapter = createEntityAdapter<User>({
  // Optional: custom ID selection (defaults to entity.id)
  selectId: (user) => user.id,
  
  // Optional: sorting function for IDs array
  sortComparer: (a, b) => a.name.localeCompare(b.name)
});

// Create slice with entity adapter
const usersSlice = createSlice({
  name: 'users',
  initialState: usersAdapter.getInitialState({
    // Additional state beyond entities and ids
    loading: false,
    error: null as string | null
  }),
  reducers: {
    userAdded: usersAdapter.addOne,
    usersReceived: usersAdapter.setAll,
    userUpdated: usersAdapter.updateOne,
    userRemoved: usersAdapter.removeOne,
    
    // Custom reducers can use adapter methods
    userUpserted: (state, action) => {
      usersAdapter.upsertOne(state, action.payload);
      // Can also modify additional state
      state.loading = false;
    }
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.pending, (state) => {
        state.loading = true;
      })
      .addCase(fetchUsers.fulfilled, (state, action) => {
        state.loading = false;
        usersAdapter.setAll(state, action.payload);
      })
      .addCase(fetchUsers.rejected, (state, action) => {
        state.loading = false;
        state.error = action.error.message || null;
      });
  }
});

export const { userAdded, usersReceived, userUpdated, userRemoved } = usersSlice.actions;
export default usersSlice.reducer;

Entity State Structure

The normalized state structure used by entity adapters.

/**
 * Standard entity state structure with normalized data
 */
interface EntityState<T, Id extends EntityId = EntityId> {
  /** Array of entity IDs maintaining order */
  ids: Id[];
  /** Lookup table mapping ID to entity */
  entities: Record<Id, T>;
}

/**
 * Valid entity ID types
 */
type EntityId = number | string;

/**
 * Update object for partial entity updates
 */
interface Update<T, Id extends EntityId = EntityId> {
  /** ID of the entity to update */
  id: Id;
  /** Partial changes to apply */
  changes: Partial<T>;
}

Usage Examples:

// Example state structure
const initialState = {
  ids: ['user1', 'user2', 'user3'],
  entities: {
    'user1': { id: 'user1', name: 'Alice', email: 'alice@example.com' },
    'user2': { id: 'user2', name: 'Bob', email: 'bob@example.com' },
    'user3': { id: 'user3', name: 'Charlie', email: 'charlie@example.com' }
  }
};

// Update object example
const update: Update<User, string> = {
  id: 'user1',
  changes: { name: 'Alice Smith' }
};

Entity Selectors

Prebuilt selectors for accessing entity data with memoization.

/**
 * Generated selectors for entity data access
 */
interface EntitySelectors<T, V, Id extends EntityId> {
  /** Select the array of entity IDs */
  selectIds(state: V): Id[];
  
  /** Select the entities lookup table */
  selectEntities(state: V): Record<Id, T>;
  
  /** Select all entities as an array */
  selectAll(state: V): T[];
  
  /** Select the total number of entities */
  selectTotal(state: V): number;
  
  /** Select entity by ID */
  selectById(state: V, id: Id): T | undefined;
}

Usage Examples:

// Get selectors for the entity adapter
const {
  selectIds: selectUserIds,
  selectEntities: selectUserEntities,
  selectAll: selectAllUsers,
  selectTotal: selectUsersTotal,
  selectById: selectUserById
} = usersAdapter.getSelectors();

// Use with state selector
const userSelectors = usersAdapter.getSelectors((state: RootState) => state.users);

// In components
const UsersList = () => {
  const users = useSelector(userSelectors.selectAll);
  const totalUsers = useSelector(userSelectors.selectTotal);
  
  return (
    <div>
      <h2>Users ({totalUsers})</h2>
      {users.map(user => (
        <UserItem key={user.id} user={user} />
      ))}
    </div>
  );
};

const UserProfile = ({ userId }: { userId: string }) => {
  const user = useSelector(state => userSelectors.selectById(state, userId));
  
  if (!user) {
    return <div>User not found</div>;
  }
  
  return <div>{user.name} - {user.email}</div>;
};

// Custom selectors built on entity selectors
const selectActiveUsers = createSelector(
  [userSelectors.selectAll],
  (users) => users.filter(user => user.isActive)
);

const selectUsersByRole = createSelector(
  [userSelectors.selectAll, (state, role: string) => role],
  (users, role) => users.filter(user => user.role === role)
);

CRUD Operations

The entity adapter provides all standard CRUD operations with Immer integration.

/**
 * Add single entity to the collection
 * @param state - Entity state
 * @param entity - Entity to add
 */
addOne<S extends EntityState<T, Id>>(state: S, entity: T): void;

/**
 * Add multiple entities to the collection
 * @param state - Entity state
 * @param entities - Array of entities or entities object
 */
addMany<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;

/**
 * Add or replace single entity
 * @param state - Entity state
 * @param entity - Entity to set
 */
setOne<S extends EntityState<T, Id>>(state: S, entity: T): void;

/**
 * Add or replace multiple entities
 * @param state - Entity state
 * @param entities - Array of entities or entities object
 */
setMany<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;

/**
 * Replace all entities in the collection
 * @param state - Entity state
 * @param entities - Array of entities or entities object
 */
setAll<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;

/**
 * Remove entity by ID
 * @param state - Entity state
 * @param key - Entity ID to remove
 */
removeOne<S extends EntityState<T, Id>>(state: S, key: Id): void;

/**
 * Remove multiple entities by ID
 * @param state - Entity state
 * @param keys - Array of entity IDs to remove
 */
removeMany<S extends EntityState<T, Id>>(state: S, keys: readonly Id[]): void;

/**
 * Remove all entities from the collection
 * @param state - Entity state
 */
removeAll<S extends EntityState<T, Id>>(state: S): void;

/**
 * Update single entity with partial changes
 * @param state - Entity state
 * @param update - Update object with id and changes
 */
updateOne<S extends EntityState<T, Id>>(state: S, update: Update<T, Id>): void;

/**
 * Update multiple entities with partial changes
 * @param state - Entity state
 * @param updates - Array of update objects
 */
updateMany<S extends EntityState<T, Id>>(state: S, updates: ReadonlyArray<Update<T, Id>>): void;

/**
 * Add or update single entity (insert if new, update if exists)
 * @param state - Entity state
 * @param entity - Entity to upsert
 */
upsertOne<S extends EntityState<T, Id>>(state: S, entity: T): void;

/**
 * Add or update multiple entities
 * @param state - Entity state
 * @param entities - Array of entities or entities object
 */
upsertMany<S extends EntityState<T, Id>>(state: S, entities: readonly T[] | Record<Id, T>): void;

Usage Examples:

const usersSlice = createSlice({
  name: 'users',
  initialState: usersAdapter.getInitialState(),
  reducers: {
    // Direct adapter method usage
    userAdded: usersAdapter.addOne,
    usersLoaded: usersAdapter.setAll,
    userUpdated: usersAdapter.updateOne,
    userRemoved: usersAdapter.removeOne,
    
    // Custom reducers using adapter methods
    bulkUpdateUsers: (state, action) => {
      const updates = action.payload.map(user => ({
        id: user.id,
        changes: { lastUpdated: Date.now(), ...user.changes }
      }));
      usersAdapter.updateMany(state, updates);
    },
    
    toggleUserActive: (state, action) => {
      const user = state.entities[action.payload.id];
      if (user) {
        usersAdapter.updateOne(state, {
          id: action.payload.id,
          changes: { isActive: !user.isActive }
        });
      }
    },
    
    syncUsers: (state, action) => {
      // Replace all users and maintain sort order
      usersAdapter.setAll(state, action.payload);
    }
  }
});

// Usage in async thunks
const fetchUsers = createAsyncThunk(
  'users/fetchUsers',
  async () => {
    const response = await api.getUsers();
    return response.data;
  }
);

const updateUser = createAsyncThunk(
  'users/updateUser',
  async ({ id, changes }: { id: string; changes: Partial<User> }) => {
    const response = await api.updateUser(id, changes);
    return { id, changes: response.data };
  }
);

// In extraReducers
const usersSlice = createSlice({
  name: 'users',
  initialState: usersAdapter.getInitialState({ loading: false }),
  reducers: {},
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsers.fulfilled, (state, action) => {
        usersAdapter.setAll(state, action.payload);
      })
      .addCase(updateUser.fulfilled, (state, action) => {
        usersAdapter.updateOne(state, action.payload);
      });
  }
});

Advanced Patterns

Custom ID Selection

interface Product {
  sku: string;
  name: string;
  category: string;
  price: number;
}

// Use custom field as ID
const productsAdapter = createEntityAdapter<Product, string>({
  selectId: (product) => product.sku,
  sortComparer: (a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name)
});

Nested Entity Management

interface Comment {
  id: string;
  postId: string;
  text: string;
  authorId: string;
}

const commentsAdapter = createEntityAdapter<Comment>({
  sortComparer: (a, b) => a.createdAt - b.createdAt
});

const commentsSlice = createSlice({
  name: 'comments',
  initialState: commentsAdapter.getInitialState(),
  reducers: {
    commentsLoaded: commentsAdapter.setAll,
    commentAdded: commentsAdapter.addOne,
    
    // Remove all comments for a post
    postCommentsRemoved: (state, action) => {
      const commentIds = state.ids.filter(id => 
        state.entities[id]?.postId === action.payload.postId
      );
      commentsAdapter.removeMany(state, commentIds);
    }
  }
});

// Selectors for nested data
const commentSelectors = commentsAdapter.getSelectors((state: RootState) => state.comments);

const selectCommentsByPost = createSelector(
  [commentSelectors.selectAll, (state, postId: string) => postId],
  (comments, postId) => comments.filter(comment => comment.postId === postId)
);

Optimistic Updates with Entities

const optimisticUpdateUser = createAsyncThunk(
  'users/optimisticUpdate',
  async (
    { id, changes }: { id: string; changes: Partial<User> },
    { dispatch, rejectWithValue }
  ) => {
    // Apply optimistic update immediately
    dispatch(userUpdatedOptimistically({ id, changes }));
    
    try {
      const response = await api.updateUser(id, changes);
      return { id, changes: response.data };
    } catch (error) {
      // Revert on failure
      dispatch(revertOptimisticUpdate(id));
      return rejectWithValue(error.message);
    }
  }
);

const usersSlice = createSlice({
  name: 'users',
  initialState: usersAdapter.getInitialState({
    optimisticUpdates: {} as Record<string, Partial<User>>
  }),
  reducers: {
    userUpdatedOptimistically: (state, action) => {
      const { id, changes } = action.payload;
      state.optimisticUpdates[id] = changes;
      usersAdapter.updateOne(state, { id, changes });
    },
    
    revertOptimisticUpdate: (state, action) => {
      const id = action.payload;
      const originalChanges = state.optimisticUpdates[id];
      if (originalChanges) {
        // Revert the changes
        const revertedChanges = Object.keys(originalChanges).reduce((acc, key) => {
          // This would need original values stored somewhere
          return acc;
        }, {});
        usersAdapter.updateOne(state, { id, changes: revertedChanges });
        delete state.optimisticUpdates[id];
      }
    }
  },
  extraReducers: (builder) => {
    builder.addCase(optimisticUpdateUser.fulfilled, (state, action) => {
      const { id, changes } = action.payload;
      delete state.optimisticUpdates[id];
      usersAdapter.updateOne(state, { id, changes });
    });
  }
});

Pagination with Entities

interface PaginatedUsersState extends EntityState<User> {
  currentPage: number;
  totalPages: number;
  pageSize: number;
  totalItems: number;
  loading: boolean;
}

const usersAdapter = createEntityAdapter<User>();

const usersSlice = createSlice({
  name: 'users',
  initialState: usersAdapter.getInitialState({
    currentPage: 1,
    totalPages: 0,
    pageSize: 20,
    totalItems: 0,
    loading: false
  } as PaginatedUsersState),
  reducers: {
    pageChanged: (state, action) => {
      state.currentPage = action.payload;
    }
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchUsersPage.pending, (state) => {
        state.loading = true;
      })
      .addCase(fetchUsersPage.fulfilled, (state, action) => {
        state.loading = false;
        const { data, pagination } = action.payload;
        
        if (action.meta.arg.replace) {
          // Replace all users (new search/filter)
          usersAdapter.setAll(state, data);
        } else {
          // Append users (load more)
          usersAdapter.addMany(state, data);
        }
        
        state.currentPage = pagination.page;
        state.totalPages = pagination.totalPages;
        state.totalItems = pagination.totalItems;
      });
  }
});

// Selectors for paginated data
const selectPaginatedUsers = createSelector(
  [usersAdapter.getSelectors().selectAll, (state: RootState) => state.users],
  (users, usersState) => ({
    users,
    currentPage: usersState.currentPage,
    totalPages: usersState.totalPages,
    totalItems: usersState.totalItems,
    hasMore: usersState.currentPage < usersState.totalPages
  })
);

Install with Tessl CLI

npx tessl i tessl/npm-reduxjs--toolkit

docs

actions-reducers.md

async-thunks.md

core-store.md

entity-adapters.md

index.md

middleware.md

react-integration.md

rtk-query-react.md

rtk-query.md

utilities.md

tile.json