【Redux】JSONオブジェクトを正規化(byId, allIds)する実装例

JavaScript

Redux『Redux Style Guide#Normalize Complex Nested/Relational State』にもあるように、『Redux Store内のState(以下State)』のデータは正規化して管理することが推奨されています。

Stateで管理するデータをWeb API経由で取得する場合、取得元のデータは非正規形であるケースが多いです。
そこで今回は、非正規形のデータを正規化してStateに保存する実装例について紹介します。

前提知識: Reduxにおける正規化について

データを正規化する方法を説明する前に、Reduxにおける正規化の前提知識について説明します。

本項目で紹介する正規化の前提知識はRedux『Normalizing State Shape』を意訳したものですので、正規化の詳細については当該記事をご参照ください。

一般的なオブジェクト(非正規形)のデータ構造について

Web API経由で取得するJSONを始めとした一般的なオブジェクトは入れ子(ネスト)構造をしています。具体例は以下の通りです。

const blogPosts = [
  {
    id: 'post1',
    author: { username: 'user1', name: 'User 1' },
    body: '......',
    comments: [
      {
        id: 'comment1',
        author: { username: 'user2', name: 'User 2' },
        comment: '.....'
      },
      {
        id: 'comment2',
        author: { username: 'user3', name: 'User 3' },
        comment: '.....'
      }
    ]
  },
  {
    id: 'post2',
    author: { username: 'user2', name: 'User 2' },
    body: '......',
    comments: [
      {
        id: 'comment3',
        author: { username: 'user3', name: 'User 3' },
        comment: '.....'
      },
      {
        id: 'comment4',
        author: { username: 'user1', name: 'User 1' },
        comment: '.....'
      },
      {
        id: 'comment5',
        author: { username: 'user3', name: 'User 3' },
        comment: '.....'
      }
    ]
  }
  ...
  ...
]

上記のように入れ子構造をしたオブジェクトは正規化されていないデータ(非正規形のデータ)に分類されます。

正規化されていないデータをStateで管理するデメリット

非正規形のデータ、つまり正規化されていないデータをStateで管理するデメリットは以下の通りです。

  • 同じデータが複数の箇所で利用されているためデータ更新の実装が複雑化する
  • ネストの深さに応じてデータ更新の実装が複雑化する
  • イミュータブルなデータ更新をするにあたり、不要なレンダリングが発生する

正規化とは

Stateにおける正規化されたデータの定義は以下の通りです。

  • DBでいうテーブルのような状態で各データが保存されている
  • データのIDをkey、データの中身をvalueとしたオブジェクトが保存されている
  • データを参照するためのID一覧の配列が保存されている
  • IDの配列がデータの順序を表現している

正規化されたデータの具体例は以下の通りです。

{
  posts : {
    byId : {
      "post1" : {
        id : "post1",
        author : "user1",
        body : "......",
        comments : ["comment1", "comment2"]
      },
      "post2" : {
        id : "post2",
        author : "user2",
        body : "......",
        comments : ["comment3", "comment4", "comment5"]
      }
    },
    allIds : ["post1", "post2"]
  },
  comments : {
    byId : {
      "comment1" : {
        id : "comment1",
        author : "user2",
        comment : ".....",
      },
      "comment2" : {
        id : "comment2",
        author : "user3",
        comment : ".....",
      },
      "comment3" : {
        id : "comment3",
        author : "user3",
        comment : ".....",
      },
      "comment4" : {
        id : "comment4",
        author : "user1",
        comment : ".....",
      },
      "comment5" : {
        id : "comment5",
        author : "user3",
        comment : ".....",
      },
    },
    allIds : ["comment1", "comment2", "comment3", "comment4", "comment5"]
  },
  users : {
    byId : {
      "user1" : {
        username : "user1",
        name : "User 1",
      },
      "user2" : {
        username : "user2",
        name : "User 2",
      },
      "user3" : {
        username : "user3",
        name : "User 3",
      }
    },
    allIds : ["user1", "user2", "user3"]
  }
}

上記の正規化されたデータは、key-valueのオブジェクトを格納するbyIdと、データのID一覧を格納するallIdsで構成されています。

