メインコンテンツにスキップ

createEntityAdapter

概要

特定の種類のデータオブジェクトのインスタンスを含む正規化された状態構造に対してCRUD操作を実行するための、事前構築されたReducerとセレクターのセットを生成する関数です。これらのReducer関数は、`createReducer`および`createSlice`にケースReducerとして渡すことができます。また、`createReducer`および`createSlice`内で「ミューテート」ヘルパー関数として使用することもできます。

このAPIは、NgRxメンテナーによって作成された`@ngrx/entity`ライブラリから移植されましたが、Redux Toolkitで使用するために大幅に変更されています。このAPIを最初に作成し、移植とニーズへの適応を許可してくれたNgRxチームに感謝します。

注記

「エンティティ」という用語は、アプリケーションにおける一意のタイプのデータオブジェクトを指します。たとえば、ブログアプリケーションでは、`User`、`Post`、`Comment`データオブジェクトがあり、それぞれのインスタンスが多数クライアントに保存され、サーバーに永続化されます。 `User`は「エンティティ」であり、アプリケーションが使用する一意のタイプのデータオブジェクトです。エンティティの各一意のインスタンスは、特定のフィールドに一意のID値を持つと想定されます。

すべてのReduxロジックと同様に、プレーンなJSオブジェクトと配列*のみ*をストアに渡す必要があります - **クラスインスタンスは渡さないでください!**

このリファレンスの目的上、Redux状態ツリーの特定の部分でReducerロジックのコピーによって管理されている特定のデータ型を`Entity`と呼び、その型の単一インスタンスを`entity`と呼びます。例:`state.users`では、`Entity`は`User`型を指し、`state.users.entities[123]`は単一の`entity`になります。

`createEntityAdapter`によって生成されるメソッドはすべて、次のようになる「エンティティ状態」構造を操作します。

{
// The unique IDs of each item. Must be strings or numbers
ids: []
// A lookup table mapping entity IDs to the corresponding entity objects
entities: {
}
}

`createEntityAdapter`はアプリケーションで複数回呼び出すことができます。プレーンなJavaScriptで使用している場合、エンティティタイプが十分に似ている場合(すべて`entity.id`フィールドを持っているなど)、単一のアダプター定義を複数のエンティティタイプで再利用できる場合があります。TypeScriptを使用する場合、型定義が正しく推測されるように、個別の`Entity`型ごとに`createEntityAdapter`を個別に呼び出す必要があります。

使用例

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

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

const booksAdapter = createEntityAdapter({
// Assume IDs are stored in a field other than `book.id`
selectId: (book: Book) => book.bookId,
// Keep the "all IDs" array sorted based on book titles
sortComparer: (a, b) => a.title.localeCompare(b.title),
})

const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState(),
reducers: {
// Can pass adapter functions directly as case reducers. Because we're passing this
// as a value, `createSlice` will auto-generate the `bookAdded` action type / creator
bookAdded: booksAdapter.addOne,
booksReceived(state, action) {
// Or, call them as "mutating" helpers in a case reducer
booksAdapter.setAll(state, action.payload.books)
},
},
})

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

type RootState = ReturnType<typeof store.getState>

console.log(store.getState().books)
// { ids: [], entities: {} }

// Can create a set of memoized selectors based on the location of this entity state
const booksSelectors = booksAdapter.getSelectors<RootState>(
(state) => state.books
)

// And then use the selectors to retrieve values
const allBooks = booksSelectors.selectAll(store.getState())

パラメータ

`createEntityAdapter`は、2つのオプションフィールドを含む単一のオプションオブジェクトパラメータを受け入れます。

`selectId`

単一の`Entity`インスタンスを受け取り、内部にある一意のIDフィールドの値を返す関数です. 指定しない場合、デフォルトの実装は`entity => entity.id`です。`Entity`タイプが一意のID値を`entity.id`以外のフィールドに保持している場合、**必ず** `selectId`関数を指定する必要があります.

