こちらはAlgoliaのSenior Software EngineerのEmmanuel Krebs(@emmkrebs)が書いた Centralizing state and data handling with React Hooks: on the road to reusable components の翻訳です。
アプリケーション開発というのは往々にしてリアクティブなものです。必要が出てきたら、できるだけ早くソリューションをデリバーしていくわけですが、この高速なソフトウェアサイクルの中で、私たちは要件を集めて直ぐに実装に取り掛かります。私は別にクイック(素早い)でダーティー(汚い)な話をしているのでなく、ベストなRADベストプラクティス – rapid application development を指しています。
RADサイクルとは以下のようなものです: 優れたコア機能を実装(MVPなスタイル)し、何年もの経験に裏打ちされたメンテナンス性の高いコードを書く。しかし、時が経つにつれ、様々なことがおこります。要件が変わったり、より多くの実装が追加されたり、コードベースが直感的には素晴らしいけれども完全にロバストなアーキテクチャに対して反抗をはじめます。そして、技術が変化してコードをよりシンプルに、よりクリーンに、よりパワフルにする新しい方法が現れていることを発見することもあるでしょう。
ゲームチェンジャーである React Hooks が登場しました。急成長するビジネスにおいて、新しい機能が満載されるようにアプリケーションを書き換えなければならない。
Rewrite – スクラッチで。人生は二度目の機会を与えました。
React Hooksがどのような私たちの管理アプリを救ったのか
アプリケーション開発はまたプロ(リ)アクティブになり得ます。私たちの管理アプリケーションはdata-intensive(データ集約型)です。以前は多くの別々の(そして競合が発生する)コンポーネントがそれぞれ独立してデータを管理していました – connecting, formatiing, isplaying, updating 等
管理アプリケーションの要件
管理アプリケーションはデータの扱いを一元化するのに適した候補であると言えます。管理者はデータをそのままの形で見る必要があり、画面の表示はそこにあるデータの構造と一致します。一方で、お客様向けのダッシュボードにおいてはビジネスユーザー向けに機能するビューを表示しますが、管理者はお客様のサブスクリプションの情報をなどを一貫して直感的な方法で見る必要があります。
私たちが必要としていたのは、よりスケールするソリューションです。複数のソースからデータを取得し、それらは1つのAPIと多くのエンドポイントからアクセスができるため、データ処理における共通する部分を一元化したいと考えました。これによって、testing, caching, syncing, standard typingといった直ぐに手に入るメリットを得られるだけでなく、将来的なデータ統合も容易になりシンプルになりました。
カスタムフック
私たちはuseDataというReactのカスタムフックを実装して、データを取得するAPIコール、データエクスチェンジ、型のチェック、キャッシュといったデータをベースにする機能の全てを一元管理するようにしました。こうすることで、例えばキャッシュ機能だけでも、ユーザー視点では劇的にスピードが向上しました。
同じように重要なこととして、このスピードと一元化によって、フロントエンド開発者たちが、自分たちのコンポーネントやUI要素を様々なところで再利用できるようになったことが挙げられます。これによって、フロントエンド開発者が各コンポーネント内で固有のstateに関する情報を保持することがなく、機能が豊富でユーザーフレンドリーなUI/UXを実現できるようになりました。また、内部的にはデータが再利用されることによってフロントエンドの機能を動作させるモデルの一貫性が実現されました。
React Hooksのフロントエンドでのベネフィットについては、今後、別の記事としてご説明できればと思いますが、こちらでは、フロントエンドに信頼性と拡張性のあるデータ処理レイヤをどのように提供しているかをご紹介させていただきます。
どのようにuseDataフックで処理を一元化したか
私たちは様々なデータソースを使っていることは前にも述べましたが、かなり複雑なものがあるものの、全てはおなじJSON API仕様に準拠しています。さらに、それは全て同じニーズ – つまり
- データの取得
- デシリアライズおよびフォーマット
- フォーマットのバリデーション
- エラーハンドリング(データクオリティ、ネットワーク)
- アプリのリフレッシュや他のデータ/ワークフローとの同期
- データのキャッシュおよび最新状態を保つ
ということでお話はこれくらいにして、私たちのuseData
フックのコードを見ていきましょう
import { useCallback } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { ZodObject, infer as Infer } from 'zod';
import { useApi } from 'hooks';
import { metaBuilder, MetaInstance } from 'models';
interface Options {
forceCallApi?: boolean;
preventGetData?: boolean;
}
interface ApiData<T> {
data?: T;
meta?: MetaInstance;
}
export interface DataResult<Output> {
data?: Output;
meta: any;
loading: boolean;
errors: Error[];
refresh: () => Promise<void>;
}
export const useData = <Model extends ZodObject<any>, ModelType = Infer<Model>, Output extends ModelType = ModelType>(
builder: (data: ModelType) => Output,
url: string,
{ forceCallApi = false, preventGetData = false }: Options = {}
): DataResult<Output> => {
const queryClient = useQueryClient();
const { getData } = useApi(url);
const getDataFromApi = useCallback(async (): Promise<ApiData<Output>> => {
// here we get the data (and meta) using getData, and handle errors and various states
return { data: builder(apiData), meta: metaBuilder(apiMeta) }
}, [getData, builder, queryClient, url, forceCallApi]);
const { data: getDataResult, isLoading, error } = useQuery<ApiData<Output>, Error>(
[url, forceCallApi],
getDataFromApi,
{ enabled: !preventGetData, cacheTime: forceCallApi ? 0 : Infinity }
);
const refresh = useCallback(async () => {
await queryClient.refetchQueries([url, forceCallApi], {
exact: true,
});
}, [queryClient, url, forceCallApi]);
return {
data: getDataResult?.data,
meta: getDataResult?.meta,
loading: isLoading,
errors: ([error]).filter((error) => error !== null) as Error[],
refresh,
};
};
ご覧いただいたように、このフックは3つのパラメーターを受け取って、それらを組み合わせることで、以下の全ての機能を実現することができます。
- コンポーネントで使うためのデータの変換や拡張用の”builder”機能
- データを取得するAPIエンドポイントのURL
- オプションのパラメータ。例えば、キャッシュを無視したり、他のデータの準備が終わるまで待ってからAPIを呼び出す 等
結果として、私たちのコンポーネントは全てを管理する必要がなくなりました。複雑な部分を抽象化およびカプセル化したということです。
useData
フックは、コンポーネントで使うことができるvalueをいくつか返します:
- いくつかのstate: ローティングとエラー(もしあれば)
- データ(もしあれば)
- Meta情報(存在すれば。例えばページネーションの情報など)
- リフレッシュ機能(APIをもう一度呼び出してデータを更新)
データの構築
それではこのコードが何をしていて、どのように使うのかをより深くみていきましょう。
Zodでのスキーマバリデーション
データを取得するというのは一つのことに過ぎず、データが正しく構造化されているかどうか、つまり型付けされているかというのは別の問題です。複雑なデータ型には、yupやzodのように効率的でクリーンなメソッドを強制して、誤った型に基づく実行時エラーを処理するツールやエラー処理を提供するバリデーションツールが必要となります。私たちのフロントエンドは強く型付けされたデータセットに依存しているため、バリデーションは私たちにとってとても重要なステージです。
私たちはzodを使っています。Zodはデータのモデルを構築するために使われます。例えば、私たちのアプリケーションのモデルは以下のようになっています:
import { object, string, number } from 'zod';
const Application = object({
applicationId: string(),
name: string(),
ownerEmail: string(),
planVersion: number(),
planName: string(),
});
ビルダーファンクションを構築していくために、zedモデル上に自分たちで構築したジェネリックなヘルパーを使います。このヘルパーは2つのパラメータを受け取ります:
- データのモデル(上記の例ではApplication)
- モデルをリッチにするために使われるトランスフォーマー(変換)ファンクション
私たちの場合は、その変換は次のようなものになります。
import { infer as Infer } from 'zod';
const transformer = (application: Infer<typeof Application>) => ({
...application,
get plan() {
return `${application.planName} v${application.planVersion}`;
},
});
リッチ化のもう一つの例としては、モデルが日付を持つ場合です。文字列としての日付ではなくJavaScriptのdateをexposeしたいとします。
私たちは2つのバージョンのヘルパーファンクションを持っています。1つはobjectsでもう一つはarrays。以下はその1つ目です。
import type { ZodType, TypeOf, infer as Infer } from 'zod';
import { SentryClient } from 'utils/sentry';
export const buildObjectModel = <
Model extends ZodType<any>,
ModelType = Infer<Model>,
Output extends ModelType = ModelType
>(
model: Model,
transformer: (data: TypeOf<Model>) => Output
): ((data: ModelType) => Output) => {
return (data: ModelType) => {
const validation = model.safeParse(data);
if (!validation.success) {
SentryClient.sendError(validation.error, { extra: { data } });
console.error('zod error:', validation.error, 'data object is:', data);
return transformer(data);
}
return transformer(validation.data);
};
};
zodによって型付けされたアウトプットはとてもクリーンで、私たち自身が書いたTypeScriptの型のように見えますがzedは私たちのモデルを使ってJSONをパースしています。安全のために、zodのsafeParse
メソッドを使っていて、そうすることでパースの段階でエラーが発生した際、JSONを”as is(そのまま)”返すことができるようになります。また、エラーを追跡するツールであるSentryでエラーを受け取っています。
私たちの例では、ビルダーファンクションは以下のようになります。
export const applicationBuilder = buildObjectModel(Application, transformer);
// and for the record, here is how to get the type output by this builder:
export type ApplicationModel = ReturnType<typeof applicationBuilder>;
// which looks like this in your code editor:
// type ApplicationModel = {
// plan: string;
// applicationId: string;
// name: string;
// ownerEmail: string;
// planVersion: number;
// planName: string;
// }
APIのコール
内部的に別のカスタムフックである useAPI (200行未満のコード)を使ってGET/POST/PATCH/DELETEを処理しています。こちらのフックでは、axiosを使ってバックエンドAPIを呼び出して、典型的なCRUDを実行します。例えば読み込む側では、Axiosは受け取ったデータをJSON APIスペックからよりくクラシックなJSONに変換する前にデシリアライズして、snake_caseからcamelCaseにスイッチします。また、受け取ったMeta情報を処理することもあります。
また、プロセス的には、APIを呼び出す際のリクエストのキャンセルやエラーの管理も行っています。
データのキャッシュ
この時点でのまとめとしては、useApi
フックでデータを取得して、ビルダーに渡してバリデートとリッチ化を行って、react-queryでキャッシュしています。
フロントエンドでデータをキャッシュするために、APIエンドポイントのURLをキャッシュのキーとするreact-queryを実装しました。reqct-queryは前述のuseApi
フックを使ってリモートのデータの取得、同期、更新、そしてキャッシュを行うため、とても小さなコードベースでこれらの機能のすべてを活用できます。
つまり、私たちがしなければならないことは、react-queryのプロバイダを実装することだけです。そうするために私たちは小さなreactコンポーネントを作りました。
import { FC } from 'react';
import { QueryClient, QueryClientProvider, QueryClientProviderProps } from 'react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
refetchInterval: false,
refetchIntervalInBackground: false,
refetchOnMount: false,
refetchOnReconnect: false,
retry: false,
},
},
});
type IProps = Omit<QueryClientProviderProps, 'client'> & {
client?: QueryClient;
};
export const GlobalContextProvider: FC<IProps> = ({
children,
client = queryClient,
...props
}) => (
<QueryClientProvider {...props} client={client}>
{children}
</QueryClientProvider>
);
最も重要なことは、キャッシュを管理することです。同じデータを必要とするコンポーネントがたくさんあるため、同じ情報を取得するために不必要なネットワークトラフィックを避けたいのです。そして、パフォーマンスは常に重要です。不必要なネットワーク呼び出しを行うといった潜在的なエラーを抑えることも大切です。キャッシュを使えば、あるコンポーネントがデータを要求すると、キャッシュがそのデータを保存して、同じ情報を取得する要求をしてきた他のコンポーネントに渡します。もちろんバックグラウンドでは、reqct-queryがキャッシュ内のデータを最新に保つことに責務を負っています。
ということで、まとめると、このuseData
フックと上記で定義したApplicationモデルを使って作られたコンポーネントの例は以下のようになります。
import { FC } from 'react';
interface ApplicationProps {
applicationId: string;
}
export const ApplicationCard: FC<ApplicationProps> = ({ applicationId }) => {
const { loading, data: application, errors } = useData(applicationBuilder, `/applications/${applicationId}`);
return loading ? (
<div>loading...</div>
) : errors.length > 0 ? (
<div>{errors.map(error => (<div>{error}</div>))}</div>
) : (
<div>
<div>{application.applicationId}</div>
<div>{application.ownerEmail}</div>
<div>{application.name}</div>
<div>{application.plan}</div>
</div>
);
};
ご覧の通り、useData
フックを使えば、ローディングやエラーのstateを標準化することができ、それらのstateを処理する再利用可能なコンポーネントを書くことができます。例えば、私たちにはStateCard
やStateContainer
といった再利用可能なコンポーネントがありますが、データが簡単に利用できるようになったことで、これらの再利用可能なコンポーネントを統合して、クリーンで、完全な機能を持ち、スケーラブルな素晴らしいフロントエンドの構築に専念できるようになったのです。
コメント