正規化のメリット

Stateで管理するデータを正規化するメリットは以下の通りです。

  • 同じデータが複数の箇所で利用されていないため、データ更新がシンプルになる
  • 深いネストを扱わないためデータ更新の実装が複雑化しない
  • データの取得・更新の実装がシンプルになる
  • 各種データが分離されているため不要なレンダリング発生を防げる

正規化の方法について

データを正規化する主な方法は以下の3つです。

  • normalizrを利用する
  • Redux ToolkitのcreateEntityAdapterを利用する
  • 独自実装する

以下では独自実装による正規化の具体例について紹介します。

オブジェクトを正規化する実装例

以下のような非正規形のオブジェクトあったとします。

// 型定義
type TodoEntity = {
  id: number;
  content: string;
  completed: boolean;
}

// 正規化前のデータ
const todos: TodoEntity[] = [
  {
    "id": 1,
    "content": "go somewhere",
    "completed": true
  },
  {
    "id": 2,
    "content": "eat something",
    "completed": false
  }
];

この時、以下のような正規化されたデータに変換したいとします。

// 型定義
type TodoEntity = {
  id: number;
  content: string;
  completed: boolean;
}

type Entities<Entity> = {
  byId: {
    [entityId: number]: Entity;
  };
  allIds: number[];
};

// 以下のような形に正規化したい
const todoEntities: Entities<TodoEntity> = {
  "byId": {
    "1": {
      "id": 1,
      "content": "go somewhere",
      "completed": true
    },
    "2": {
      "id": 2,
      "content": "eat something",
      "completed": false
    }
  },
  "allIds": [
    1,
    2
  ]
}

上記の変換をする実装例は以下の通りです。

// 型定義
type TodoEntity = {
  id: number;
  content: string;
  completed: boolean;
}

type BaseEntity = {
  id: number;
};

type ById<T> = {
  [entityId: number]: T;
};

// メソッド定義
const buildById = <T extends BaseEntity>(entities: T[]) => {
  const byId: ById<T> = {};
  entities.forEach((entity) => {
    byId[entity.id] = entity;
  });
  return byId;
}

const buildAllIds = <T extends BaseEntity>(byId: ById<T>) => {
  return Object.keys(byId).map((id) => Number(id));
}

const buildEntities = <T extends BaseEntity>(entities: T[]) => {
  const byId = buildById<T>(entities);
  const allIds = buildAllIds<T>(byId);
  return { byId, allIds };
}

// 正規化の実行
const todos: TodoEntity[] = [
  {
    "id": 1,
    "content": "go somewhere",
    "completed": true
  },
  {
    "id": 2,
    "content": "eat something",
    "completed": false
  }
];

const todoEntities = buildEntities<TodoEntity>(todos);

// 実行結果
console.log(todoEntities);
// {
//   "byId": {
//     "1": {
//       "id": 1,
//       "content": "go somewhere",
//       "completed": true
//     },
//     "2": {
//       "id": 2,
//       "content": "eat something",
//       "completed": false
//     }
//   },
//   "allIds": [
//     1,
//     2
//   ]
// }

console.log(todoEntities.byId);
// {
//   "1": {
//     "id": 1,
//     "content": "go somewhere",
//     "completed": true
//   },
//   "2": {
//     "id": 2,
//     "content": "eat something",
//     "completed": false
//   }
// }

console.log(todoEntities.allIds);
// [1, 2]

なお、type ById<T>は汎用的な型を表現するジェネリックタイプを利用した型です。ジェネリックタイプの詳細解説は【TypeScript】ジェネリクス(Generics)入門。ジェネリクスの種類と利用例で紹介しています。

また、<T extends BaseEntity>extendsはジェネリック関数の引数に制約をつけるためのものです。
ジェネリック関数におけるextendsの詳細解説は【TypeScript】 Genericsのextendsでジェネリック関数の引数に制約をつけるで紹介しています。

正規化メソッドを組み込んだReactアプリケーションの例

さきほど紹介した正規化メソッドをReactアプリケーションに組み込む方法について紹介します。

