TodoアプリをReact Typescript redux-toolkit chakra-uiで作成

#この記事の概要

React Typescript redux-toolkit chakra-uiを用いTodoアプリを実装してみました。
Source code [https://github.com/ryota-ak/todo_app_chakra](https://github.com/ryota-ak/todo_app_chakra)

#実行環境

- Node.js 14.15.1
- React 17.0.2
- typescript 4.1.5

##ディレクトリ構成

```
todoapp
├── node_modules
├── public
├── src
│   ├── App.tsx
│   ├── app
│   │   ├── hooks.ts
│   │   └── store.ts
│   ├── component
│   │   └── Header.tsx
│   ├── features
│   │   └── task
│   │       ├── TaskInput.tsx
│   │       ├── TaskItem.tsx
│   │       ├── TaskList.tsx
│   │       ├── taskSlice.ts
│   │       └── types.ts
│   ├── index.tsx
│   ├── react-app-env.d.ts
│   ├── serviceWorker.ts
│   └── setupTests.ts
├── tsconfig.json
├── package.json
└── yarn.lock
```


#1.環境構築

##create-react-app

```
 $ npx create-react-app todoapp --template redux-typescript
```
redux-typescriptをテンプレートに指定し、Reactの雛形ファイルをインストールします。

##必要なパッケージをインストール

###chakra-ui

```
$ yarn add @chakra-ui/react @emotion/react@^11 @emotion/styled@^11 framer-motion@^4
```
UIはchakra-uiを利用します。
chakra-ui関連のパッケージをインストールします。また、chakra-ui/iconsも使いたいのでインストールしておきます。

```
$ yarn add @chakra-ui/icons
```

#2.taskSliceを作成
状態管理にはredux-toolkitを利用します。createSlice関数を用いてTaskに関するstoreを作成します。


##stateの型を定義

```typescript:types.ts
export interface Task {
  id: number;
  title: string;
  completed: boolean;
}

export interface TaskState {
  tasks: Task[];
  editedTask: Task;
  taskCount: number;
}

```
Reduxで管理するStateの型です。

- tasks => Task型の配列
- editedTask => 編集中のtask
- taskCount => taskを新規作成した時にidを一意に割り当てるための数値

##Stateの初期値を定義

```typescript:taskSlice.ts
export const initialState: TaskState = {
  tasks: [],
  editedTask: { id: 0, title: "", completed: false },
  taskCount: 0,
};
```
editedTaskのidが0の場合はtaskの新規作成となるよう実装します。

##createSlice

```typescript:taskSlice.ts
export const taskSlice = createSlice({
  name: "task",
  initialState,
  reducers: {
    addTask: (state, action: PayloadAction<string>) => {
      const newTask = {
        id: state.taskCount + 1,
        title: action.payload,
        completed: false,
      };
      state.tasks = [...state.tasks, newTask];
      state.taskCount = state.taskCount + 1;
      state.editedTask = initialState.editedTask;
    },
    updateTask: (state, action: PayloadAction<Task>) => {
      state.tasks = state.tasks.map((task) =>
        task.id === action.payload.id ? action.payload : task
      );
      state.editedTask = initialState.editedTask;
    },
    deleteTask: (state, action: PayloadAction<Task>) => {
      state.tasks = state.tasks.filter((task) => task.id !== action.payload.id);
    },
    editTask(state, action: PayloadAction<Task>) {
      state.editedTask = action.payload;
    },
  },
});
```
createSlice関数を用いてtaskSliceを作成します。
reducersの中にstateを更新するためのactionを定義しています。
- addTask => tasksに新たなtaskを追加する関数
- updateTask => tasksの中で指定したtaskを更新する関数
- deleteTask => tasksの中で指定したtaskを消去する関数
- editTask => 編集中のtaskであることを示すeditedTaskを更新する関数


##必要なものをexport

```typescript:taskSlice.ts
export const { addTask, updateTask, editTask, deleteTask } = taskSlice.actions;

export const selectTasks = (state: RootState) => state.task.tasks;
export const selectEditedTask = (state: RootState) => state.task.editedTask;

export default taskSlice.reducer;
```
コンポーネントで使うactionやselectorをexportしておきます。


##storeを作成、ProviderでWrap

```typescript:store.ts
export const store = configureStore({
  reducer: {
    task: taskReducer,
  },
});
```

/src/index.tsをでreduxの状態を保持するためのProviderとchakra-uiを利用するためのChakraProviderでコンポーネントをラッピング

```tsx:index.tsx
ReactDOM.render(
  <React.StrictMode>
    <Provider store={store}>
      <ChakraProvider>
        <App />
      </ChakraProvider>
    </Provider>
  </React.StrictMode>,
  document.getElementById("root")
);
```

これで状態管理の設定は完了です。
この時点でRedux devtoolsはこのようになっています。
![スクリーンショット 2021-08-03 16.08.00.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1789986/69b986db-d506-7d0f-2102-08a5d2a3fbb0.png)


#3.各コンポーネントの作成

##Appコンポーネント

```tsx:App.tsx
const App: VFC = () => {
  return (
    <>
      <Box bg="gray.100" w="100vw" h="100vh">
        <Box w={800} mx="auto" py={10}>
          <Flex direction="column">
            <Header />
            <TaskInput />
            <TaskList />
          </Flex>
        </Box>
      </Box>
    </>
  );
};

```
後述するHeader,TaskInput,TaskListコンポーネントを縦に並べました。

##Headerコンポーネント

```tsx:Header.tsx
const Header = () => {
  const dispatch = useAppDispatch();

  return (
    <Heading
      textAlign="center"
      color="orange.300"
      cursor="pointer"
      onClick={() => dispatch(editTask(initialState.editedTask))}
    >
      TodoApp
    </Heading>
  );
};
```
Headerにはアプリのタイトルとして、TodoApp表示します。また、クリックすると編集中のtaskが初期値となるよう実装しました。

##TaskInputコンポーネント

```tsx:TaskInput.tsx
const TaskInput: VFC = () => {
  const dispatch = useAppDispatch();
  const editedTask = useAppSelector(selectEditedTask);
  const isDisabled = editedTask.title.length === 0;

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    dispatch(editTask({ ...editedTask, title: e.target.value }));
  };

  const handleOnKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Enter" && !isDisabled) {
      e.preventDefault();
      editedTask.id === 0 ? addClicked() : updateClicked();
    }
  };

  const addClicked = () => {
    dispatch(addTask(editedTask.title));
  };

  const updateClicked = () => {
    dispatch(updateTask(editedTask));
  };

  return (
    <Box mt={5} textAlign="center">
      <Flex>
        <Input
          mr={5}
          bg="white"
          placeholder="task"
          type="text"
          value={editedTask.title}
          onChange={handleInputChange}
          onKeyPress={handleOnKeyPress}
        />
        {editedTask.id === 0 ? (
          <Button
            type="submit"
            colorScheme="yellow"
            variant="solid"
            cursor="pointer"
            leftIcon={<AddIcon />}
            disabled={isDisabled}
            onClick={addClicked}
          >
            ADD
          </Button>
        ) : (
          <Button
            type="submit"
            colorScheme="yellow"
            variant="solid"
            cursor="pointer"
            disabled={isDisabled}
            onClick={updateClicked}
          >
            UPDATE
          </Button>
        )}
      </Flex>
    </Box>
  );
};


```
TaskInputコンポーネントでは、taskを新規作成または更新するためのロジックを実装しました。editedTask.idが初期値か否かによって、作成か更新かを分岐させています。

##TaskListコンポーネント

```tsx:TaskList.tsx
const TaskList: VFC = () => {
  const tasks = useAppSelector(selectTasks);
  return (
    <Box bg="white" mt={5} maxH={500} overflowY="scroll">
      {tasks.length > 0 &&
        tasks.map((task) => <TaskItem key={task.id} task={task} />)}
    </Box>
  );
};
```
taskの一覧を表示するコンポーネントです。map関数を使い、TaskItemコンポーネントにpropsとしてtaskを渡しています。

##TaskItemコンポーネント

```tsx:TaskInput.tsx
type Props = {
  task: Task;
};

const TaskItem: VFC<Props> = memo(({ task }) => {
  const dispatch = useAppDispatch();
  const { isOpen, onOpen, onClose } = useDisclosure();

  const handleDelete = () => {
    dispatch(deleteTask(task));
    dispatch(editTask(initialState.editedTask));
    onClose();
  };

  return (
    <>
      <Box p={2} borderBottom="1px" borderColor="gray.200" h={12}>
        <Flex>
          <Checkbox
            mr={4}
            pb={2}
            colorScheme="red"
            checked={task.completed}
            onChange={() => {
              dispatch(updateTask({ ...task, completed: !task.completed }));
            }}
          />
          <Box pt={1} textDecoration={task.completed ? "line-through" : "none"}>
            {task.title}
          </Box>
          <Spacer />
          <IconButton
            aria-label="Search database"
            colorScheme="linkedin"
            variant="outline"
            mr={2}
            size="sm"
            icon={<EditIcon />}
            onClick={() => dispatch(editTask(task))}
          />
          <IconButton
            aria-label="Search database"
            colorScheme="red"
            variant="outline"
            size="sm"
            icon={<DeleteIcon />}
            onClick={onOpen}
          />
        </Flex>
      </Box>
      <Modal isOpen={isOpen} onClose={onClose}>
        <ModalOverlay />
        <ModalContent>
          <ModalBody textAlign="center" mt={2} fontWeight="bold">
            Do you delete task really?
          </ModalBody>

          <ModalFooter>
            <Button mr={3} colorScheme="red" onClick={handleDelete}>
              Yes
            </Button>
            <Button colorScheme="blue" onClick={onClose}>
              No
            </Button>
          </ModalFooter>
        </ModalContent>
      </Modal>
    </>
  );
});

```
taskを表示するコンポーネントです。
左端のcheckboxでtaskが完了したかを管理しています。
次にtaskのタイトルを表示し、右端にはicon属性に`@chakra-ui/icons`からimportした`<EditIcon/>`と`<DeleteIcon/>`を指定した二つの`<IconButton>`を配置しています。
`<EditIcon/>`をクリックするとこのコンポーネント内のtaskを編集中のtaskに指定することができます。
`<DeleteIcon/>`をクリックするとModalが立ち上がり、taskを消去することができます。
また、このコンポーネントはmemo化し不要な再描画を減らしています。

以上で、Todoアプリの完成です。


#4.完成品
##新規作成
![task_add.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1789986/654c8275-2398-de46-45a3-2ef3421ba24f.gif)

##更新
![task_update.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1789986/56ad0286-c048-4acc-30d7-85d09235da3d.gif)


##削除
![task_delete.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1789986/0c64db84-0d75-6333-22bb-cd3a383cedc2.gif)