Next.js✖️RecoilによるTODOアプリハンズオン


どうもこんにちは、たくびー(@takubii)です。
今回はNext.jsとRecoilを使ったTODOアプリの作成を行ったので、その復習として記事に残したいと思います。

Next.jsは最近実装されたApp Routerをベースにしています。
簡単にライブラリの情報などを以下に記載します。
- Next.js 13.4.8
- recoil 0.7.7
- tailwindcss 3.3.2
- typescript 5.1.6

# インストール

## Next.jsのインストール
インストールには`create-next-app`を使いました。各種設定などは以下の通りです。
今回はTailwind CSSにも挑戦しようと思い、インストールに含めています。

```zsh:terminal
npx create-next-app@latest nextjs-recoil-todo
Need to install the following packages:
  create-next-app@13.4.8
Ok to proceed? (y) y
✔ Would you like to use TypeScript with this project? … No / Yes       → Yes
✔ Would you like to use ESLint with this project? … No / Yes           → Yes
✔ Would you like to use Tailwind CSS with this project? … No / Yes     → Yes
✔ Would you like to use `src/` directory with this project? … No / Yes → Yes
✔ Use App Router (recommended)? … No / Yes                             → Yes
✔ Would you like to customize the default import alias? … No / Yes     → No
```

## Recoilのインストール
Recoilのインストールはシンプルに以下のコマンドのみでいけました。
最初のステップが簡単だと導入しやすくていいと感じます。

```zsh:terminal
npm install recoil
```

# Recoilを使うための準備
Recoilを使うためには一番トップのコンポーネントに`RecoilRoot`というコンポーネントでラップする必要があります。
今回の場合、`src/app/layout.tsx`に追加します。

```typescript:src/app/layout.tsx
'use client';

import { Inter } from 'next/font/google';
import { RecoilRoot } from 'recoil';
import './globals.css';

const inter = Inter({ subsets: ['latin'] });

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang='en'>
      <body className={inter.className}>
        <RecoilRoot>{children}</RecoilRoot>
      </body>
    </html>
  );
}

```
※`RecoilRoot`を使うためには、`use client`宣言しクライアントサイドレンダリングにする必要があります。

その他、`src/app/page.tsx`、`src/app/global.css`の内容は必要ないので削除します。

```typescript:src/app/page.tsx
export const metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
};

export default function Home() {
  return (
    <main>
      <div className='container mx-auto max-w-md'>
      </div>
    </main>
  );
}

```

```css:src/app/global.css
@tailwind base;
@tailwind components;
@tailwind utilities;

```

# atomの作成
Recoilの状態管理の核である、`atom`を作成します。

```typescript:src/atoms/todoState.ts
import { atom } from 'recoil';

export type Todo = {
  id: number;
  title: string;
  isCompleted: boolean;
};

export const todoState = atom<Todo[]>({
  key: 'todoState',
  default: [
    {
      id: 1,
      title: 'test',
      isCompleted: false,
    },
  ],
});

```

`atom`はRecoilの`atom`関数によって作成されます。
`atom`内には`key`と`default`というパラメーターがあり、`key`には一意の値を与える必要があります。
`default`は型にあった空のオブジェクトや配列でもいいのですが、今回は初回の確認に便利なようにデフォルト値を設定しました。

# TODOアプリで使用するコンポーネントの作成
今回作成するコンポーネントは`AddTodo.tsx`、`TodoItem.tsx`、`TodoList.tsx`の3つを作成したいと思います。
それぞれ順にソースコードを実装します。

