React/Next.jsでの俺的ベストプラクティスを見てくれ
木瓜丸です。
最近になって、やっとNext.jsを上手く使いこなせてるんじゃないか?!と思えるようなコンポーネントの設計手法を見つけたので、Zennにまとめてみたいなと思います。
この記事で触れること
この記事では、主にページ単位でどのように状態管理を行うのかに焦点を当てることにします。
コンポーネントの管理の仕方などは特に着目しませんがご了承下さい。
hooksの導入
React初心者の方は最初に疑問に思うと思いますので、hooksについて触れておきます。
hooksというのは、Reactによって提供されているuseState, useEffectといったやつや、それらを組み合わせて作ったオレオレ状態管理基盤の総称です。
この記事で用いる基本的なhooksをいくつか紹介します。
useState
その名の通り、状態を持つ変数を作ってくれます。
const Hoge = () => {
const [state, setState] = useState<number>(0);
const countUp = () => setState(state + 1);
return (
<>
{state}
<button onClick={countUp}></button>
</>
)
}
なんでuseStateは[state, setState]が分けられるのか
Vueなんかを使っている人だと、useStateが返り値を2つ渡してきて、片方がただの変数になっていて片方がsetterみたいになっている設計に違和感があるかもしれません。
VueではComposition APIでもrefがObserverになっていて、再代入されると勝手に再描画は走ります。
では、Vueを書いている時にどこで再描画が発生しているか分からなくて、ウンウン悩んだあげくしょうもない所で数時間消耗したことありませんか?あ、ありませんか。なんでもないです。
Reactの場合、
- なにかしらのイベントが発生する
- setter的なものによって状態が更新される
- 再描画
という流れに従って描画されます。
逆に言うとsetterを発火しない限り再描画が起きないので、そういったバグに対して原因が追求しやすいようになっています。
useEffect
これはstateを監視し、変化があった時に関数を実行してくれるというものです。
const Hoge = () => {
const [state, setState] = useState<number>(0);
const countUp = () => setState(state + 1);
return (
<>
<Piyo param={state} />
<button onClick={countUp}></button>
</>
)
}
const Piyo = ({ state }: {state: number}) => {
const [isOdd, setIsOdd] = useState<boolean>(false);
useEffect(() => {
setIsOdd(state % 2 === 1)
}, [state])
return (
<>{ isOdd ? "odd" : "even"}</>
)
}
第二引数の配列に与えた変数が更新されると関数が実行されます。
useReducer
useStateのつよつよ版みたいなやつです。
ReduxやVuexといった状態管理ツールを使って開発をする方は多いと思いますが、その根底の思想としても有名なFlux Architectureに従って状態管理してくれます。
サンプルコードは長くなるので割愛しますmm
オレオレhooksを作る
では、Zennのすごい人とかが自作のuseHogehogeみたいな関数を作って使っているアレはなんなのでしょうか?
これはuseStateやuseEffectなどの提供されているhooksを組み合わせたもので、状態の取り回しが共通している部分などはhooksとして実装して切り出してしまおうというものです。
例えば、APIからデータを取ってstateに入れる処理があるとき、
const useReadAPI = () => {
const [data, setData] = useState(null);
useEffect(() => {
fetch('https://example.com/api').then(res => res.json()).then(res => {
setData(res)
})
}, []);
return {
data
}
}
みたいに作っておけば、
const Hoge = () => {
const { data } = useReadAPI();
return (
<>{data === null ? 'data not available' : data}</>
)
}
というように使えるわけです。
個人的には、
- 複雑な状態遷移をまとめておくことができる
- 外部との通信を行って状態を更新する流れを抽象化できる
といったメリットがあると感じており、そのような活かし方ができそうな時にチョコチョコhooksを自作したりします。
Stateが複雑化した時の悩み
Next.jsにしろ他のFWにしろ、開発しているうちにStateはモリモリになって、気付いたらuseStateが20行くらい書いてあるみたいなことになります。
Reactのstateはsetterが分かれているためバグが見つかりやすいと上述しましたが、そうなってくると収拾が付きません。
テストを書こうにも、そんな複雑化したフロントエンドのテストケースなんて考えるだけで吐きそうになりますね。
そこで、状態をページ単位でまとめて管理する新しい仕組みを作ってみることにします!
ViewModelHookを作ろう
オレオレhooksの作り方を上述しましたが、これを応用して複雑化するStateをシンプルに見えるように作り換えてみましょう。
ここで作るhookの事をViewModelHookと呼ぶことにします。
先にサンプルコードの全体像を示し、順を追って解説していきます。
interface SampleViewModelState {
count: number;
}
export type SampleViewModelAction = {
type: 'RESET';
} | {
type: 'ADD';
amount: number;
} | {
type: 'SUB';
amount: number;
}
const SampleViewModelReducer = (state: SampleViewModelState, action: SampleViewModelAction) => {
switch(action.type){
case 'RESET':
return { count: 0 }
case 'ADD':
return { count: state.count + action.amount }
case 'SUB':
return { count: state.count - action.amount }
default:
return state
}
}
const useSampleViewModel = () => {
const [state, dispatch] = useReducer()
const reset = () => {
dispatch({
type: 'RESET',
});
}
const add = (amount: number) => {
dispatch({
type: 'ADD',
amount,
});
}
const sub = (amount: number) => {
dispatch({
type: 'SUB',
amount,
});
}
return {
...state,
reset,
add,
sub
}
}
const HogePage = () => {
const viewmodel = useSampleViewModel()
return (
<>
{viewmodel.amount}
<button onClick={() => viewmodel.reset()}>reset</button>
<button onClick={() => viewmodel.add(3)}>+3</button>
<button onClick={() => viewmodel.sub(2)}>-2</button>
</>
)
}
ブラウザバックしないで下さい(泣)
これには訳があるんです。ただ足し引き算を冗長に書いてるだけじゃないんです。
ポイントは、Pageコンポーネントからはメソッドの実行のみを行っているところです。
Reducerを使ってFluxの旨みを活かす
Fluxの概念に立ち帰って考えてみましょう。
Fluxでは、ActionをDispatcherに渡すとStoreが更新され、Viewに反映されるという流れで描画されますが、これによって
- 画面の更新を発火させる場所
- 状態の更新ロジック
を分離でき、何か問題が発生した場合に原因箇所を探しやすくなります。
上述の通り、useReducerを使えばFluxをそのまま実装することができます。とりあえずReducerだけ作ったものが、↓こちら↓
interface SampleViewModelState {
count: number;
}
export type SampleViewModelAction = {
type: 'RESET';
} | {
type: 'ADD';
amount: number;
} | {
type: 'SUB';
amount: number;
}
const SampleViewModelReducer = (state: SampleViewModelState, action: SampleViewModelAction) => {
switch(action.type){
case 'RESET':
return { count: 0 }
case 'ADD':
return { count: state.count + action.amount }
case 'SUB':
return { count: state.count - action.amount }
default:
return state
}
}
const useSampleViewModel = () => {
const [state, dispatch] = useReducer()
return {
...state,
}
}
Reducerのdispatchもユースケースごとにラップする
ViewModelHookでは、Reducerを使った上で、さらにdispatcherを呼びだす処理をユースケースごとにメソッドとしてくるみ、コンポーネントに渡します。
...
const useSampleViewModel = () => {
const [state, dispatch] = useReducer()
const reset = () => {
dispatch({
type: 'RESET',
});
}
const add = (amount: number) => {
dispatch({
type: 'ADD',
amount,
});
}
const sub = (amount: number) => {
dispatch({
type: 'SUB',
amount,
});
}
return {
...state,
reset,
add,
sub
}
}
これは、テスタビリティを上げる目的で疎結合にするためです。
メソッドはユーザーの行動が意味する内容によって切り分けるようにし、「実質的に中では同じ処理だから」という理由でまったく違う目的のメソッドを再利用することを避けましょう。
まとめ
- ViewModelHookという仕組みを提唱しました(ドヤ顔)
- Fluxに従った状態遷移を行えるよう、内部ではuseReducerを使用します
- Viewとロジックを疎結合にするため、メソッドはユースケースごとに切り分けて提供しましょう
複雑にStateが入り乱れるプロジェクトでは、ViewModelHookをNext.jsのPageごとに導入すれば、多分幸せになれると思います!
この記事を読んでみたり、やってみて思ったことなどあれば、是非ともコメントでおしえて下さい。自分自身の学びにさせて頂ければなと思っています。
Discussion