はじめに
前回までにドラック&ドロップでタスクの進捗状態を変更する機能が実装できました。
今回はそれに加え完了したタスクを削除する機能、モーダルでタスクの内容を変更する機能を実装しました。
今回実装できた内容
- Redux toolkitを導入してグローバルな状態管理ができるようになった
- 新しい機能実装に対応できるディレクトリ構成を検討できた
- Material UIを導入してModalを実装できた
- onDragとonClickイベントを持つDOM要素のイベントを制御できた
前回までのあらすじ
今までの内容
- Tailwindを導入できた
- ComponentにPropsを渡せた
- Componentの中でPropsを使用できた
- Componentをループを使って表示できた
- React Iconsを導入できた
- useStateを使って状態管理ができた
- setStateを使って状態の更新ができた
- 状態に対するイミュータブル(不変)な操作の必要性が分かった
- 子側で親のイベントをトリガーできた
Next.jsでTrello風タスク管理アプリを作成する日記③
- filterをかけて条件に一致したデータでComponentを表示できた
- Dnd kikを導入できた
- Dnd kikのDndContextとソート機能に関するSortableContextでカラム内ソートを実装できた
Next.jsでTrello風タスク管理アプリを作成する日記④
- Dnd kitのDropableでカラム間を移動した時に対応したidをhandlerに渡す事ができた
- onDragEndとonDragOverを使い分けてhandlerを作成できた
- カラム間を横断するドラック&ドロップアイテムを作成できた
グローバルな状態管理の必要性
Todoに対するCRUD(Create,Read,Update,Delete)処理とModalの表示処理をPropsを利用して各コンポーネント実装しようとするとコンポーネントに対してPropsをリレーして関数を渡す必要があります。
↓色々な状態を変更する色々なコンポーネント
TodoItem:Todo削除、Todo状態変更、モーダル開く
これらの関数が変更する対象(TodoやisModalOpen)をグローバル管理する事でPropsリレーをしない構成にしていきたいと思います。
ライブラリの選択
グローバルな状態管理についてはこちらのサイトを参考にReduxを使おうかと思います。
ReduxはReact が扱う state の管理に特化したライブラリでFluxという状態管理のアーキテクチャに則って設計されています。
以下の図をよく見るのですが、単一のStoreに対してを読み取りから変更まで一方向のフローで行います。
※公式ドキュメントより引用
またこちらReduxの概念についてとても分かりやすい記事でした
Reduxをより簡単に記述できそうだったためRedux toolkitを使用する事にします。
Redux Toolkitについて
こちらの記事でRedux toolkit使用したカウンターアプリを作成する記事を参考にグローバルな状態管理を実装しました。
Redux toolkitはStoreの中にSliceという単位で状態と状態を更新する関数を管理します。
下図がとても分かりやすかったです。
ラクスルエンジニアブログ
ディレクトリ構造の変更
Reduxの導入や実装が進むにつれファイル数が多くなってきたためディレクトリ構造を整理しました。
こちらの方のZenn記事を参考にさせて頂きました。
特徴的なのはFeatureという再利用しないコンポーネントを管理するディレクトリを作成し、Todoに関するTypeやStyle、状態管理のためのReducerを"/feature/todo"に含めた点です。
/features
ある特定の機能、ドメインでしか使わないapiへのアクセサや定数、型、hooks、コンポーネントなど全て>を詰め込む。
Redux Toolkitの導入
Redux toolkit関連も含め拡張性を考慮したディレクトリ構成を知る事ができたのでいよいよReduxを導入していきます。
ライブラリインストール
npm i react-redux @reduxjs/toolkit
Sliceの作成
Todo、Modalに関する値をReduxで管理していきたいと思います。
TodoSliceの作成
ドラック&ドロップで順番も変更するため配列の順番も含め管理します。
現状idと配列内の順番を一致させる事でidをindexのように扱っています。
今後DBに順番情報をどう保存していくかは要検討です。
import { createSlice } from '@reduxjs/toolkit'
export const todoSlice = createSlice({
name: 'todos',
// stateの初期値を設定
initialState: {
todos: [
{
id: '1',
title: 'Reduxで取得する長いタイトルのTodo',
content: 'TODO内容はここに記載します。',
status: 'Done',
},
{
id: '2',
title: 'タイトル2',
content: 'TODO内容の二番目',
status: 'Progress',
},
{
id: '3',
title: 'タイトル3',
content: 'TODO内容の3番目',
status: 'Incomplete',
},
],
},
// stateを更新するreducerを設定
reducers: {
// Dndで順番を変更するため新しいTodo配列をactionで受りStateを更新する
setTodos: (state, action) => {
state.todos = action.payload
},
// 新しいTodoを受け取りStateの配列の対応するTodoを更新する
updateTodo: (state, action) => {
const updateTodo = action.payload
const updatedTodos = state.todos.map((todo) => (todo.id === updateTodo.id ? { ...todo, ...updateTodo } : todo))
state.todos = updatedTodos
},
// 新しいTodoを追加する
addTodo: (state, action) => {
const newTodo = action.payload
newTodo.id = (state.todos.length + 1).toString()
state.todos.push(newTodo)
},
// idを受け取りidに対応するTodoを削除する
removeTodoById: (state, action) => {
const newTodos = state.todos.filter((todo) => todo.id !== action.payload)
/// 番号を振りなおす(AddTodoでは配列数+1のidが割り振られるため欠番があると重複する)
state.todos = newTodos.map((todo, index) => {
return {
...todo,
id: (index + 1).toString(),
}
})
},
},
})
export const { setTodos, updateTodo, addTodo, removeTodoById } = todoSlice.actions
export default todoSlice.reducer
modalSliceの作成
モーダルはTodoの編集用と削除確認用の2つを用意するため、各モーダルのアクティブ状態とモーダルが操作する対象Todoを管理するようにしました。
import { createSlice } from '@reduxjs/toolkit'
export const modalSlice = createSlice({
name: 'modals',
// 各モーダルのアクティブ状態と操作対象Todo
initialState: {
editModalIsOpen: false,
confirmModalIsOpen: false,
todo: {
id: '',
title: '',
content: '',
status: '',
},
},
reducers: {
// 編集モーダルのアクティブ化と対象Todoの保持
openEditModal: (state, action) => {
state.todo = action.payload
state.editModalIsOpen = true
},
// 削除モーダルのアクティブ化と対象Todoの保持
openConfirmModal: (state, action) => {
state.todo = action.payload
state.confirmModalIsOpen = true
},
// 全てのモーダルの非アクティブ化
closeModal: (state) => {
state.confirmModalIsOpen = false
state.editModalIsOpen = false
},
},
})
export const { openEditModal, openConfirmModal, closeModal } = modalSlice.actions
export default modalSlice.reducer
Storeの作成
作成したsliceは以下のようにStoreに登録します。
役割の異なる複数のStoreを利用する場合はTodoStoreなどの名前を付けるのだと思いますが、今回はそのままStoreとしました。
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from '@/features/reduxSample/reducers/counterSlice'
import userReducer from '@/features/reduxSample/reducers/userSlice'
import todoReducer from '@/features/todo/reducers/todoSlice'
import modalReducer from '@/features/todo/reducers/modalSlice'
export const store = configureStore({
reducer: {
todos: todoReducer,
modals: modalReducer,
},
})
Providerを使用する
作成したStoreは下記のようにProviderに渡すことでProviderが含むコンポーネント内でStateやStateを変更するアクションが使用できます。
import TodoListForm from '@/features/todo/components/todoListForm'
import { store } from '@/store/store'
import { Provider } from 'react-redux'
export default function Home() {
return (
<Provider store={store}>
<div className='flex justify-center'>
<div className='m-5 w-auto'>
<TodoListForm />
</div>
</div>
</Provider>
)
}
Reducerの使用方法
基本的にはuseSelectorでstateの読み込み、useDispatchでstateに対する処理を呼び出す感じでした。
例えば、TodoItemのドラッグエンドで順番が変わった時にStateを変更する処理で以下のようにreducerを利用しました。
import { useSelector, useDispatch } from 'react-redux'
// stateからTodoを読み込む
const todos = useSelector((state: RootState) => state.todos.todos)
// グローバルなTodoの更新をローカルが感知し反映できるようにする
useEffect(() => {
setTodoItemList(todos)
}, [todos])
// ↓useEffectを利用してtodoItemListの変更の度にグローバルstateの更新を行う設定を考えましたが
// 循環参照みたいになってしまう気がしたのでDragEndのタイミングで更新処理を行っています。
const [todoItemList, setTodoItemList] = useState<Todo[]>(todos)
const handleDragEnd = (event: DragEndEvent) => {
...省略
//
dispatch(setTodos(newTodos))
}
上記の他、Modalの切り替えModalによるTodo更新の保存、Todoの削除ボタンなどに各種reducerを仕込みました。
Material-Tailwindの導入
Modalを実装する際にカッコいいデザインと動きを使用したいと思いmaterial-tailwindを導入しました。
ライブラリのインストール
npm i @material-tailwind/react
公式サイトでカッコいいモーダルとフォーム部品を探して使用していきます。
Tailwindのようにスタイル定義済のクラスをClass属性に追加していく事でデザインもできますが、基本的にはMaterial UIが提供するコンポーネントを使用し各種Propsでカスタマイズする感じでした。
import {
Button,
Dialog,
DialogHeader,
DialogBody,
DialogFooter,
Input,
} from '@material-tailwind/react'
...
return (
<Dialog className='p-5' open={isOpen} size='xs' handler={handleCloseModalOnClick}>
<DialogHeader className='text-gray-600'>編集</DialogHeader>
<DialogBody>
...
</DialogBody>
<DialogFooter>
<Button variant='text' color='gray' onClick={handleCloseModalOnClick} className='mr-1'>
<span>キャンセル</span>
</Button>
<Button variant='gradient' color='green' onClick={(e) => handleCloseModalOnClick(e, true)}>
<span>保存</span>
</Button>
</DialogFooter>
</Dialog>
)
また準備としてTailwind.config.jsに修正を加えました。
※公式より引用
const withMT = require("@material-tailwind/react/utils/withMT");
module.exports = withMT({
content: ["./pages/**/*.{js,ts,jsx,tsx}"],
theme: {
extend: {},
},
plugins: [],
});
Icon付きSelect要素
どうしてもSelect要素が含むOption要素(選択肢)や選択済のOption要素にIconを付けたりしたかったのですが少し大変でした。
選択状態のOptionをSelect部分に表示しTodoのStatusに合わせ更新したいと思いました。
そのためuseStateとuseEfectを使用しTodoの状態が変われば選択状態のOption要素も変更されるよう実装しました。
// todoのStatus(Done, Progress, Incomplete)に対応する<Option>を描画するための値
const optionStatuses: StatusValues[] = [
{
status: 'Done',
color: 'green',
iconDom: <FaCheckCircle className='w-6 h-6 text-white fill-current' />,
},
{
status: 'Progress',
color: 'blue',
iconDom: <TbProgress className='w-6 h-6 text-white fill-current' />,
},
{
status: 'Incomplete',
color: 'gray',
iconDom: <RiZzzFill className='w-6 h-6 text-white fill-current' />,
},
]
// 上記の各種Optionの値の中で現在Select要素で選択されているものについて状態管理
const [optionStatus, setOptionStatus] = useState<StatusValues>()
// 選択されている値はTodoの状態と一致しているように設定
useEffect(() => {
const setOptionStatuses = optionStatuses.find((status) => status.status === todo.status)
setOptionStatuses && setOptionStatus(setOptionStatuses)
}, [todo])
...
<Select
size='lg'
label='状態'
defaultValue={todo.status}
value={todo.status}
// onChangeでTodoの状態を変更すると、Select要素の選択状態のOptionも変更される
onChange={(status) => handlerTodoStatusOnChange(status)}
// 選択されているoptionStatusを使い選択時のDOM要素を描画
selected={() => (
<div className='flex items-center'>
<span className={`p-1 me-2 border rounded bg-${optionStatus?.color}-500`}>
{optionStatus?.iconDom}
</span>
<span>{optionStatus?.status}</span>
</div>
)}
>
// オプションは選択に関わらず全てのoptionStatusをループで描画
{optionStatuses.map((status) => {
return (
<Option key={status.status} value={status.status}>
<div className='flex items-center'>
<span className={`p-1 me-2 border rounded bg-${status.color}-500`}>{status.iconDom}</span>
<span>{status.status}</span>
</div>
</Option>
)
})}
</Select>
...
Dndのドラック要素にonClickを設定
todoItemはドラック&ドロップで順番入れ替え、クリックで編集モーダルを開く事ができるコンポーネントになっています。
デフォルトではクリックの瞬間ドラックイベントが開始してしまい、クリックイベントが発生しなくなってしまうのですが、下記の記事通りドラックの開始を任意のpx以上の移動に設定する事で解決しました。
useSensorに{ distance: 5 }を設定する事でドラックアイテムの5px以上の移動でしかドラックイベントが発動しないよう設定しました。
onst sensors = useSensors(useSensor(MouseSensor, { activationConstraint: { distance: 5 } }))
まとめ
色々な状態を管理する際アクセスが大変だから全部グローバルにするという安易な考えはダメだと思うので、これからもみんなの記事を見ながらそれをマネします。
今回実装できた内容
- Redux toolkitを導入してグローバルな状態管理ができるようになった
- 新しい機能実装に対応できるディレクトリ構成を検討できた
- Material UIを導入してModalを実装できた
- onDragとonClickイベントを持つDOM要素のイベントを制御できた