正規化はアクションで取得したデータをStateへ保存する際に行いますので、Reducerで正規化メソッドを呼びます。

import { createSlice } from "@reduxjs/toolkit";
import { buildEntities } from "../lib/entitiesBuilder";
import { Entities } from "../types/state/base";
import { TodoEntity } from "../types/state/todos";

const initialState: Entities<TodoEntity> = {
  allIds: [],
  byId: {},
};

const todosSlice = createSlice({
  name: "todos",
  initialState,
  reducers: {
    // 非正規形のデータを受けとり、正規化してStateにセットするアクション
    setInitialTodos(state, action) {
      // action.payload格納されている非正規形のデータを buildEntities で正規化する
      const { allIds, byId } = buildEntities<TodoEntity>(action.payload);

      // allIdsプロパティとbyIdプロパティに正規形に変換されたデータをセット
      state.allIds = allIds;
      state.byId = byId;
    },
    ...
    ...
  },
});

export const { setInitialTodos, ... } = todosSlice.actions;
export default todosSlice.reducer;

正規化されたデータをSelector経由で呼び出す場合は以下のようになります。

import { AppState } from "../store/index";
import { VISIBILITY_FILTERS } from "../types/constants/visibilityFilterType";
import { TodoEntity } from "../types/state/todos";
import { createSelector } from "@reduxjs/toolkit";
import { selectVisibilityFilter } from "./visibilityFilter";
import { Entities } from "../types/state/base";

const selectTodoEntities = (state: AppState): Entities<TodoEntity> =>
  state.entities.todos;

const selectTodoIds = createSelector(
  [selectTodoEntities],
  (todos) => todos.allIds
);

const selectTodosById = createSelector(
  [selectTodoEntities],
  (todos) => todos.byId
);

// allIdsに格納されているIDをキーに、byIdに保存されているデータの中身を抽出。
const selectTodos = createSelector(
  [selectTodoIds, selectTodosById],
  (todoIds, todos) => todoIds.map((id) => todos[id])
);

// visibilityFilterという変数の値を利用してtodosをフィルタリングするSelector
export const selectTodosByVisibilityFilter = createSelector(
  [selectTodos, selectVisibilityFilter],
  (todos, visibilityFilter) => {
    switch (visibilityFilter) {
      case VISIBILITY_FILTERS.COMPLETED:
        return todos.filter((todo) => todo.completed);
      case VISIBILITY_FILTERS.INCOMPLETE:
        return todos.filter((todo) => !todo.completed);
      case VISIBILITY_FILTERS.ALL:
      default:
        return todos;
    }
  }
);

データの正規化アクションの発行や、正規化データの取得は以下のように行います。

import { FC, useEffect } from "react";
import Todo from "./Todo";
import { selectTodosByVisibilityFilter } from "../selectors/todo";
import { TodoEntity } from "../types/state/todos";
import { useDispatch, useSelector } from "react-redux";
import styles from "./TodoList.module.css";
import { AppDispatch } from "../store";
import { setInitialTodos } from "../reducers/todosSlice";

const TodoList: FC = () => {
  const dispatch: AppDispatch = useDispatch();

  // Selector経由でToDoオブジェクトを取得する
  const todos: TodoEntity[] = useSelector(selectTodosByVisibilityFilter);

  useEffect(() => {
    const todos = [
      {
        id: 1,
        content: "go somewhere",
        completed: true,
      },
      {
        id: 2,
        content: "eat something",
        completed: false,
      },
    ];
    // 非正規データを渡し、正規化アクションを発行する
    dispatch(setInitialTodos(todos));
  }, [dispatch]);

  return (
    <ul className={styles.todoList}>
      {todos && todos.length
        ? todos.map((todo: TodoEntity, index: number) => {
            return <Todo key={`todo-${todo.id}`} todo={todo} />;
          })
        : "No todos, yay!"}
    </ul>
  );
};

export default TodoList;

以下は、上記の実装が組み込まれたToDoアプリケーションです。コードはnishina555/normalization-converter-reduxappで公開しています。

さいごに

Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!

参考資料