ReactのJSX生成には、ある困った問題を持っています。
それはSPAなどで画面を遷移した直後に、変数を読み込ませたい場合です。具体的にどのような不具合が発生するのかですが、以下のようにTodo機能の詳細ページを作成するとします。パラメータからidを取得して、それに紐づいたTodoデータを取得し詳細ページを表示させるというものです。
※react-router-dom6使用
import React,{ useState,useContext } from 'react';
import {TodoContext} from '../App'
import {useParams,useNavigate } from 'react-router-dom'
const EditTodo: React.FC = () => {
const [d_item,setItem] = useState([])
const context = useContext(TodoContext)
const navigate = useNavigate()
const params = useParams() //パラメータからid取得
/*中略*/
return (
<>
<h2>TODOを編集する</h2>
{
(params.id != d_item.id)?
<div>ID:{params.id}のTODOが見つかりませんでした</div>
:
<table>
<thead>
<tr>
<th>タイトル</th>
<th>説明</th>
<th>ステータス</th>
</tr>
</thead>
<tbody>
<tr>
<td>{d_item.title}</td>
<td>{d_item.description}</td>
<td>{d_item.status}</td>
</tr>
</tbody>
</table>
}
<button onClick={()=>navigate("/")} >戻る</button>
</>
)
}
export default EditTodo;
これについては、クラスコンポーネントを使用していたReact16.7以前だとロード時に処理を制御するconstrcutorを使用していたので、さして問題はなかったのですが、関数コンポーネントだとconstructorに代わる方法が必要となります。
ちなみに、これは以前投稿していた記事に対し、閲覧者(honey32氏)から誤りの指摘を受けたので一旦削除(Qiitaは記事の取り下げができない仕様なので)した上で、採り上げたい部分だけピックアップしたものです。
方法1:useEffectフック(今回では不適切)
stackoverflowやGithubのissueで解決方法を検索していると、だいたいはuseEffectフックを使用すればよいという回答が導かれます。
ところが……。
import React,{ useState,useContext,useEffect } from 'react';
import {TodoContext} from '../App'
import {useParams,useNavigate } from 'react-router-dom'
const EditTodo: React.FC = () => {
const [d_item,setItem] = useState([])
const context = useContext(TodoContext)
const navigate = useNavigate()
const params = useParams() //パラメータからid取得
/*useEffectはJSX生成後に呼び出される*/
useEffect(()=>{
const item = context.todos.find((item)=>item.id == params.id)
setItem(item)
},[params.id,context])
return (
<>
<h2>TODOを編集する</h2>
{
(params.id != d_item.id)?
<div>ID:{params.id}のTODOが見つかりませんでした</div>
:
<table>
<thead>
<tr>
<th>タイトル</th>
<th>説明</th>
<th>ステータス</th>
</tr>
</thead>
<tbody>
<tr>
<td>{d_item.title}</td>
<td>{d_item.description}</td>
<td>{d_item.status}</td>
</tr>
</tbody>
</table>
}
<button onClick={()=>navigate("/")} >戻る</button>
</>
)
}
export default EditTodo;
useEffectフックはDOM生成後に処理を行う仕様のため、DOM生成前に変数を処理しなければいけない今回の場合だとd_item.idは未定義のままなのでエラーが表示されます。
TypeError: Cannot read properties of undefined (reading 'id')
方法2:カスタムコンポーネント化(分担作業には不向き)
これを解決させる一般的な方法がカスタムコンポーネント化です。カスタムコンポーネントにすれば、オブジェクトを定義したcustomという変数がJSX生成前に評価されるので今回の処理もうまくいくのですが、変数処理を理由にコンポーネント化するとひとかたまりのJSXが細切れに分断され、後々のメンテナンスや機能拡張、デザイン担当との分担が大変になるので、自分としてはあまりやりたくない方法です。また、今回の場合はd_itemという変数がJSX全体に影響しているので、カスタムコンポーネント化自体が冗長な制御になってしまいます。
import React,{ useState,useContext } from 'react';
import {TodoContext} from '../App'
import {useParams,useNavigate } from 'react-router-dom'
const DetailTodo: React.FC = () => {
const [d_item,setItem] = useState([])
const context = useContext(TodoContext)
const navigate = useNavigate()
const params = useParams() //パラメータからid取得
/*カスタムコンポーネントはJSX生成前に構築されるので、d_itemの変数が準備された状態にはなる*/
const Custom = ()=>{
const item = context.todos.find((item)=> item.id == params.id )
setItem(item)
return(
(params.id != d_item.id)?
<div>ID:{params.id}のTODOが見つかりませんでした</div>
:
<table>
<thead>
<tr>
<th>タイトル</th>
<th>説明</th>
<th>ステータス</th>
</tr>
</thead>
<tbody>
<tr>
<td>{d_item.title}</td>
<td>{d_item.description}</td>
<td>{d_item.status}</td>
</tr>
</tbody>
</table>
)
}
return (
<>
<h2>TODOを編集する</h2>
<Custom />{/*このように部品を分離されると、デザイナーにとっては困った記述方法となる*/}
<button onClick={()=>{navigate("/")} } >戻る</button>
</>
)
}
export default DetailTodo;
方法3:useMemoフック(一般的ではない)
そこでuseEffectフック、カスタムコンポーネント化の代替方法として有効じゃないのかと思ったのが、useMemoフックによるメモ化です。先程リンクを貼った質問でも代替方法として紹介している回答者がおり、少数ながらgood評価がされています。
その回答者は次のように答えています。
You can use useMemo hook (as below) to demonstrate as constructor for functional component. Somebody suggested to use useEffect but it will be invoked after render.
(関数コンポーネントにおいてコンストラクタ代わりにuseMemoフックを使用できます。皆さんはuseEffectを採り上げていますが、それはレンダー《JSX生成》後に呼び出されるものです)
また、この記事でも代替方法として紹介しているようです。
React HooksでクラスをStateless Functional Componentsで書き換えよう
useMemoフックは、本来はパフォーマンス向上のために、時間やメモリを要するような処理に対し、特定の変数が変更されたタイミングで処理が実行されるというものですが、このuseMemoフックはJSX生成前に処理が行われるため、JSX生成時には既に変数が代入された状態となっているので、上記エラーを回避することができます。
import React,{ useState,useContext,useMemo } from 'react';
import {TodoContext} from '../App'
import {useParams,useNavigate } from 'react-router-dom'
const DetailTodo: React.FC = () => {
const [d_item,setItem] = useState([])
const context = useContext(TodoContext)
const navigate = useNavigate()
const params = useParams() //パラメータからid取得
/*useMemoはJSX生成前に呼び出されるので、setItemによる変数代入が間に合う*/
useMemo(()=>{
const item = context.todos.find((item)=>item.id == params.id)
setItem(item)
},[params.id,context])
return (
<>
<h2>TODOを編集する</h2>
{
(params.id != d_item.id)?
<div>ID:{params.id}のTODOが見つかりませんでした</div>
:
<table>
<thead>
<tr>
<th>タイトル</th>
<th>説明</th>
<th>ステータス</th>
</tr>
</thead>
<tbody>
<tr>
<td>{d_item.title}</td>
<td>{d_item.description}</td>
<td>{d_item.status}</td>
</tr>
</tbody>
</table>
}
<button onClick={()=>navigate("/")} >戻る</button>
</>
)
}
export default DetailTodo;
ただ、Reactの公式マニュアルを調べてみても、この手法は載っていません。したがって一般的な解決方法とはいえないようで、もっと適切な方法が存在しないか気になりました。ちなみにReact.memoを使用してメモ化をしても、d_itemに対し変数未定義エラーは出なくなるものの、setItemメソッドによる変数代入がされないので、nullとなります(検証済)。
参考にした記事
useMemo, useCallback, React.memoとは何か?違いは?
方法4: useCallbackフック(条件あり)
更に色々調べているとuseCallbackフックを使用するという例もあり、これでも詳細ページがうまく表示されました。この方がまだReact本来の使用方法に近いのではないかと思います。なお、useCallbackフックを使用しない場合にuseStateフックを呼び出すとinfinite loopエラーが発生します。
ちなみに以下の記述は、まだ暫定的な解答です。
import React,{ useState,useContext,useCallback } from 'react';
import {TodoContext} from '../App'
import {useParams,useNavigate } from 'react-router-dom'
const EditTodo: React.FC = () => {
const [d_item,setItem] = useState([])
const context = useContext(TodoContext)
const navigate = useNavigate()
const params = useParams() //パラメータからid取得
const getCallback = useCallback( ()=>{
const item = context.todos.find((item)=>item.id == params.id)
setItem(item)
},[context])
getCallback() //ここでコールバック関数を返す
//更新
const hundle = (e)=>{
const {name,value} = e.target
setItem({...d_item,[name]:value})
}
//更新ボタン押下イベント
const onSubmit = ()=>{
const data = {
title:d_item.title,
description:d_item.description,
str_status:d_item.str_status,
}
context.updTodo(params.id,data)
navigate('/') //トップページに遷移
}
return (
<>
<h2>TODOを編集する</h2>
{
(params.id != d_item.id)?
<div>ID:{params.id}のTODOが見つかりませんでした</div>
:
<>
<div>
<label>タイトル</label>
<input type="text" id="title" name="title" value={d_item.title} onChange={hundle}/>
</div>
<div>
<label>説明</label>
<textarea id="description" name="description" value={d_item.description} onChange={hundle}/>
</div>
<div>
<label>ステータス</label>
<select id="status" name="str_status" onChange={hundle}>
<option value="waiting">waiting</option>
<option value="working">working</option>
<option value="completed">completed</option>
<option value="pending">pending</option>
</select>
</div>
</>
}
<button onClick={()=>onSubmit()}>更新する</button>
</>
)
}
export default EditTodo;
ただ、この方法だとフォームに動的な変化を伴う修正ページの場合は対応できませんでした。
方法5:useEffectフック+useCallbackフック
そこで、以下の回答に合わせてuseCallbackフックをuseEffectフックと合わせて使用してみるとうまく処理され、フックの使用方法も適切といえるので、これがベストプラクティスじゃないかと思います。useEffectフック単体だと、d_itemの変数代入前にJSXを展開しようとしますが、useCallbackフックを使用すれば、絞り込み処理を記憶し、変数d_itemを確保してくれる上に、JSX生成後にuseEffectフックとuseStateフックによってステート管理も行えるようになります。
import React,{ useState,useContext,useEffect,useCallback } from 'react';
import {TodoContext} from '../App'
import {useParams,useNavigate } from 'react-router-dom'
const EditTodo: React.FC = () => {
const [d_item,setItem] = useState([])
const context = useContext(TodoContext)
const navigate = useNavigate()
const params = useParams() //パラメータからid取得
const getCallback = useCallback( async (todos)=>{
const item = await todos.find((item)=>item.id == params.id)
setItem(item)
},[params.id])
useEffect(()=>
getCallback(context.todos) //useEffectフックから変数処理をコールバックする
,[context,getCallback])
//更新
const hundle = (e)=>{
const {name,value} = e.target
setItem({...d_item,[name]:value})
}
//更新ボタン押下イベント
const onSubmit = ()=>{
const data = {
title:d_item.title,
description:d_item.description,
str_status:d_item.str_status,
}
context.updTodo(params.id,data)
navigate('/') //トップページに遷移
}
return (
<>
<h2>TODOを編集する</h2>
{
(params.id != d_item.id)?
<div>ID:{params.id}のTODOが見つかりませんでした</div>
:
<>
<div>
<label>タイトル</label>
<input type="text" id="title" name="title" value={d_item.title} onChange={hundle}/>
</div>
<div>
<label>説明</label>
<textarea id="description" name="description" value={d_item.description} onChange={hundle}/>
</div>
<div>
<label>ステータス</label>
<select id="status" name="str_status" onChange={hundle}>
<option value="waiting">waiting</option>
<option value="working">working</option>
<option value="completed">completed</option>
<option value="pending">pending</option>
</select>
</div>
</>
}
<button onClick={()=>onSubmit()}>更新する</button>
</>
)
}
export default EditTodo
方法6: 関数コンポーネント内に直書き(今回は非推奨)
Vue3のcomposition APIやSvelteのsetupメソッドのように直書きしてみたらどうなるか試してみました。詳細ページのように単純に変数を代入して結果を参照するだけなら、これでも問題はなさそうですし、公式ページのチュートリアルにも実用例がありました。
import React,{ useState,useContext,useCallback } from 'react'
import {TodoContext} from '../App'
import {useParams,useNavigate } from 'react-router-dom'
const DetailTodo: React.FC = () => {
const [d_item,setItem] = useState([])
const context = useContext(TodoContext)
const navigate = useNavigate()
const params = useParams() //パラメータからid取得
const d_item = context.todos.find((item)=>item.id == params.id)
ですが、修正ページのように一度フォーム上に展開された変数に対し動的な変更が加わる場合は、上記の方法を用いると色々と面倒な問題が発生(フォームで変更した際に制御できない、かといってロード時にuseStateを呼び出すとinfinite loopエラー)します。結局、動きのない詳細ページなど使用が限定されるので、今回だとこの方法は推奨できないです。
結論
ベストプラクティスはおそらく5のuseEffect+useCallbackでしょう。ですが、自分としては3のuseMemoの方法でも問題ないのでは?とも考えています。たしかに本来の使用方法じゃないのでバッドノウハウになるのかもしれませんが、5より記述が簡単だからです。
また、DEVコミュニティにもこれは有効な使い道だと評する記事がありました。
これについて明確な回答を頂ける方、ご意見などがあると助かります。