🥋

データ整形の達人になりたい

2023/11/24に公開
2

JavaScript/Typescriptを使用していると頻繁に必要になるデータの整形作業。
配列やObjectに対するソートや抽出が代表的なものとして挙げられると思います。

背景

筆者はTypescript環境でReactを用いてフロントエンドを構築する作業を主な生業としていますが、おおよそ下記のようなアプローチをしています。

  1. Routerによってページコンポーネントを実行
  2. ページコンポーネントでデータをフェッチ
  3. フェッチしたデータをロジックをまとめたカスタムフックに投入
  4. カスタムフックは表示用コンポーネントが自然に扱えるように整理・整形された値を返す
  5. 無理のないシンプルなpropsでUIを構築
type Props = {
    pageId: number;
};

const Page = ({ pageId }: Props) => {
    const { data: queryResult } = useQuery({ pageId }); // データフェッチ
    const data = usePage({ queryResult }); // カスタムフック

    // ロード中の代替表示やエラーハンドリングなど...

    return <UserInterface {...data} />;
};

データ(情報)とビュー(画面表示)が分離されることはReactに限らず多くの技術体系で推奨される設計であると思いますが、私も似たような考え方をしています。Reactの単方向データフローの原則に則った、比較的よく見かけるシンプルなアプローチではないでしょうか。

この場合、3番のカスタムフックの内部にビジネスロジックが凝集されることになりますから、データフェッチやUIコンポーネントはその純粋性を保つことができ、良い意味でビジネスロジックに関心を寄せなくて済むようになります。テストも簡潔に書けますし、流用性も高いでしょう。

ロジックをカスタムフックにまとめる意義についてはこちらで触れています。
https://zenn.dev/steelyard/articles/14a521cb0c46c0

では、肝心のビジネスロジックが凝集されたカスタムフックの中では何が行われるのでしょうか...? それが今回の主な話題です。

複雑なビジネスロジックを簡潔に書きたい

凝集されたビジネスロジックなどと格好をつけましたが、つまるところ 「Objectを食わせて別のObjectを吐かせる」 だけです。

多くの場合、データフェッチはシステムのバックエンドに対するAPIリクエストで賄われると思います。APIの設計にも依りますが、取得されるデータはDBのモデルを素直に表していることが多いのではないでしょうか。そして、 それはしばしばUIが期待するデータの構造をしていません。

であるならば、 APIから取得したデータをUIが望む構造に変換する関数を書けば良さそうです。

変換の過程では様々な条件判定を行う処理や、UIの描画を制御するフラグなどが発生するでしょう。アプリケーションとしてレンダリングするために必要なデータ構造への変換や、状態を制御するためのフラグを生成・管理する存在として 「その機能(ページ)がどのように振る舞うのか」という情報が定義された(=ビジネスロジックが凝集された)関数 と言えるのではないでしょうか。

type Props = {
    queryResult: QueryResultType;
};

type QueryResultType = {
    id: number;
    userName: string;
    userDescription: string;
    visitedAt?: string;
};

const usePage = ({ queryResult }: Props) => {
    const user = useMemo(() => {
        return {
            name: queryResult.userName,
            description: queryResult.userDescription,
        };
    }, [queryResult.userName, queryResult.userDescription]);

    const isVisited = useMemo(() => {
        return !!queryResult?.visitedAt;
    }, [queryResult?.visitedAt]);

    return { user, isVisited };
};

上記の例は単純すぎますが、実際には複雑な配列操作や配列・Objectの相互変換、複数の異なるフォーマットのデータのマージ等の処理が頻発します。

私はコレの達人になりたいのです...!

代表的なものを挙げてみよう

基本的なメソッドについては既に優れた記事がたくさんありますので、改めて紹介はしません。
もう少し具体的であるあるなケースについて触れていきたいと思います。

https://ics.media/entry/200825/

配列からObjectにしたい

はい、よくあります。めっちゃあります。
下記のケースを例に考えてみましょう。

type User = {
    id: number;
    name: string;
    description?: string;
};

const users: User[] = [
    { id: 1, name: "user_1" },
    { id: 2, name: "user_2", description: "love cat." },
];

このとき、

  • idが2ということはわかってるので、idが2の人を参照したい
  • nameがuser_2ということはわかってるので、nameが"user_2"の人を参照したい

というケースでは、fiterfind等で抽出を行うことになるでしょう。
もちろん問題はないのですが、条件が変化する度に毎回抽出を行うのも少々面倒かもしれません。

では、例えばこのように変形させてみるのはどうでしょうか?

type User = {
    id: number;
    name: string;
    description?: string;
};

const users: { [key: string]: Omit<User, "name"> } = {
    user_1: { id: 1 },
    user_2: { id: 2, description: "love cat." },
};

const userName = "user_1";

console.log(users[userName]); // { id: 1 }!

Objectの良いところは目的のデータをピンポイントで参照できることです。
一度生成したusersを繰り返し使用することができますし、エコなのではないでしょうか。

変換してみる

では、この変換処理について考えてみましょう。
アプローチは無数にあると思いますが、筆者はreduceでマッピングすることが多いです。

type User = {
    id: number;
    name: string;
    description?: string;
};

const users: User[] = [
    { id: 1, name: "user_1" },
    { id: 2, name: "user_2", description: "love cat." },
];

const usersObject = users.reduce<{ [key: User["name"]]: Omit<User, "name"> }>(
    (accumulator, { name, ...current }) => {
        const user = { [name]: current };
        return { ...accumulator, ...user };
    },
    {}
);

console.log(usersObject["user_2"]); // { id: 2, description: 'love cat.'}

はい、単純ですね。

Objectのkeyを用いる際の注意