`sortComparer`

2つの`Entity`インスタンスを受け取り、ソートの相対順序を示す標準の`Array.sort()`数値結果(1、0、-1)を返すコールバック関数です。

指定した場合、`state.ids`配列はエンティティオブジェクトの比較に基づいてソートされた順序で保持されるため、ID配列をマッピングしてIDでエンティティを取得すると、ソートされたエンティティの配列が返されます。

指定しない場合、`state.ids`配列はソートされず、順序に関する保証はありません。つまり、`state.ids`は標準のJavascript配列のように動作すると予想できます。

ソートは、以下のCRUD関数のいずれか(たとえば、`addOne()`、`updateMany()`)によって状態が変更された場合にのみ開始されることに注意してください.

戻り値

「エンティティアダプター」インスタンス。エンティティアダプターは、生成されたReducer関数、元々提供された`selectId`および`sortComparer`コールバック、初期「エンティティ状態」値を生成するメソッド、およびこのエンティティタイプに対してグローバル化および非グローバル化されたメモ化セレクター関数のセットを生成する関数を含むプレーンなJSオブジェクト(クラスではありません)です。

アダプターインスタンスには、次のメソッドが含まれます(追加の参照TypeScriptタイプが含まれています)

export type EntityId = number | string

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

export type IdSelector<T> = (model: T) => EntityId

export type Update<T> = { id: EntityId; changes: Partial<T> }

export interface EntityState<T> {
ids: EntityId[]
entities: Record<EntityId, T>
}

export interface EntityDefinition<T> {
selectId: IdSelector<T>
sortComparer: false | Comparer<T>
}

export interface EntityStateAdapter<T> {
addOne<S extends EntityState<T>>(state: S, entity: T): S
addOne<S extends EntityState<T>>(state: S, action: PayloadAction<T>): S

addMany<S extends EntityState<T>>(state: S, entities: T[]): S
addMany<S extends EntityState<T>>(state: S, entities: PayloadAction<T[]>): S

setOne<S extends EntityState<T>>(state: S, entity: T): S
setOne<S extends EntityState<T>>(state: S, action: PayloadAction<T>): S

setMany<S extends EntityState<T>>(state: S, entities: T[]): S
setMany<S extends EntityState<T>>(state: S, entities: PayloadAction<T[]>): S

setAll<S extends EntityState<T>>(state: S, entities: T[]): S
setAll<S extends EntityState<T>>(state: S, entities: PayloadAction<T[]>): S

removeOne<S extends EntityState<T>>(state: S, key: EntityId): S
removeOne<S extends EntityState<T>>(state: S, key: PayloadAction<EntityId>): S

removeMany<S extends EntityState<T>>(state: S, keys: EntityId[]): S
removeMany<S extends EntityState<T>>(
state: S,
keys: PayloadAction<EntityId[]>,
): S

removeAll<S extends EntityState<T>>(state: S): S

updateOne<S extends EntityState<T>>(state: S, update: Update<T>): S
updateOne<S extends EntityState<T>>(
state: S,
update: PayloadAction<Update<T>>,
): S

updateMany<S extends EntityState<T>>(state: S, updates: Update<T>[]): S
updateMany<S extends EntityState<T>>(
state: S,
updates: PayloadAction<Update<T>[]>,
): S

upsertOne<S extends EntityState<T>>(state: S, entity: T): S
upsertOne<S extends EntityState<T>>(state: S, entity: PayloadAction<T>): S

upsertMany<S extends EntityState<T>>(state: S, entities: T[]): S
upsertMany<S extends EntityState<T>>(
state: S,
entities: PayloadAction<T[]>,
): S
}

export interface EntitySelectors<T, V> {
selectIds: (state: V) => EntityId[]
selectEntities: (state: V) => Record<EntityId, T>
selectAll: (state: V) => T[]
selectTotal: (state: V) => number
selectById: (state: V, id: EntityId) => T | undefined
}

