前回に続きReact+Firebaseでミニアプリを作っていきます。前回の投稿はこちらから。
React + Firebaseのミニアプリを作る【実装編3】
前回に続きReact+Firebaseでミニアプリを作っていきます。前回の投稿はこちらから。 どんなアプリを作るかは【計画編】を見て下さい。 前回はReduxのセットアップと、Fireb ...
続きを見る
どんなアプリを作るかは【計画編】を見て下さい。
前回まででCreate, Readまでは実装できました。今回はUpdateの実装です。
REST APIでCRUD作成(前回の続き)
今回ベースとするソースコードは以下のコードから【実装編2】【実装編3】の内容を実装したものになります。
CRUDの完成コードはこちら↓
フォルダ構成やファイルの全体像がわからなくなった場合は完成コードを参照して下さい。
SubjectのUPDATE
SubjectのUpdateは非常に簡単でCreateの処理とほぼ同じです。PUTメソッドを使って更新をするところとreducerがやや異なります。
コンポーネント
まずは編集画面を表示させます。とはいえフォームとレイアウトは再利用出来るように分割してあったのでほぼonSubmit以外は同じようなコードになります。
components > content > form > SubjectEdit.js
import React from "react"; import { useDispatch, useSelector } from "react-redux"; import { useHistory } from "react-router-dom"; import { useForm } from "react-hook-form"; import { updateSubject } from "../../../actions/subjects"; import SubjectForm from "./SubjectForm"; import SubjectFormLayout from "../../layout/SubjectFormLayout"; const SubjectEdit = () => { const dispatch = useDispatch(); const history = useHistory(); const item = useSelector(state => state.subjects.subject); const { register, handleSubmit } = useForm(); const onSubmit = data => { data.id = item.id; dispatch(updateSubject(data)); history.push("/detail/" + item.id); }; return ( <SubjectFormLayout title="編集"> <SubjectForm handleSubmit={handleSubmit} register={register} onSubmit={onSubmit} title={item.title} /> </SubjectFormLayout> ); }; export default SubjectEdit;
これを表示するためContent.jsにRouteを追加します。
components > layout > Content.js
// import SubjectEdit from "../content/form/SubjectEdit"; <Route path="/edit/:id"> <Grid container justify="center" className={classes.root}> <Grid item xs={12} sm={10} md={8}> <SubjectEdit /> </Grid> </Grid> </Route>
あとはリンクを設置すれば完了です。詳細画面(DetailView)のツールバーから編集できるようにするので、
DetailToolbar.jsを少し変更します。Subjectのidをpropsで受け取るように変更します。
const { title, id } = props;
またペンのIconButtonにcomponent={Link}
とto={`/edit/${id}`}
を追加します。
<IconButton edge="start" color="inherit" aria-label="menu" component={Link} to={`/edit/${id}`} > <EditOutlinedIcon /> </IconButton>
以上でコンポーネントに関しては終了です。
Action Creator
action creatorでやることはシンプルで、PUTメソッドでデータを送信し、成功すればアクションを生成する。これだけです。
export const updateSubject = data => async dispatch => { try { await axios.put(`/subject/${data.id}.json`, data); dispatch(updateSubjectSuccess(data)); } catch (error) { dispatch(apiFailed("update subject failed")); } }; const updateSubjectSuccess = data => { return { type: ACTION_TYPES.UPDATE_SUBJECT, payload: data }; };
Reducer
reducerでは変更したデータをstateのsubjectに入れつつ、subjectListも変更しています。
subjectListではIDを元に更新されたデータのオブジェクトのみ入れ替えて、新たな配列をmapによって生成しています。
case ACTION_TYPES.UPDATE_SUBJECT: return { ...state, subject: action.payload, subjectList: state.subjectList.map(item => item.id === action.payload.id ? action.payload : item ) };
ポイント
記事を書いているときに気付きましたが、subjectListは更新する必要は(たぶん)ないです。ListViewを表示する際はAPI通信でデータを取得するため、更新後のデータを取得することになるからです。
以上でSubjectのUpdateは実装完了です。
このように編集画面が表示され、あらかじめ現在の値がセットされています。
編集機能も動作していますので確認してみて下さい。
注意ポイント
Saveボタンを押すと詳細画面に戻りますが、編集したタイトルが反映されておらず、古いタイトルが表示されるバグがあります。これについては後述します。
リロード・もしくは一旦リスト画面に戻れば、更新自体は正しくされていることが確認できるはずです。
(正直バグに関しては認識していましたが、すっかり忘れておりました😋)
Q&AのUPDATE
次にQ&A部分でのUpdateを実装していきたいと思います。一応このアプリのメインとなる部分ですね。
コンポーネント
今まで表示してきたQ&AはただのTextFieldでした。これをきちんとformで囲い、各Fieldにnameを設定します。
DetailView.jsを以下のように編集します。
import React, { useEffect } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useHistory, useParams } from "react-router-dom"; import { useForm } from "react-hook-form"; import Grid from "@material-ui/core/Grid"; import Box from "@material-ui/core/Box"; import Button from "@material-ui/core/Button"; import QuestionAnswer from "./detail/QuestionAnswer"; import DetailToolbar from "./detail/DetailToolbar"; import ExtraQuestion from "./detail/ExtraQuestion"; import { getSubject } from "../../actions/subjects"; import { updateQuestionAnswers } from "../../actions/questionAnswers"; const DetailView = props => { const dispatch = useDispatch(); const history = useHistory(); const { id } = useParams(); const { register, handleSubmit } = useForm(); const item = useSelector(state => state.subjects.subject); const questions = useSelector(state => state.subjects.questions); const answers = useSelector(state => state.subjects.answers); useEffect(() => { dispatch(getSubject(id)); }, [dispatch, id]); const onSubmit = data => { dispatch(updateQuestionAnswers(id, data)); }; return ( <Grid container spacing={2}> <Grid item xs={12}> <DetailToolbar title={item.title} id={item.id} /> </Grid> <Grid item xs={12}> <form onSubmit={handleSubmit(onSubmit)}> <Grid container spacing={5}> {Object.keys(questions).map(ky => { const answerKey = ky.replace("q", "a"); return ( <Grid item key={ky} xs={12}> <QuestionAnswer question={questions[ky]} questionFieldName={ky} answer={answers[answerKey]} answerFieldName={answerKey} register={register} /> </Grid> ); })} <Grid item xs={12}> <ExtraQuestion /> </Grid> <Grid item xs={12}> <Box textAlign="center"> <Button variant="contained" color="primary" type="submit"> Save </Button> </Box> </Grid> </Grid> </form> </Grid> </Grid> ); }; export default DetailView;
nameに関してはQuestionならq1, q2, ...、Answerならa1, a2, ...とします。そこで多少強引ですが、mapでQuestionのkeyを取得し、ky.replace("q", "a");
によってAnswerのkeyを取得しています。このkeyがそのままフォームのnameになります。
あとはこのnameをQuestion, Answerに設定します。
components > content > detail > QuestionAnswer.jsを以下のように編集します。
import React from "react"; import Question from "./Question"; import Answer from "./Answer"; const QuestionAnswer = props => { const { question, answer, register, answerFieldName, questionFieldName } = props; return ( <React.Fragment> <Question question={question} register={register} name={questionFieldName} /> <Answer answer={answer} register={register} name={answerFieldName} /> </React.Fragment> ); }; export default QuestionAnswer;
Questionを以下のように編集react hook form用にinputRefを設定し、nameも追加します。
import React from "react"; import InputBase from "@material-ui/core/InputBase"; import QuestionBox from "./QuestionBox"; const Question = props => { const { question, register, name } = props; return ( <QuestionBox> <InputBase name={name} label="Q" defaultValue={question} fullWidth inputRef={register} autoComplete="off" /> </QuestionBox> ); }; export default Question;
AnswerもQuestionと同じように編集します。
import React from "react"; import TextField from "@material-ui/core/TextField"; import Box from "@material-ui/core/Box"; const Answer = props => { const { name, answer, register } = props; return ( <Box mt={2}> <TextField name={name} multiline rows="4" defaultValue={answer} variant="outlined" fullWidth inputRef={register} /> </Box> ); }; export default Answer;
これでコンポーネントはOKです。
Action Creator
とってもシンプルです。putメソッドでデータを送信するだけです。
アクセス先の`qa/${id}.json`
のidはSubjectのIDである点には注意して下さい。
export const updateQuestionAnswers = (id, data) => async dispatch => { try { await axios.put(`qa/${id}.json`, data); } catch (error) { dispatch(apiFailed("update question failed")); } };
今回は入力されている値を逐一stateで管理はしないため、これでQ&Aも完了です。
成功のメッセージをまだ実装していないため、一見無反応ですが、処理自体はきちんと動作しています。
リロードすればきちんと保存されていることが確認できます。
これでUpdate処理の実装が完了しました。
バグについて
Subjectの編集(/edit/:id)でSaveボタンを押すと、詳細画面(/detail/:id)に遷移しますが、その際に編集前のデータ(title)が表示されるというバグがあります。
バグの原因
これはSubjectEdit.jsのonSubmitにて以下の処理がされています。
dispatch(updateSubject(data)); history.push("/detail/" + item.id);
ここで問題なのはupdateSubjectの処理が終了する前にhistory.pushが実行され、詳細画面でデータを取得するAPI通信がされてしまうことです。
これはデバッグツールを使うとよくわかります。
このようにupdateSubjectと詳細画面表示のためのgetSubjectが並列で(非同期で)実行されていることがわかります。
それぞれ独立で動いているため、Subjectの更新がされる前に詳細画面でのデータ取得が始まってしまい、古いデータを取得してしまっているわけです。
Redux DevToolsで見てみます。タイトルを「Next Gen JavaScript」から「JavaScript」へ変更してみます。
「UPDATE_SUBJECT」は期待通り動いていて(画面右)、この時点では編集後の「JavaScript」が表示されています。
その後の「GET_SUBJECT」が問題で、古いデータを読み込んだ処理が後で実行されているため、「UPDATE_SUBJECT」で更新されたstateが古いデータ(Next Gen JavaScript)で上書きされてしまっています。
解決策
解決策はいくつかありますが、最もわかりやすい方法はSubjectの更新処理が終わるのを待ってからページ遷移を行うことです。
今回のように非同期通信が終了するのを待ってからページ遷移をするのには一工夫が必要になります。
これに関しては次の記事で書こうと思います。
記事を投稿しました。↓
【React Router】action creator内でページ遷移する方法
React + Redux環境でReact Routerを使っていると、ページ遷移のタイミングで困ることがあると思います。 特に非同期通信を行う場合は、通信が終了したタイミングでページ遷移をしたい場合 ...
続きを見る
続く