注意点として、Objectのkeyを指定する方法の場合は常にそのkeyが存在しない可能性を考慮しなくてはなりません。可能であれば型で縛るべきでしょうし、最低でもundefinedであった場合のフォールバック値を用意しておいたほうが良いでしょう。NULL合体演算子やオプショナルチェーンの併用を検討してください。

また、Objectのkeyは同じ値を複数持つことができません。よって、もしkeyの重複があった場合には上書きされてしまい、データを失う可能性があることになりますから、基本的に重複の可能性が低いIDに類するものを指定するべきでしょう。もちろん処理の過程でerrorthrowしてハンドリングすることもできます。

Objectから配列にしたい

では逆の場合も考えてみましょう。

type User = {
    id: number;
    description?: string;
};

const users: { [key: string]: User } = {
    user_1: { id: 1 },
    user_2: { id: 2, description: "love cat." },
};

const usersArray = Object.entries(users).map<User & { name: string }>(
    ([name, user]) => ({ name, ...user })
);

console.log(usersArray[1]); // { id: 2, description: 'love cat.', name: "user_2"}

配列の良いところは並び順が担保され、一覧性・規則性に優れるところでしょうか。
「順番に1つずつ」「並び順のn番目」といった用途に適していますね。

データソースがObjectの場合、Objectはiteratableではないのでそのままではイテレーターメソッドを用いることができません。よって、まずObject.entriesで列挙可能な配列を取得し、それをメソッドチェーンでArray.mapとして実行します。

ほとんどのイテレーターメソッドは配列を返しますから、内部では望む形にデータを整形するだけで済みます。もし空値を返す必要がある(=欠番にしたい)場合には、続けてfilterするか、初めからreduceで整形するといいでしょう。

// 特定の条件でデータ自体を破棄したい
const usersArray = Object.entries(users).map<
    (User & { name: string }) | undefined
>(([name, user]) => {
    if (name == null) return; // 一旦undefinedを返す
    return { name, ...user };
}).filter((value) => value); // 値がnullishならば含まない

// 特定の条件でデータ自体を破棄したい
const usersArray = Object.entries(users).reduce<(User & { name: string })[]>(
    (accumulator, [name, user]) => {
        if (name == null) return accumulator; // 特定の条件ではデータを足さない
        return [...accumulator, { name, ...user }];
    },
    []
);

データソースを統合して扱いやすくしたい

応用編。皮肉なことにDBの設計がきちんと機能している環境ほど良く遭遇します(褒めてます)。

ちょっとこっちの都合のいいようにAPI生やしてくれない?と言いたくなる気持ちも芽生えるでしょうが...ケースバイケースですよね。ただ、 仮に難色が濃くても別にコッチでもやれるからいいのよ〜 という余裕を持てるかどうかで、気持ちの良いお願いの仕方ができるかどうかも変わってくるのかなと思います。余裕大事。

type User = {
    id: number;
    name: string;
    description?: string;
};

type Ranking = {
    rank: number;
    userId: number;
};

const users: User[] = [
    { id: 1, name: "user_1" },
    { id: 2, name: "user_2", description: "love cat." },
    { id: 3, name: "user_3" },
];

const ranking: Ranking[] = [
    { rank: 1, userId: 3 },
    { rank: 2, userId: 1 },
    { rank: 3, userId: 2 },
];

const usersObject = users.reduce<{ [key: User["id"]]: User }>(
    (accumulator, current) => {
        const user = { [current.id]: current };
        return { ...accumulator, ...user };
    },
    {}
);

const rankingWithUser = ranking.reduce<
    (Omit<Ranking, "userId"> & { user: User })[]
>((accumulator, { userId, ...rank }) => {
    const current = { ...rank, user: usersObject[userId] };
    return [...accumulator, current];
}, []);

console.log(rankingWithUser);

/*

[
    {
        rank: 1,
        user: { id: 3, name: "user_3" },
    },
    {
        rank: 2,
        user: { id: 1, name: "user_1" },
    },
    {
        rank: 3,
        user: { id: 2, name: "user_2", description: "love cat." },
    },
];

*/

はい。これまでの応用で新しい情報は何もありません。

ごく単純なことしかやっていませんが、Reactコンポーネントのレンダリングの前準備としてこのような工夫をしておけば、rankingmapしてRankコンポーネントをばら撒き、各RankコンポーネントがuserIdをキーにして1人ずつユーザーをフェッチして...のような富豪通信パターンを綺麗に避けられそうですよね。propsで複雑な計算をして可読性を損なう危険もなさそうです。

不都合なデータのまま、UIで無茶をする必要はない

複雑なものを複雑なまま扱い、摩訶不思議な実装になってしまった経験は、フロントエンドに携わる人間ならば誰しも一度はあるのではないでしょうか。もっと扱いやすい粒度でAPIを作ってくれよ!なんて思ったかもしれませんね。

DBやAPIといった情報源や、画面を構築するUIコンポーネントは、過剰にビジネスロジックについて知るべきではありません。できるかぎり純粋であるべきです。

もしそのためにUIが構築しにくいのならば、都合の良いように変形させることも検討してみてください。入力と出力で冪等性が担保されていれば何の問題もありませんし、心配なら手厚くテストを書けば良いのです。

UIの構築に注力するために、その前段階で工夫できることを磨いてみるのも良いのではないでしょうか。もっといい方法があるぜ!とか、わたしはこうしてるよ!とかがあれば是非教えてくださいね。

Discussion

nap5nap5

もっといい方法があるぜ!とか、わたしはこうしてるよ!とかがあれば是非教えてくださいね。

Tanstack QueryのuseQueryフックから提供されているselectオプションは使い勝手いいかもです。

steelyardsteelyard

コメントありがとうございます。

おぉ、そんな便利なものがあるのですね!
Tanstack Queryについては使用したことがないので、この機会に拝見してみようと思います。