export interface EntityAdapter<T> extends EntityStateAdapter<T> {
selectId: IdSelector<T>
sortComparer: false | Comparer<T>
getInitialState(): EntityState<T>
getInitialState<S extends object>(state: S): EntityState<T> & S
getSelectors(): EntitySelectors<T, EntityState<T>>
getSelectors<V>(
selectState: (state: V) => EntityState<T>,
): EntitySelectors<T, V>
}

CRUD関数

エンティティアダプターの主な内容は、エンティティ状態オブジェクトからエンティティインスタンスを追加、更新、削除するための生成されたReducer関数のセットです.

  • `addOne`:単一のエンティティを受け取り、まだ存在しない場合は追加します。
  • `addMany`:エンティティの配列または`Record<EntityId, T>`形式のオブジェクトを受け取り、まだ存在しない場合は追加します。
  • `setOne`:単一のエンティティを受け取り、追加または置換します。
  • `setMany`:エンティティの配列または`Record<EntityId, T>`形式のオブジェクトを受け取り、追加または置換します。
  • `setAll`:エンティティの配列または`Record<EntityId, T>`形式のオブジェクトを受け取り、既存のすべてのエンティティを配列の値に置き換えます。
  • `removeOne`:単一のエンティティID値を受け取り、そのIDを持つエンティティが存在する場合は削除します。
  • `removeMany`:エンティティID値の配列を受け取り、それらのIDを持つ各エンティティが存在する場合は削除します。
  • `removeAll`:エンティティ状態オブジェクトからすべてのエンティティを削除します。
  • `updateOne`:エンティティIDと、`changes`フィールド内に更新する1つ以上の新しいフィールド値を含むオブジェクトを含む「更新オブジェクト」を受け取り、対応するエンティティに対してシャローアップデートを実行します.
  • `updateMany`:更新オブジェクトの配列を受け取り、対応するすべてのエンティティに対してシャローアップデートを実行します.
  • `upsertOne`: 単一のエンティティを受け入れます. その ID を持つエンティティが既に存在する場合、シャローアップデートを実行し、指定されたフィールドは既存のエンティティにマージされ、一致するフィールドは既存の値を上書きします. エンティティが存在しない場合は、追加されます.
  • `upsertMany`: 浅くアップサートされるエンティティの配列または `Record<EntityId, T>` 形式のオブジェクトを受け入れます.
エンティティを追加、設定、またはアップサートする必要がありますか?

3 つのオプションすべてが*新しい*エンティティをリストに挿入します. ただし、既に存在するエンティティの処理方法が異なります. エンティティが**既に存在する**場合

  • `addOne` および `addMany` は新しいエンティティに対して何も行いません
  • `setOne` および `setMany` は古いエンティティを新しいエンティティに完全に置き換えます. これにより、新しいバージョンのエンティティに存在しないエンティティのプロパティもすべて削除されます.
  • `upsertOne` および `upsertMany` は、既存の値を上書きし、存在しなかった値を追加し、新しいエンティティで提供されていないプロパティには触れずに、古いエンティティと新しいエンティティをマージするためにシャローコピーを実行します.

各メソッドは、次のようなシグネチャを持ちます

;(state: EntityState<T>, argument: TypeOrPayloadAction<Argument<T>>) =>
EntityState<T>

つまり、`{ids: [], entities: {}}`のような状態を受け取り、新しい状態を計算して返します。

これらのCRUDメソッドは、複数の方法で使用できます

  • `createReducer`および`createSlice`にケースReducerとして直接渡すことができます。
  • `state`引数が実際にはImmer `Draft`値である場合、既存のケースReducer内で`addOne()`を個別に手書きで呼び出すなど、手動で呼び出されたときに「ミューテート」ヘルパーメソッドとして使用できます。
  • `state`引数が実際にはプレーンなJSオブジェクトまたは配列である場合、手動で呼び出されたときに不変の更新メソッドとして使用できます。
