1. はじめに
Next.jsを使う時でも、バックエンドのサーバーを別で用意するのであれば、fetchというものは度々使うことになると思います。
fetchコードを記述することが多くなってくるとやりたくなるのが、重複部分の共通化です。
fetch処理を共通化するにあたって、関数とクラスの使い方を試してみました。
忘備録を兼ねて、ここで解説させていただこうと思います。
目次
2. 関数でfetch共通化
この章では、fetch処理はどのような時に共通化したいのか、想定される場面を説明したのち、実際に考えられる実装例と、その課題について述べます。
2-1. 想定する状況
まず、関数で処理を共通化する場面を考えてみましょう。
以下は、Next.jsのコンポーネントの中で、GETメソッドでサーバーからデータをfetchする処理と、POSTメソッドでサーバーにデータを渡す処理の二つを実装している例です。
Next.jsがわからない方は、関数の場所だけ見てもらっても構いません。
"use clinet";
export default function PostThreadForm(){
const [error,setError] = useState<string>("");
const [isLoading,setIsLoading] = useState<boolean>(false);
const [data,setData] = useState<string>("");
const [filtering,setFiltering] = useState<{filter:string,filter_value:string}>();
const [comment,setComment] = useState<string>("");
async function HandleSearch(){
setIsLoading(true);
try{
const res = await fetch(`${process.env.SERVER_BASE_URL}/api/threads`,{
method:"GET",
headers:{
"Content-Type":"application/json"
},
body:JSON.stringify({
filter:filtering.filter,
filter_value:filtering.filter_value
})
});
if (res.status !== 200) setError("Failed to fetch filtered data.");
const data = res.json();
setData(data);
}catch(error){
error instanceof Error ? setError(error.message) : setError("Something went wrong.");
}finally{
setIsLoading(false);
}
}
async function HandleSubmit(){
setIsLoading(true);
try{
const res = await fetch(`${process.env.SERVER_BASE_URL}/api/threads/${id}`,{
method:"POST",
headers:{
"Content-Type":"application/json"
},
body:JSON.stringify({comment})
});
if (res.status !== 200) setError("Failed to post thread.");
const data = res.json();
setData(data);
}catch(error){
error instanceof Error ? setError(error.message) : setError("Something went wrong.");
}finally{
setIsLoading(false);
}
}
return(
<>
// data list & form UI
</>
);
}
※ここでは省略していますが、commentやdataオブジェクトは、input 要素のchangeイベントが発行されたとき、input の入力値をsetStateで取得するようにしています。よく分からない方は、controlled inputというワードで調べてみてください。
2-2. 実装例
仮に、上のコードを書いたのがプログラマー初心者「ずんだもん」だとしましょう。
あなたは彼/彼女に、どのようなアドバイスをしますか?
このままのコードでも大丈夫でしょうか?
私だったら、「ロジックを切り出した方がいいかも」とアドバイスするでしょう。
Next.js(React.js)において、コンポーネントはUIを表示させるのに専念させることが望まれます。つまり、ロジック部分はあまり大きくしてほしくないわけです。イベントが発行されたときに走る関数を関数コンポーネントの中に記述することは一般的だと思いますが、それでもいちいちfetchメソッドなどを書いているとコード量が大きくなり、そのためロジック部分の記述量が膨張してしまいます。
こんなときは、サーバーにリクエストを投げる処理を共通化すると便利です。
まずは、関数で共通化してみましょう。
"use clinet";
async function request(
path:string,
method:"GET"|"POST"|"PATCH"|"DELETE",
bodyInfo?:{[key:string]:string|number}
):Promise<Response>{
const body=bodyInfo && JSON.stringify({...bodyInfo});
return await fetch(`${process.env.SERVER_BASE_URL}/api/${path}`,{
method,
headers:{
"Content-Type":"application/json"
},
body,
});
}
export default function PostThreadForm(){
const [error,setError] = useState<string>("");
const [isLoading,setIsLoading] = useState<boolean>(false);
const [data,setData] = useState<string>("");
const [filtering,setFiltering] = useState<{filter:string,filter_value:string}>();
const [comment,setComment] = useState<string>("");
async function HandleSearch(){
setIsLoading(true);
try{
const res = await request("threads","GET",{...filtering});
if (res.status !== 200) throw new Error("Failed to fetch filtered data.");
const data = res.json();
setData(data);
}catch(error){
error instanceof Error ? setError(error.message) : setError("Something went wrong.");
}finally{
setIsLoading(false);
}
}
async function HandleSubmit(){
setIsLoading(true);
try{
const res = await request("threads","GET",{comment});
if (res.status !== 200) throw new Error("Failed to post thread.");
const data = res.json();
setData(data);
}catch(error){
error instanceof Error ? setError(error.message) : setError("Something went wrong.");
}finally{
setIsLoading(false);
}
}
return(
<>
// data list & form UI
</>
)
}
コンポーネント内のロジック処理の記述が、かなりすっきりしました。
ずんだの妖精 「やった!これでfetch処理は共通化できたのだ!もう大丈夫なのだ!」
私 「ちょっと待った。満足するには少しはやいんだぜ」
2-3. 関数による共通化の課題
概ねは、今回作ったrequestメソッドの様に、fetch処理を関数で共通化するだけで済むことが多いと思います。
しかしNext.jsでは、主にgetメソッドの時だけ、fetchの初期化オブジェクトで設定するプロパティがいくつかあります。それが、{cache:"no-store"}
や、{next:{revalidate:60}}
などの値です。
即ち、getメソッドの時は特別な対応が必要になるわけです。
では、requestメソッドを変えるとしたら、どのようにすればよいでしょうか?
以下が解決策のひとつになると思います。
export async function request(
path:string,
method:"GET"|"POST"|"PATCH"|"DELETE",
bodyInfo?:{[key:string]:string|number},
cacheOption?:{
noCache?:boolean,
next?:{revalidate:number}
}
):Promise<Response>{
const body=bodyInfo && JSON.stringify({...bodyInfo});
return await fetch(`${process.env.SERVER_BASE_URL}/api/${path}`,{
cache:`${cacheOption?.noCache ? "no-cache" : "force-cache" }`,
next:cacheOption?.next,
method,
headers:{
"Content-Type":"application/json"
},
body,
});
}
いかがでしょうか。
確かに、これでもrequest処理全般を賄うことができます。しかし、これには二つほど問題点があると思います。
一つ目に、requestメソッドが必要とする引数の数が多くなりうること。
これは、二つほど解決策を考えることはできます。一つ目は、関数の引数をひとつのオブジェクトにすること。二つ目に、bodyInfoとかcacheOptionとかを引数として求めず、メソッドの呼び出し側において、第三引数としてまとめてイニシャルオブジェクトを定義してもらうことです。
しかしどちらにしろ、呼び出す側で多くのプロパティをもったオブジェクトをつくらなければなりません。このメソッドを呼び出すのはコンポーネントの中になるわけですから、やはり呼び出し側の記述を多くさせる実装はあまりよろしくないかもしれません。例えば、以下の通りです。
type RequestValue={
path:string,
method:"GET"|"POST"|"PATCH"|"DELETE",
init?:{[key:string]:string|number|Object},
};
export async function request(value:RequestValue):Promise<Response>{
const path=value.path;
return await fetch(`${process.env.SERVER_BASE_URL}/api/${path}`,{
method:value.method,
...value.init
});
}
const value:RequestValue={
path:"threads",
method:"GET",
init:{
cache:"no-store",
body:JSON.stringify({...filtering})
}
};
const res = await request(value);
二つ目の問題点として、requestメソッドに求める役割が大きくなる点が問題になると思います。
cacheOptionはgetの時しか入用にはなりませんが、postやpatchメソッドでfetchしたいときもrequestメソッドを呼び出すため、第三者(又は数年後の自分)がメソッドの呼び出し元からそれを定義しているスクリプトに飛んだ時、なぜcacheOptionなどが引数に設定されているのか(一瞬)分からないと思います。
粒度の小さい関数を作って組み合わせることで保守性と拡張性を担保するのがよりよいやり方であると私は信じているので、fetchを共通化するにしてももうちょっと関数を分割したいところです。
上記の二つの問題意識に基づくと、fetchに渡すイニシャルオブジェクトのプロパティが多くなったりメソッドによって違ってくるとき、関数ではない別の方法による共通化が望ましいのではないかと感じてきます。
ずんだもん 「じゃあ、どうすればいいのだ!」
私 「クラスを使ってみましょう」
3. クラスでfetch共通化
type Body={[key:string]:string|number};
export class API{
private path:string=`${process.env.SERVER_BASE_URL}/api/`;
private body:string|undefined;
constructor(path:string,body?:Body){
this.path=this.path + path + "/";
this.body=body && JSON.stringify({...body});
}
private getBaseOptions=(method:"GET"|"POST"|"PATCH"|"DELETE")=>{
return{
method,
headers:{
"Content-Type":"application/json"
},
body:this.body,
}
}
getMethod=({noCache,next}:{noCache?:boolean,next?:{revalidate:number}}):Promise<Response>=>{
const baseOptions = this.getBaseOptions("GET");
return fetch(`${this.path}`,{
cache:`${noCache ? "no-store" : 'force-cache' }`,
next,
...baseOptions,
} );
};
postMethod=():Promise<Response>=>{
return fetch(`${this.path}`,this.getBaseOptions("POST"));
}
// pathcMethod...
// deletMethod...
// I abbrevated these methods.
}
APIクラスを呼び出す際に、pathを渡し、必要であればbodyも渡すようにしています。
そして、各fetchメソッドごとに対応するメソッドを定義し、特にgetメソッドの時はcacheやnextの値を渡していますが、他のメソッドの時にはそのような引数は受け取りません。これにより、メソッドごとに異なる引数の混在がなくなりました。
呼び出し側ではこのように使うことになります。
import {API} from "@/lib/Common/CommonAPI"
async function HandleSearch(){
setIsLoading(true);
try{
const res = await new API("threads",{...filtering}).getMethod({noCache:true});
if (res.status !== 200) throw new Error("Failed to fetch filtered data.");
const data = res.json();
setData(data);
}catch(error){
error instanceof Error ? setError(error.message) : setError("Something went wrong.");
}finally{
setIsLoading(false);
}
}
ずんだもん 「呼び出す側がほんとに一行で終わっているのだ!」
const res = await new API("threads",{...filtering}).getMethod({noCache:true});
と、一行で済ませていますが、分かりにくいようであれば以下のように二行にすることもできます。いずれにしろ、かなり簡潔になったことが分かると思います。
const api = new API("threads",{...filtering});
const res = await api.getMethod({noCache:true});
4. おわりに
関数を使うかクラスを使うかは、人の好みと状況によると思います。特にNext.js(React.js)では、コンポーネントがクラスから関数に変更して以降、あまりクラスを実装で使う場面を目撃することが少なくなったように思います。そのため、使い馴染んだ関数を工夫して拡張することも十分に考えられると思います。一方で、ややこしい定義をすべてクラスに封じ込めて、呼び出すときの認知的負荷とコード量を減らす方がよいと感じる人もおられると思います。
状況と好みに依るので、お好きな方を選んでいただけるとよいと思います。