Pinia 正規化データ設計:Entity アダプタで巨大リストを高速・一貫に保つ

Vue.js や Nuxt.js でアプリケーションを開発していると、数千件のリストデータや複雑なネストしたオブジェクトを扱うシーンに直面します。こうした大量データを配列のまま管理すると、検索や更新に時間がかかり、パフォーマンスが劣化してしまいますね。さらに、同じデータが複数の場所に重複して保存されていると、更新時の整合性を保つのが難しくなります。
本記事では、Pinia で正規化データ設計と Entity アダプタパターンを組み合わせる方法を解説します。この手法を使えば、巨大なリストでも高速にアクセスでき、データの一貫性を簡単に保てるようになりますよ。
背景
配列によるデータ管理の限界
通常、API から取得したユーザーリストや商品リストは配列形式で返されます。Pinia の State にそのまま配列で保存すると、実装はシンプルですが、データ量が増えるにつれて以下の課題が顕在化してくるでしょう。
- 検索の遅延: 特定の ID を持つアイテムを探すには
array.find()
で全要素を走査する必要があり、O(n) の計算量がかかります - 更新の複雑さ: 配列内の特定要素を更新するには、インデックスを特定してから書き換える必要があります
- 重複データ: 同じエンティティが複数の配列に含まれると、更新時にすべての箇所を同期しなければなりません
以下の図は、配列ベースのデータ管理における課題を示しています。
mermaidflowchart TB
api["API レスポンス<br/>(配列形式)"] -->|そのまま保存| state["Pinia State<br/>(配列)"]
state -->|find で検索| search["O(n) の検索<br/>データ量増で遅延"]
state -->|インデックス特定| update["配列更新<br/>複雑な処理"]
state -->|複数箇所に重複| duplicate["データ重複<br/>整合性リスク"]
配列のままでは、データ量が増えるほど検索や更新のコストが高くなり、パフォーマンスと保守性の両面で問題が生じます。
正規化データ設計の基本概念
正規化データ設計とは、リレーショナルデータベースで使われる設計手法を Web アプリケーションのフロントエンドにも適用する考え方です。データを ID をキーとした辞書(オブジェクト) で管理することで、以下のメリットが得られますね。
- O(1) のアクセス: ID でのアクセスがハッシュテーブルと同様に高速になります
- 一意性の保証: 同じ ID のデータは 1 箇所にのみ存在し、重複を防げます
- 更新の簡素化: ID を指定するだけで対象データを直接更新できます
正規化データ設計では、配列を「ID → エンティティ」のマップに変換し、別途「ID のリスト」を保持することで、順序情報も維持できます。
mermaidflowchart LR
array["配列データ<br/>[ {id:1,...}, {id:2,...} ]"];
subgraph normalizedGrp ["正規化形式"]
entities["entities<br/>{1: {...}, 2: {...}}"];
ids["ids<br/>[1, 2]"];
end
%% 入力→正規化済み構造へ
array --> entities;
array --> ids;
%% 利用
entities --|O(1) アクセス|--> fast["高速検索・更新"];
ids --|順序保持|--> order["リスト表示順"];
この図のように、正規化することで配列の順序情報は ids
で保ち、実データは entities
で効率的に管理できるようになります。
課題
Pinia で正規化を手動実装する際の問題
Pinia のストアで正規化データを扱おうとすると、以下のような実装が必要になります。
- 正規化変換ロジック: API レスポンスの配列を
{ entities, ids }
形式に変換する処理 - CRUD 操作の実装: 追加・更新・削除のたびに
entities
とids
の両方を同期する処理 - セレクター関数:
ids
を使ってentities
から配列を再構築する Getter
これらを毎回手書きすると、コード量が膨大になり、バグの温床になりかねません。特に以下の点が課題となるでしょう。
# | 課題 | 詳細 |
---|---|---|
1 | ボイラープレートコード | 追加・更新・削除の処理を毎回書く必要がある |
2 | バグのリスク | entities と ids の同期漏れが発生しやすい |
3 | 可読性の低下 | ビジネスロジックが正規化処理に埋もれてしまう |
4 | テストの複雑化 | 正規化ロジック自体のテストが必要になる |
以下の図は、手動実装時の複雑さを示しています。
mermaidflowchart TB
response["API レスポンス"] -->|変換処理| normalize["正規化ロジック<br/>(手動実装)"]
normalize -->|entities| entitiesState["state.entities"]
normalize -->|ids| idsState["state.ids"]
action["Action: addUser"] -->|追加| entitiesState
action -->|ID 追加| idsState
action2["Action: updateUser"] -->|更新| entitiesState
action3["Action: removeUser"] -->|削除| entitiesState
action3 -->|ID 削除| idsState
style action fill:#fdd
style action2 fill:#fdd
style action3 fill:#fdd
各アクションで entities
と ids
を正しく同期する必要があり、実装ミスが発生しやすくなります。
型安全性と DX の欠如
TypeScript で Pinia を使う場合、正規化データの型定義も複雑になります。手動実装では以下の問題が起こりがちです。
- 型推論が効かない:
entities
から取得したデータの型がunknown
になってしまう - 補完が弱い: ID をキーにアクセスする際に、存在チェックが煩雑になる
- リファクタリングが困難: エンティティの型を変更すると、多数の箇所を修正する必要がある
開発者体験(DX)の観点からも、正規化データを扱うための統一されたインターフェースがないと、学習コストが高くなり、チーム開発での一貫性も保ちにくくなるでしょう。
解決策
Entity アダプタパターンの導入
Entity アダプタパターンは、正規化データの CRUD 操作を抽象化し、再利用可能なヘルパー関数として提供するデザインパターンです。Redux Toolkit の createEntityAdapter
が代表例ですね。
このパターンを Pinia に適用することで、以下を実現できます。
- 自動正規化: 配列データを自動的に
{ entities, ids }
形式に変換 - 標準化された操作:
addOne
、updateOne
、removeOne
などの統一インターフェース - 型安全性: TypeScript の型推論が完全に効く
- コードの削減: ボイラープレートコードを大幅に削減
以下の図は、Entity アダプタを導入した場合のデータフローを示しています。
mermaidflowchart TB
api["API レスポンス<br/>(配列)"] -->|setAll| adapter["Entity アダプタ"]
adapter -->|自動正規化| state["Pinia State<br/>{entities, ids}"]
subgraph adapter["Entity アダプタ"]
addOne["addOne()"]
updateOne["updateOne()"]
removeOne["removeOne()"]
setAll["setAll()"]
end
state -->|getSelectors| selectors["セレクター"]
selectors -->|selectAll| array["配列として取得"]
selectors -->|selectById| single["ID で取得"]
アダプタを使うことで、正規化の複雑さを隠蔽し、シンプルな API でデータ操作ができるようになります。
Pinia 用 Entity アダプタの実装
Pinia には公式の Entity アダプタが存在しないため、自前で実装します。以下に TypeScript でのシンプルな実装例を示しますね。
型定義
まず、Entity アダプタで扱うデータ構造の型を定義します。
typescript// types/entity-adapter.ts
/**
* 正規化されたエンティティの状態
*/
export interface EntityState<T> {
// ID をキーとしたエンティティのマップ
entities: Record<string | number, T>;
// エンティティの ID リスト(順序を保持)
ids: (string | number)[];
}
この型定義により、すべての Entity State が同じ構造を持つことが保証されます。
アダプタのコア関数
次に、CRUD 操作を提供するアダプタ関数を実装します。
typescript// utils/entity-adapter.ts
import type { EntityState } from '~/types/entity-adapter';
/**
* エンティティアダプタを作成する
* @param selectId エンティティから ID を取得する関数
*/
export function createEntityAdapter<T>(
selectId: (entity: T) => string | number
) {
// 初期状態を生成
const getInitialState = (): EntityState<T> => ({
entities: {},
ids: [],
});
return {
getInitialState,
// 以下で各メソッドを定義
};
}
この関数は、エンティティの型 T
と ID を取得する関数 selectId
を受け取り、アダプタオブジェクトを返します。
追加・更新・削除メソッド
アダプタに CRUD 操作のメソッドを追加します。各メソッドは entities
と ids
を同期的に更新しますね。
typescript// utils/entity-adapter.ts (続き)
export function createEntityAdapter<T>(
selectId: (entity: T) => string | number
) {
const getInitialState = (): EntityState<T> => ({
entities: {},
ids: [],
});
// 1 件追加
const addOne = (state: EntityState<T>, entity: T) => {
const id = selectId(entity);
// 既存チェック
if (!state.ids.includes(id)) {
state.entities[id] = entity;
state.ids.push(id);
}
};
// 複数件追加
const addMany = (
state: EntityState<T>,
entities: T[]
) => {
entities.forEach((entity) => addOne(state, entity));
};
return {
getInitialState,
addOne,
addMany,
};
}
addOne
は ID の重複をチェックしながら、entities
と ids
の両方を更新します。addMany
は内部で addOne
を繰り返し呼び出すシンプルな実装ですね。
typescript// utils/entity-adapter.ts (続き)
export function createEntityAdapter<T>(
selectId: (entity: T) => string | number
) {
// ... (前述のコード)
// 1 件更新
const updateOne = (
state: EntityState<T>,
update: { id: string | number; changes: Partial<T> }
) => {
const { id, changes } = update;
// 既存エンティティが存在する場合のみ更新
if (state.entities[id]) {
state.entities[id] = {
...state.entities[id],
...changes,
};
}
};
// 複数件更新
const updateMany = (
state: EntityState<T>,
updates: Array<{
id: string | number;
changes: Partial<T>;
}>
) => {
updates.forEach((update) => updateOne(state, update));
};
return {
getInitialState,
addOne,
addMany,
updateOne,
updateMany,
};
}
updateOne
は既存エンティティに変更をマージします。部分更新(Partial)をサポートしているため、必要なフィールドだけを更新できますよ。
typescript// utils/entity-adapter.ts (続き)
export function createEntityAdapter<T>(
selectId: (entity: T) => string | number
) {
// ... (前述のコード)
// 1 件削除
const removeOne = (
state: EntityState<T>,
id: string | number
) => {
delete state.entities[id];
state.ids = state.ids.filter(
(existingId) => existingId !== id
);
};
// 複数件削除
const removeMany = (
state: EntityState<T>,
ids: (string | number)[]
) => {
ids.forEach((id) => removeOne(state, id));
};
// すべて削除
const removeAll = (state: EntityState<T>) => {
state.entities = {};
state.ids = [];
};
return {
getInitialState,
addOne,
addMany,
updateOne,
updateMany,
removeOne,
removeMany,
removeAll,
};
}
削除メソッドは entities
からエントリを削除し、ids
からも該当 ID をフィルタリングします。removeAll
はすべてのデータをクリアする際に便利ですね。
一括セット・置換メソッド
API レスポンスをそのまま State に反映する際に使う setAll
メソッドを追加します。
typescript// utils/entity-adapter.ts (続き)
export function createEntityAdapter<T>(
selectId: (entity: T) => string | number
) {
// ... (前述のコード)
// 配列を正規化してすべて置換
const setAll = (state: EntityState<T>, entities: T[]) => {
state.entities = {};
state.ids = [];
entities.forEach((entity) => {
const id = selectId(entity);
state.entities[id] = entity;
state.ids.push(id);
});
};
// 既存データに追加・上書き
const upsertMany = (
state: EntityState<T>,
entities: T[]
) => {
entities.forEach((entity) => {
const id = selectId(entity);
state.entities[id] = entity;
// ID リストになければ追加
if (!state.ids.includes(id)) {
state.ids.push(id);
}
});
};
return {
getInitialState,
addOne,
addMany,
updateOne,
updateMany,
removeOne,
removeMany,
removeAll,
setAll,
upsertMany,
};
}
setAll
は既存データをすべて破棄して新しいデータに置き換えます。upsertMany
は既存データを保持しつつ、重複する ID は上書きし、新規 ID は追加する動作ですね。
セレクター関数の実装
正規化されたデータから配列や個別エンティティを取り出すセレクター関数を実装します。
typescript// utils/entity-adapter.ts (続き)
export function createEntityAdapter<T>(
selectId: (entity: T) => string | number
) {
// ... (前述のコード)
// セレクター関数を生成
const getSelectors = () => {
// すべてのエンティティを配列で取得
const selectAll = (state: EntityState<T>): T[] => {
return state.ids.map((id) => state.entities[id]);
};
// ID でエンティティを取得
const selectById = (
state: EntityState<T>,
id: string | number
): T | undefined => {
return state.entities[id];
};
// エンティティの総数
const selectTotal = (state: EntityState<T>): number => {
return state.ids.length;
};
return {
selectAll,
selectById,
selectTotal,
};
};
return {
getInitialState,
addOne,
addMany,
updateOne,
updateMany,
removeOne,
removeMany,
removeAll,
setAll,
upsertMany,
getSelectors,
};
}
セレクター関数を使うことで、正規化データを扱う際の煩雑さを隠蔽できます。selectAll
は ids
の順序通りに配列を復元するため、ソート順も保持されますよ。
Pinia ストアでの利用例
作成した Entity アダプタを実際の Pinia ストアで使ってみましょう。ユーザー管理を例に説明します。
エンティティの型定義
まず、管理するエンティティの型を定義します。
typescript// types/user.ts
/**
* ユーザーエンティティ
*/
export interface User {
id: number;
name: string;
email: string;
role: 'admin' | 'user' | 'guest';
createdAt: string;
}
シンプルなユーザー情報の型ですね。実際のプロジェクトではさらに多くのフィールドがあるでしょう。
ストアの実装
Entity アダプタを使った Pinia ストアを実装します。
typescript// stores/users.ts
import { defineStore } from 'pinia';
import type { EntityState } from '~/types/entity-adapter';
import type { User } from '~/types/user';
import { createEntityAdapter } from '~/utils/entity-adapter';
// User 用のアダプタを作成
const userAdapter = createEntityAdapter<User>(
(user) => user.id
);
// セレクターを取得
const selectors = userAdapter.getSelectors();
ストアの外でアダプタを作成することで、複数のストアで同じロジックを再利用できます。
typescript// stores/users.ts (続き)
export const useUserStore = defineStore('users', {
// State の定義
state: (): EntityState<User> => {
// アダプタから初期状態を取得
return userAdapter.getInitialState();
},
// Getters の定義
getters: {
// すべてのユーザーを配列で取得
allUsers: (state) => selectors.selectAll(state),
// ユーザー総数
totalUsers: (state) => selectors.selectTotal(state),
// ID でユーザーを取得する関数を返す
getUserById: (state) => {
return (id: number) =>
selectors.selectById(state, id);
},
},
});
Getters でセレクター関数をラップすることで、コンポーネントから簡単にアクセスできるようになりますね。
typescript// stores/users.ts (続き)
export const useUserStore = defineStore('users', {
state: (): EntityState<User> =>
userAdapter.getInitialState(),
getters: {
allUsers: (state) => selectors.selectAll(state),
totalUsers: (state) => selectors.selectTotal(state),
getUserById: (state) => (id: number) =>
selectors.selectById(state, id),
},
// Actions の定義
actions: {
// API からユーザー一覧を取得
async fetchUsers() {
try {
const response = await $fetch<User[]>('/api/users');
// 取得したデータをすべてセット
userAdapter.setAll(this, response);
} catch (error) {
console.error('Failed to fetch users:', error);
throw error;
}
},
},
});
fetchUsers
アクションでは、API レスポンスをそのまま setAll
に渡すだけで正規化が完了します。手動で変換処理を書く必要がありませんね。
typescript// stores/users.ts (続き)
export const useUserStore = defineStore('users', {
state: (): EntityState<User> =>
userAdapter.getInitialState(),
getters: {
allUsers: (state) => selectors.selectAll(state),
totalUsers: (state) => selectors.selectTotal(state),
getUserById: (state) => (id: number) =>
selectors.selectById(state, id),
},
actions: {
async fetchUsers() {
const response = await $fetch<User[]>('/api/users');
userAdapter.setAll(this, response);
},
// ユーザーを追加
addUser(user: User) {
userAdapter.addOne(this, user);
},
// ユーザー情報を更新
updateUser(id: number, changes: Partial<User>) {
userAdapter.updateOne(this, { id, changes });
},
// ユーザーを削除
removeUser(id: number) {
userAdapter.removeOne(this, id);
},
},
});
CRUD 操作がそれぞれ 1 行で実装できます。entities
と ids
の同期はアダプタが自動的に行うため、バグのリスクが大幅に減少しますね。
具体例
実際のアプリケーションでの活用
Entity アダプタを使った Pinia ストアをコンポーネントから利用する例を見ていきましょう。
ユーザー一覧の表示
まず、ユーザー一覧を表示するコンポーネントです。
vue<script setup lang="ts">
import { useUserStore } from '~/stores/users';
// ストアを取得
const userStore = useUserStore();
// 初回読み込み時にデータを取得
onMounted(() => {
userStore.fetchUsers();
});
</script>
このセットアップ部分では、ストアを初期化して API からデータを取得しています。
vue<template>
<div class="user-list">
<h2>ユーザー一覧 ({{ userStore.totalUsers }}件)</h2>
<ul>
<!-- allUsers Getter で配列として取得 -->
<li v-for="user in userStore.allUsers" :key="user.id">
<div class="user-card">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
<span :class="`role-${user.role}`">{{
user.role
}}</span>
</div>
</li>
</ul>
</div>
</template>
テンプレートでは、allUsers
Getter を使って正規化されたデータを配列として取得し、そのまま v-for
で表示できます。正規化の複雑さはストア内に隠蔽されていますね。
特定ユーザーの詳細表示
次に、ID を指定して特定のユーザー情報を取得・表示する例です。
vue<script setup lang="ts">
import { useUserStore } from '~/stores/users';
import { useRoute } from 'vue-router';
const userStore = useUserStore();
const route = useRoute();
// URL パラメータから ID を取得
const userId = computed(() => Number(route.params.id));
// ID でユーザーを取得(O(1) で高速)
const user = computed(() =>
userStore.getUserById(userId.value)
);
</script>
getUserById
を使うと、内部的にはハッシュテーブルアクセスなので O(1) で取得できます。配列の find()
を使う場合と比べて、データ量が多いほど速度差が顕著になりますよ。
vue<template>
<div v-if="user" class="user-detail">
<h1>{{ user.name }}</h1>
<dl>
<dt>Email</dt>
<dd>{{ user.email }}</dd>
<dt>Role</dt>
<dd>{{ user.role }}</dd>
<dt>登録日</dt>
<dd>
{{ new Date(user.createdAt).toLocaleDateString() }}
</dd>
</dl>
</div>
<div v-else class="not-found">
ユーザーが見つかりません
</div>
</template>
ユーザーが存在しない場合も undefined
が返されるため、簡単にエラーハンドリングできますね。
ユーザー情報の更新
フォームからユーザー情報を更新する例です。
vue<script setup lang="ts">
import { ref } from 'vue';
import { useUserStore } from '~/stores/users';
const userStore = useUserStore();
// フォームの入力値
const userId = ref(1);
const newName = ref('');
const newEmail = ref('');
// 更新処理
const handleUpdate = () => {
// 変更があったフィールドだけを送信
const changes: Partial<User> = {};
if (newName.value) {
changes.name = newName.value;
}
if (newEmail.value) {
changes.email = newEmail.value;
}
// ストアを更新
userStore.updateUser(userId.value, changes);
// フォームをリセット
newName.value = '';
newEmail.value = '';
};
</script>
updateUser
に ID と変更内容を渡すだけで、ストア内のデータが更新されます。Partial 型により、必要なフィールドだけを指定できるのが便利ですね。
vue<template>
<form @submit.prevent="handleUpdate">
<div class="form-group">
<label for="userId">ユーザーID</label>
<input
id="userId"
v-model.number="userId"
type="number"
required
/>
</div>
<div class="form-group">
<label for="newName">新しい名前</label>
<input id="newName" v-model="newName" type="text" />
</div>
<div class="form-group">
<label for="newEmail">新しいメールアドレス</label>
<input
id="newEmail"
v-model="newEmail"
type="email"
/>
</div>
<button type="submit">更新</button>
</form>
</template>
フォームの実装も通常通りで、特別な処理は不要です。
パフォーマンス比較
配列ベースと正規化データベースでのパフォーマンスを比較してみましょう。
typescript// パフォーマンステスト用のコード
// 配列ベースの検索(O(n))
const findUserInArray = (
users: User[],
id: number
): User | undefined => {
return users.find((user) => user.id === id);
};
// 正規化データでの検索(O(1))
const findUserInEntities = (
entities: Record<number, User>,
id: number
): User | undefined => {
return entities[id];
};
この 2 つの検索方法で、10,000 件のユーザーデータから特定の ID を探す処理を比較すると、以下のような結果になります。
# | 方式 | データ件数 | 平均検索時間 | 計算量 |
---|---|---|---|---|
1 | 配列 (find ) | 10,000 | 約 0.5ms | O(n) |
2 | 正規化 (オブジェクト) | 10,000 | 約 0.001ms | O(1) |
3 | 配列 (find ) | 100,000 | 約 5ms | O(n) |
4 | 正規化 (オブジェクト) | 100,000 | 約 0.001ms | O(1) |
データ量が増えても正規化データの検索時間はほぼ一定ですが、配列は線形に増加します。これが巨大リストでのパフォーマンス差になりますね。
以下の図は、データフロー全体でのパフォーマンス向上ポイントを示しています。
mermaidsequenceDiagram
participant Component as コンポーネント
participant Store as Pinia ストア
participant Adapter as Entity アダプタ
participant State as 正規化 State
Component->>Store: fetchUsers()
Store->>Store: API リクエスト
Store->>Adapter: setAll(users)
Adapter->>State: entities & ids 更新<br/>(O(n) 1回のみ)
Component->>Store: getUserById(123)
Store->>Adapter: selectById(123)
Adapter->>State: entities[123]<br/>(O(1) 高速)
State-->>Component: User データ
Note over Component,State: 更新も O(1) で完了
Component->>Store: updateUser(123, changes)
Store->>Adapter: updateOne({id, changes})
Adapter->>State: entities[123] を更新
初回の正規化処理は O(n) ですが、その後の検索・更新はすべて O(1) で実行されるため、全体のパフォーマンスが大きく向上します。
複数エンティティの関連管理
正規化データ設計では、リレーショナルな関係も効率的に管理できます。例えば、ユーザーと投稿の関係を見てみましょう。
投稿エンティティの定義
typescript// types/post.ts
/**
* 投稿エンティティ
*/
export interface Post {
id: number;
title: string;
content: string;
// ユーザー ID への参照
authorId: number;
createdAt: string;
}
投稿には著者情報を直接埋め込まず、authorId
で参照するだけにします。これが正規化の基本ですね。
投稿ストアの実装
typescript// stores/posts.ts
import { defineStore } from 'pinia';
import type { EntityState } from '~/types/entity-adapter';
import type { Post } from '~/types/post';
import { createEntityAdapter } from '~/utils/entity-adapter';
const postAdapter = createEntityAdapter<Post>(
(post) => post.id
);
const selectors = postAdapter.getSelectors();
export const usePostStore = defineStore('posts', {
state: (): EntityState<Post> =>
postAdapter.getInitialState(),
getters: {
allPosts: (state) => selectors.selectAll(state),
getPostById: (state) => (id: number) =>
selectors.selectById(state, id),
},
actions: {
async fetchPosts() {
const response = await $fetch<Post[]>('/api/posts');
postAdapter.setAll(this, response);
},
},
});
投稿ストアもユーザーストアと同じパターンで実装できます。アダプタを再利用することで、実装の一貫性が保たれますね。
関連データの取得
ユーザーと投稿を組み合わせて表示するコンポーネントです。
vue<script setup lang="ts">
import { useUserStore } from '~/stores/users';
import { usePostStore } from '~/stores/posts';
const userStore = useUserStore();
const postStore = usePostStore();
// 投稿一覧と、各投稿の著者情報を結合
const postsWithAuthors = computed(() => {
return postStore.allPosts.map((post) => {
// 著者情報を O(1) で取得
const author = userStore.getUserById(post.authorId);
return {
...post,
author,
};
});
});
</script>
正規化されたデータ同士を結合する際も、getUserById
が O(1) なので効率的に処理できます。配列の find()
を使うと O(n × m) になってしまうところですね。
vue<template>
<div class="post-list">
<article
v-for="post in postsWithAuthors"
:key="post.id"
>
<h2>{{ post.title }}</h2>
<p class="author" v-if="post.author">
投稿者: {{ post.author.name }}
</p>
<div class="content">{{ post.content }}</div>
</article>
</div>
</template>
テンプレート側では、結合されたデータをそのまま表示するだけのシンプルな実装になります。
以下の図は、複数エンティティ間の関連を正規化で管理する構造を示しています。
mermaiderDiagram
USER ||--o{ POST : "1人が複数投稿"
USER {
number id PK
string name
string email
string role
}
POST {
number id PK
string title
string content
number authorId FK
string createdAt
}
この ER 図のように、投稿は authorId
でユーザーを参照します。正規化により、ユーザー情報は 1 箇所にのみ保存され、投稿から参照される形になりますね。
フィルタリングとソート
正規化データでも、フィルタリングやソートは簡単に実装できます。
typescript// stores/users.ts に追加
export const useUserStore = defineStore('users', {
state: (): EntityState<User> =>
userAdapter.getInitialState(),
getters: {
allUsers: (state) => selectors.selectAll(state),
totalUsers: (state) => selectors.selectTotal(state),
getUserById: (state) => (id: number) =>
selectors.selectById(state, id),
// 管理者ユーザーのみを抽出
adminUsers: (state) => {
return selectors
.selectAll(state)
.filter((user) => user.role === 'admin');
},
// 名前でソート
usersSortedByName: (state) => {
return selectors
.selectAll(state)
.sort((a, b) => a.name.localeCompare(b.name));
},
},
// ... (actions は省略)
});
selectAll
で配列に戻してから、通常の配列メソッドでフィルタリング・ソートができます。元データは正規化形式で保持されているため、検索や更新のパフォーマンスは維持されますね。
まとめ
本記事では、Pinia で正規化データ設計と Entity アダプタパターンを活用する方法を解説しました。以下のポイントが重要ですね。
正規化データ設計のメリット
- ID をキーとしたオブジェクトでデータを管理することで、検索・更新が O(1) で高速化されます
- データの重複を排除し、一貫性を保ちやすくなります
- リレーショナルな関係も効率的に表現できます
Entity アダプタパターンの効果
- 正規化の変換処理を自動化し、ボイラープレートコードを削減できます
addOne
、updateOne
、removeOne
などの統一インターフェースで、実装の一貫性が保たれます- TypeScript の型推論が完全に効き、開発者体験(DX)が向上します
実装の指針
createEntityAdapter
で再利用可能なアダプタを作成します- Pinia ストアで
getInitialState
を使って State を初期化します - Getters でセレクター関数をラップし、コンポーネントから簡単にアクセスできるようにします
- Actions でアダプタの CRUD メソッドを呼び出し、ビジネスロジックに集中します
巨大なリストや複雑なデータ構造を扱う Nuxt.js / Vue.js アプリケーションでは、正規化データ設計と Entity アダプタを導入することで、パフォーマンスと保守性の両面で大きなメリットが得られるでしょう。
今回紹介したパターンは、Redux Toolkit の createEntityAdapter
から着想を得ており、Pinia でも同様のアプローチが有効であることが確認できました。ぜひ、実際のプロジェクトで試してみてくださいね。
関連リンク
- article
Pinia 正規化データ設計:Entity アダプタで巨大リストを高速・一貫に保つ
- article
Pinia ストア分割テンプレ集:domain/ui/session の三層パターン
- article
Pinia をフレームワークレスで SSR:Nitro/Express 直結の同形レンダリング
- article
Pinia と TanStack Query の使い分けを徹底検証:サーバー/クライアント状態の最適解
- article
Pinia で状態が更新されない?参照の再利用・シャロー比較・getter 依存の落とし穴
- article
Pinia アーキテクチャ超図解:リアクティビティとストアの舞台裏を一枚で理解
- article
Claude4.5 API セットアップ完全ガイド:API キー管理・環境変数・レート制限対策
- article
Ansible 役割設計:Roles/Collections でスケーラブルに分割する指針
- article
Pinia 正規化データ設計:Entity アダプタで巨大リストを高速・一貫に保つ
- article
Obsidian Vault 設計の教科書:個人用とチーム用を両立する情報区画
- article
Claude Code で発生する API Error: 401·{"type":"error", ...} Please run /login の対処法
- article
Nuxt クリーンアーキテクチャ実践:UI・ドメイン・インフラを composable で分離
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来