注記

これらのメソッドには、対応するReduxアクションが作成されていません。これらは単なるスタンドアロンのReducer / 更新ロジックです。**これらのメソッドをどこでどのように使用するかを決定するのは完全にあなた次第です!**ほとんどの場合、`createSlice`に渡すか、別のReducer内で使用します。

各メソッドは、`state`引数がImmer `Draft`であるかどうかを確認します。ドラフトである場合、メソッドはさらにミューテートしても安全であると想定します。ドラフトでない場合、メソッドはプレーンなJS値をImmerの`createNextState()`に渡し、不変に更新された結果値を返します.

`argument`は、プレーンな値(`addOne()`の場合は単一の`Entity`オブジェクト、`addMany()`の場合は`Entity[]`配列など)、または`action.payload`と同じ値を持つ`PayloadAction`アクションオブジェクトのいずれかです。これにより、ヘルパー関数とReducerの両方として使用できます。

浅い更新に関する注意: updateOneupdateManyupsertOne、および upsertMany は、可変的な方法で浅い更新のみを実行します。つまり、更新/アップサートがネストされたプロパティを含むオブジェクトで構成されている場合、入力された変更の値は既存のネストされたオブジェクト全体を**上書き**します。これは、アプリケーションにとって意図しない動作となる可能性があります。一般的なルールとして、これらのメソッドは、ネストされたプロパティを*持たない* 正規化されたデータ で使用するのに最適です。

getInitialState

{ids: [], entities: {}} のような新しいエンティティ状態オブジェクトを返します。

オプションのオブジェクトを引数として受け入れます。そのオブジェクトのフィールドは、返された初期状態値にマージされます。たとえば、スライスで読み込み状態も追跡したい場合があります。

const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState({
loading: 'idle',
}),
reducers: {
booksLoadingStarted(state, action) {
// Can update the additional state field
state.loading = 'pending'
},
},
})

エンティティの配列または Record<EntityId, T> オブジェクトを渡して、初期状態にいくつかのエンティティを事前に設定することもできます。

const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState(
{
loading: 'idle',
},
[
{ id: 'a', title: 'First' },
{ id: 'b', title: 'Second' },
],
),
reducers: {},
})

これは、以下を呼び出すことと同じです。

const initialState = booksAdapter.getInitialState({
loading: 'idle',
})

const prePopulatedState = booksAdapter.setAll(initialState, [
{ id: 'a', title: 'First' },
{ id: 'b', title: 'Second' },
])

追加のプロパティが必要ない場合は、最初のパラメータは undefined にすることができます。

セレクター関数

エンティティアダプターには、エンティティ状態オブジェクトの内容を読み取る方法を知っているセレクターのセットを返す getSelectors() 関数が含まれています。

  • selectIds: state.ids 配列を返します。
  • selectEntities: state.entities ルックアップテーブルを返します。
  • selectAll: state.ids 配列をマップし、同じ順序でエンティティの配列を返します。
  • selectTotal: この状態で保存されているエンティティの総数を返します。
  • selectById: 状態とエンティティIDが与えられると、そのIDを持つエンティティまたは undefined を返します。

各セレクター関数は、Reselect の createSelector 関数を使用して作成され、結果の計算のメモ化を可能にします。

ヒント

使用される createSelector インスタンスは、オプションオブジェクト(2番目のパラメーター)の一部として渡すことで置き換えることができます。

import {
createDraftSafeSelectorCreator,
weakMapMemoize,
} from '@reduxjs/toolkit'

const createWeakMapDraftSafeSelector =
createDraftSafeSelectorCreator(weakMapMemoize)

const simpleSelectors = booksAdapter.getSelectors(undefined, {
createSelector: createWeakMapDraftSafeSelector,
})

const globalizedSelectors = booksAdapter.getSelectors((state) => state.books, {
createSelector: createWeakMapDraftSafeSelector,
})