```typescript:src/components/AddTodo.tsx
'use client';

import { useState } from 'react';
import { useSetRecoilState } from 'recoil';

import { Todo, todoState } from '@/atoms/todoState';

export const AddTodo: React.FC = () => {
  const [input, setInput] = useState('');
  const setTodos = useSetRecoilState(todoState);

  const addTodo = () => {
    if (input === '') {
      return;
    }

    setTodos((oldTodos) => [
      ...oldTodos,
      { id: getId(oldTodos), title: input, isCompleted: false },
    ]);
    setInput('');
  };

  const getId = (todos: Todo[]) => (todos.length > 0 ? todos[todos.length - 1].id + 1 : 1);

  return (
    <div className='flex mt-4 mb-4'>
      <input
        className='border rounded w-full py-2 px-3 mr-4 text-gray-500'
        type='text'
        value={input}
        onChange={(e) => setInput(e.target.value)}
      />
      <button
        className='flex-no-shrink p-2 border-2 rounded text-blue-400 border-blue-400 hover:text-white hover:bg-blue-400'
        onClick={addTodo}
      >
        Add
      </button>
    </div>
  );
};

```

```typescript:src/components/TodoItem.tsx
'use client';

import { useRecoilState } from 'recoil';

import { Todo, todoState } from '@/atoms/todoState';
import Link from 'next/link';

export type TodoItemProps = {
  item: Todo;
};

export const TodoItem: React.FC<TodoItemProps> = ({ item }) => {
  const [todos, setTodos] = useRecoilState(todoState);

  const toggleItem = (item: Todo) => {
    setTodos(
      todos.map((todo) =>
        todo.id === item.id ? { ...todo, isCompleted: !todo.isCompleted } : todo
      )
    );
  };

  const deleteItem = (item: Todo) => {
    setTodos(todos.filter((todo) => todo.id !== item.id));
  };

  return (
    <div className='flex mb-4 items-center'>
      <input
        className=''
        type='checkbox'
        checked={item.isCompleted}
        onChange={() => toggleItem(item)}
      />
      <Link
        href={`/todos/${item.id}`}
        className={`w-full text-lg justify-center ml-4 text-gray-500 hover:text-gray-700 ${
          item.isCompleted ? 'line-through text-green-500 hover:text-green-700' : ''
        }`}
      >
        {item.title}
      </Link>
      <button
        className='p-2 text-lg ml-2 text-red-500 hover:text-red-700'
        onClick={() => deleteItem(item)}
      >
        &times;
      </button>
    </div>
  );
};

```

```typescript:src/components/TodoList.tsx
'use client';

import { useRecoilValue } from 'recoil';

import { todoState } from '@/atoms/todoState';
import { TodoItem } from './TodoItem';

export const TodoList: React.FC = () => {
  const todos = useRecoilValue(todoState);

  return (
    <>
      {todos.map((todo) => (
        <TodoItem key={todo.id} item={todo} />
      ))}
    </>
  );
};

```

いずれのコンポーネントもRecoilの関数を使っているので`use client`宣言が必要になります。
ここではいくつかのRecoilのAPIを使用したので、簡単に解説したいと思います。

`TodoItem.tsx`で使用した`useRecoilState`は使用感は`useState`にかなり近いです。
引数にatomを渡すことでStateの値とセッターを使うことができます。

`AddTodo.tsx`で使用した`useSetRecoilState`はatomを渡すことでセッターのみを取得できます。
値を使わず変更したい場合などに有効です。

`TodoList.tsx`で使用した`useRecoilValue`はStateの値のみを取得できます。
こちらは`useSetRecoilState`とは逆に変更せずに値のみを使いたい場面で有効です。

TODOアプリのために実装したのコンポーネントを`src/app/page.tsx`に実装したいと思います。

```typescript:src/app/page.tsx
import { AddTodo } from '@/components/AddTodo';
import { TodoList } from '@/components/TodoList';

export const metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
};

export default function Home() {
  return (
    <main>
      <div className='container mx-auto max-w-md'>
        <AddTodo />
        <TodoList />
      </div>
    </main>
  );
}

```

# selectorを追加する
Recoilには`atom`の他に`selector`と呼ばれる`atom`や他の`selector`からなんらかの計算を行い新たなStateを作成する関数を定義できるものがあります。
説明を聞いてもピンとこないと思いますので、実際にどう使われるのか見ていきます。