インスタンスが渡されない場合、デフォルトで createDraftSafeSelector になります。

セレクター関数は、この特定のエンティティ状態オブジェクトが状態ツリーのどこに保持されているかを知っている必要があるため、getSelectors() は2つの方法で呼び出すことができます。

  • 引数なしで(または最初のパラメーターとして undefined を使用して)呼び出された場合、state 引数が読み取る実際のエンティティ状態オブジェクトであると想定する「グローバル化されていない」セレクター関数のセットを返します。
  • Redux状態ツリー全体を受け取り、正しいエンティティ状態オブジェクトを返すセレクター関数を使用して呼び出すこともできます。

たとえば、Book タイプのエンティティ状態は、Redux状態ツリーに state.books として保持される場合があります。 getSelectors() を使用して、その状態から2つの方法で読み取ることができます。

const store = configureStore({
reducer: {
books: booksReducer,
},
})

const simpleSelectors = booksAdapter.getSelectors()
const globalizedSelectors = booksAdapter.getSelectors((state) => state.books)

// Need to manually pass the correct entity state object in to this selector
const bookIds = simpleSelectors.selectIds(store.getState().books)

// This selector already knows how to find the books entity state
const allBooks = globalizedSelectors.selectAll(store.getState())

注記

複数の更新の適用

updateMany() が同じIDを対象とした複数の更新で呼び出された場合、それらは単一の更新にマージされ、後の更新が前の更新を上書きします。

updateOne()updateMany() の両方で、既存のエンティティのIDを2番目の既存のエンティティのIDと一致するように変更すると、最初のエンティティが2番目のエンティティを完全に置き換えます。

CRUDメソッドとセレクターのいくつかを実行する

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

// Since we don't provide `selectId`, it defaults to assuming `entity.id` is the right field
const booksAdapter = createEntityAdapter({
// Keep the "all IDs" array sorted based on book titles
sortComparer: (a, b) => a.title.localeCompare(b.title),
})

const booksSlice = createSlice({
name: 'books',
initialState: booksAdapter.getInitialState({
loading: 'idle',
}),
reducers: {
// Can pass adapter functions directly as case reducers. Because we're passing this
// as a value, `createSlice` will auto-generate the `bookAdded` action type / creator
bookAdded: booksAdapter.addOne,
booksLoading(state, action) {
if (state.loading === 'idle') {
state.loading = 'pending'
}
},
booksReceived(state, action) {
if (state.loading === 'pending') {
// Or, call them as "mutating" helpers in a case reducer
booksAdapter.setAll(state, action.payload)
state.loading = 'idle'
}
},
bookUpdated: booksAdapter.updateOne,
},
})

const { bookAdded, booksLoading, booksReceived, bookUpdated } =
booksSlice.actions

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

// Check the initial state:
console.log(store.getState().books)
// {ids: [], entities: {}, loading: 'idle' }

const booksSelectors = booksAdapter.getSelectors((state) => state.books)

store.dispatch(bookAdded({ id: 'a', title: 'First' }))
console.log(store.getState().books)
// {ids: ["a"], entities: {a: {id: "a", title: "First"}}, loading: 'idle' }

store.dispatch(bookUpdated({ id: 'a', changes: { title: 'First (altered)' } }))
store.dispatch(booksLoading())
console.log(store.getState().books)
// {ids: ["a"], entities: {a: {id: "a", title: "First (altered)"}}, loading: 'pending' }

store.dispatch(
booksReceived([
{ id: 'b', title: 'Book 3' },
{ id: 'c', title: 'Book 2' },
]),
)

console.log(booksSelectors.selectIds(store.getState()))
// "a" was removed due to the `setAll()` call
// Since they're sorted by title, "Book 2" comes before "Book 3"
// ["c", "b"]

console.log(booksSelectors.selectAll(store.getState()))
// All book entries in sorted order
// [{id: "c", title: "Book 2"}, {id: "b", title: "Book 3"}]