```typescript:src/selectors/todoListStatsState.ts
import { todoState } from '@/atoms/todoState';
import { selector } from 'recoil';

export const todoListStatsState = selector({
  key: 'todoListStatsState',
  get: ({ get }) => {
    const todoList = get(todoState);
    const totalNum = todoList.length;

    return totalNum;
  },
});

```

`selector`にも`atom`同様に一意の`key`を設定する必要があります。
`get`には関数が実装されており、引数のgetを使い他の`atom`の値を取得することができます。
そこで取得した値を使用した新たに計算された値をリターンしています。
ここでは、todoListの合計値を返す`selector`を実装しました。

合計数を表示できるようになったので、`Header`コンポーネントを作成し、タイトルと合計値を表示させることにしましょう。

```typescript:src/components/Header.tsx
'use client';

import { todoListStatsState } from '@/selectors/todoListStatsState';
import { useRecoilValue } from 'recoil';

export const Header: React.FC = () => {
  const totalNum = useRecoilValue(todoListStatsState);

  return (
    <div className='text-center'>
      <h1 className='text-2xl font-bold text-slate-500'>TODO LIST</h1>
      <p>登録数：{totalNum}</p>
    </div>
  );
};

```

selectorも`useRecoilValue`などの`atom`と同じAPIを使用して値を取り出せます。
こちらのコンポーネントも`src/app/page.tsx`に追加しましょう。

```typescript:src/app/page.tsx
import { AddTodo } from '@/components/AddTodo';
import { Header } from '@/components/Header';
import { TodoList } from '@/components/TodoList';

export const metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
};

export default function Home() {
  return (
    <main>
      <div className='container mx-auto max-w-md'>
        <Header />
        <AddTodo />
        <TodoList />
      </div>
    </main>
  );
}

```

# App Routerによるルーティングの追加
App Routerを使用しているので各TODOをクリックしたら詳細に飛べるようにしてみたいと思います。
`TodoItem.tsx`ではすでに`Link`コンポーネントを使用してページへ飛べる処理を追加済みです。
あとは、ルーティング先のコンポーネントを作成するだけで実装はできます。

`src/app`に`todos/[id]`というフォルダを作成します。
`[id]`フォルダの中に`page.tsx`を作成し、以下のように記載してください。

```typescript:src/app/todos/[id]/page.tsx
'use client';

import Link from 'next/link';
import { useRecoilValue } from 'recoil';

import { todoState } from '@/atoms/todoState';

export default function Page({ params }: { params: { id: string } }) {
  const todos = useRecoilValue(todoState);
  const todo = todos.find((todo) => todo.id === Number(params.id));

  return (
    <main className='container mx-auto max-w-md'>
      <h1
        className={`mt-2 mb-2 text-lg ${
          todo?.isCompleted ? 'line-through text-green-500' : 'text-slate-500'
        }`}
      >
        {todo?.title}
      </h1>
      <Link href='/' className='text-blue-400 hover:text-blue-600 '>
        back
      </Link>
    </main>
  );
}

```

`[id]`というフォルダ名から`Page`コンポーネントに`id`という引数が渡されるので、そちらを使い特定のTODOを検索し、表示しています。
詳細といってもタイトルを表示しているだけですが、これでNext.jsのApp Routerがどのようにルーティングを行っているのか確認できたかと思います。
詳しくは公式ドキュメントを参照していただけると幸いです。

https://nextjs.org/docs/app/building-your-application/routing

# 終わりに
Recoilの勉強がてら最近個人ブームのNext.jsを使ってTODOアプリを作成してみました。
RecoilはMetaが作成しているState管理ライブラリだけあって、かなり既存のReactフックに近い使用感でした。
導入や使い方が簡単で多くのStateを管理する必要がない場合には、選定優先度は高いのではないかと感じています。

今回作成したアプリのコードはこちらのGithubに載っていますので、よかったらご覧ください。

https://github.com/takubii/nextjs-recoil-todo

それでは今回はこのあたりで締めたいと思います。
ここまで読んでくださりありがとうございます。
また機会があればお会いしましょう。