Souta Tech Blog.

Next.js × TypeScript × Recoil での状態管理を学ぶためのハンズオン投稿日: 2022年 2月22日

経緯

最近まで一つのサービスの開発をしていました。
その過程で最初の段階でしっかりと設計できていなかったこともあり
Propsのバケツリレーの方が多発して最初は大したことなかったものの後々大きくなるにつれてとても管理自体が大変なものになってしまいました。
(このプロダクトの反省点として現在は次に活かそうと思っています。)
その中で本格的に今まで毛嫌いしていた状態管理部分をしっかりと理解しようと思って技術選定をした結果今回のRecoilというライブラリを知ることができたので試してアウトプットとしてハンズオンしたいと思います。
今回はかなりしっかりと書いているのでわからない部分などがもしあればご連絡していただけると幸いです。適時追記します。
※ 自分自身、このライブラリはおろかフロントエンド部分の知識なども乏しい状況です。もし何かアドバイスやおかしいと思った部分などがありましたら教えていただけると幸いです🙇‍♂️

完成コード

Starとフォローをしていただけるとより一層喜びます💭
https://github.com/soutaschool/recoil-app

Let's Startハンズオン!

環境構築

このプロジェクトは Next.js + Typescript + Docker + Recoilです!

Dockerの準備

しっかりDockerさんを使って簡単に環境をセットアップしていきます。
Next.js or CRA(create react app)のどちらかで迷いましたが最近もっぱらNext.jsしか使っていないのでこっちにします!すいません。
Dockerの説明についてはここでは省きます。
ターミナル

$ mkdir recoil-app && cd recoil-app
$ touch Dockerfile


フォルダ作ってDockerfile突っ込みます
Dockerfile

# ここはバージョンは基本的になんでもいいです。自分はなるべく軽量にしたいのでAlpineを使います
FROM node:17-alpine
WORKDIR /usr/src/app


こんな感じで書きました。
次にdocker-composeの方を作ります。
ターミナル

$ touch docker-compose.yml


docker-compose.yml

version: '3'
services:
  recoil-app:
    container_name: recoil-app
    build:
      context: .
      dockerfile: Dockerfile
    volumes:
      - ./app:/usr/src/app
    command: sh -c "yarn dev"
    ports:
      - '3000:3000'



自分はこんな感じで書きました!!

Next.jsの構築

Next.jsのセットアップコマンドを打ちましょう!
ターミナル

$ docker-compose run --rm recoil-app yarn create next-app --typescript

 するとこんなのが出てきます
✔ What is your project named? … app
 と自分は設定しました。



として環境構築をすることができたら
ターミナル

$ docker-compose up -d


として

みたいに表示されたらセットアップ完了です。

Recoilの導入と型定義

早速ライブラリを入れていきましょう!

$ docker-compose run --rm recoil-app yarn add recoil


RecoilRoot

このRecoilRootというものでラッピングした部分が状態管理を扱うことができるようになると思っています。
もしかしたらアプリケーション全体のステート管理するReduxとはここで差別化を図っているかもしれません。。。(間違っていたら教えてください)
今回は簡単なアプリケーションなのでindex.tsxに直接記述していきます。
※ _app.tsxではないことに注意してください!できるとは思いますが自分の場合はここに追加すると
error - Error: This component must be used inside a <RecoilRoot> component.
が出現し、GithubのIssueやドキュメントなども読み返してもあっているはずなのですが今回は自分の方ではIndexの方に追加とさせていただきます。
(もし原因わかる方いましたら教えてください〜〜!)
index.tsx

import { NextPage } from 'next';
import { RecoilRoot } from 'recoil';

interface RootProps {}

const Root: NextPage<RootProps> = () => {
  return (
    <RecoilRoot>
      {/* ここの部分にコンポーネントを追加していく予定です! */}
    </RecoilRoot>
  );
};
export default Root;


これでRecoilRootの追加完了です。
次に、pagesフォルダとstylesフォルダを$ cd app && mkdir srcを実行してsrcファイル内に移動してください!!
srcファイル内はこんな感じにあるはずです。
※ わからなそうなら上記に貼ってあるGithubを見てみてください!

.
├── pages
│   ├── _app.tsx
│   ├── api
│   │   └── hello.ts
│   └── index.tsx
└── styles
    ├── Home.module.css
    └── globals.css


次にsrcディレクトリ$ touch types.tsを実行してみてください!
このファイルは定義する型の集合ファイルです。
types.ts

export type Task = {
  title: string;
  content: string;
};


export type Tasks = Task[];


こんな感じで定義できると思います。

Atoms

次にsrcディレクトリ$ touch atoms.tsを実行してみてください!
このファイルは定義するatomの集合ファイルです。

そもそもAtomとは...?
公式ドキュメント: https://recoiljs.org/docs/basic-tutorial/atoms

自分自身reduxを深く触ったことがないのでどれに似ているとかはわかりません。
例えがなくてごめんなさい!
ただ初期値をファイルにまとめるあたりReduxのStoreでの定義に似ているような気はします。
実際の使い方を踏まえて作成したファイルに記述しましょう!
atoms.ts

import { atom } from 'recoil';
import { Tasks } from './types';


// ここの部分でTypescriptならユニオンにできます。
export const taskListAtom = atom<Tasks>({
  // ここの部分はユニークなキーになることが求められます。
  key: 'TaskList',
  //   ここの部分が初期値にあたります。
  // 例: const [hoge, setHoge] = useState() <- ここです!
  default: [
    {
      title: '夜ご飯を買いに行く',
      content: '二郎ラーメンでニンニクマシマシアブラカラメ',
    },
  ],
});

export const taskTitleFormAtom = atom<string>({
  key: 'TaskTitleForm',
  default: '',
});

export const taskContentFormAtom = atom<string>({
  key: 'TaskContentForm',
  default: '',
});


こんな感じで自分は書きました。

Selectors

次にsrcディレクトリ$ touch selectors.tsを実行してみてください!
このファイルは定義するselectorの集合ファイルです。

そもそもSlectorとは...?
公式ドキュメント: https://recoiljs.org/docs/basic-tutorial/selectors

実際にコンポーネント側に渡すのがSelectorを渡すので
ここの部分で値をどのように整形したいかなどの処理を記述しておく必要があると思っています。
(自分が勉強してて思ったことです!間違っていたら教えていただけると幸いです😭)
実際にここで分岐分だったり値の追加や変更だったりができるのでしっかりとキャッチアップする必要があると思いました。
selectors.ts

import { selector } from 'recoil';
import { taskListAtom } from './atoms';
import { Tasks } from './types';


// ここの部分でTypescriptならユニオンにできます。
export const taskListSelector = selector<Tasks>({
  // ここの部分はユニークなキーになることが求められます。
  key: 'TaskListSelector',
  // getを受け取る関数になっています
  get: ({ get }) => {
    // 先ほどAtomsの方で定義したものを取得しています。
    const taskList: Tasks = get(taskListAtom);
    // 値を返さないと許してくれません
    return taskList;
  },
});


実際に表示していく

ちょっとだけおしゃれにします!

Material UI

最近自分自身めちゃめちゃ難しいと思っているライブラリです。
今回はせっかくなので使っていきます。

公式サイト: https://mui.com

ターミナル

$ docker-compose run --rm recoil-app yarn add @mui/material @emotion/react @emotion/styled @mui/icons-material


こちらの方をインストールしてみてください!

実際にコンポーネントを作成していく

次にsrcディレクトリ$ mkdir components && cd components && touch TaskList.tsxを実行してみてください!
TaskList.tsx

import CheckIcon from '@mui/icons-material/Check';
import {
  Avatar,
  List,
  ListItem,
  ListItemAvatar,
  ListItemText,
} from '@mui/material';
import { NextPage } from 'next';
import { useRecoilValue } from 'recoil';
import { taskListSelector } from '../selectors';
import { Task, Tasks } from '../types';


interface TasKListProps {}


const TasKList: NextPage<TasKListProps> = () => {
  const taskList: Tasks = useRecoilValue<Tasks>(taskListSelector);
  return (
    <>
      <p>残っているタスク一覧</p>
      <List sx={{ width: '100%', maxWidth: 720, bgcolor: 'background.paper' }}>
        {taskList.map((task: Task, index: number) => (
          <ListItem key={`${task.title}_${index}`}>
            <ListItemAvatar>
              <Avatar>
                <CheckIcon />
              </Avatar>
            </ListItemAvatar>
            <ListItemText primary={task.title} secondary={task.content} />
          </ListItem>
        ))}
      </List>
    </>
  );
};


export default TasKList;


useRecoilValue

公式ドキュメント: https://recoiljs.org/docs/api-reference/core/useRecoilValue

このフックはデータの読み取りのみの場合に使用するフックとなっています。
もし変更があった際にコンポーネントを再レンダリングする作用を持っていて
読み取り専用と書き込み専用の両方に対応しているのが特徴です!!

実際にタスクを追加していくコンポーネントを作成する

次にsrcディレクトリ$ mkdir components && cd components && touch TaskForm.tsxを実行してみてください!
TaskFormtsx

import { FormControl, FormHelperText, Input, InputLabel } from '@mui/material';
import { NextPage } from 'next';
import { ChangeEvent, useCallback } from 'react';
import { SetterOrUpdater, useRecoilValue, useSetRecoilState } from 'recoil';
import { taskContentFormAtom, taskTitleFormAtom } from '../atoms';


interface TaskFormProps {}


const TaskForm: NextPage<TaskFormProps> = () => {
  // stateの値を取得
  const taskTitleValue: string = useRecoilValue<string>(taskTitleFormAtom);
  const taskContentValue: string = useRecoilValue<string>(taskContentFormAtom);
  // setStateの部分の処理
  const setTaskTitleValue: SetterOrUpdater<string> =
    useSetRecoilState<string>(taskTitleFormAtom);
  const setTaskContentValue: SetterOrUpdater<string> =
    useSetRecoilState<string>(taskContentFormAtom);


  // 値の変更処理
  const onChangeFormTitleValue = useCallback(
    (event: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
      setTaskTitleValue(event.target.value);
    },
    [setTaskTitleValue]
  );
  const onChangeFormContentValue = useCallback(
    (event: ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
      setTaskContentValue(event.target.value);
    },
    [setTaskContentValue]
  );
  return (
    <>
      <FormControl>
        <InputLabel htmlFor="my-input">タスク名</InputLabel>
        <Input
          id="my-input"
          aria-describedby="my-helper-text"
          type="text"
          value={taskTitleValue}
          onChange={(event) => onChangeFormTitleValue(event)}
          placeholder="自動販売機に行く"
        />
        <FormHelperText id="my-helper-text">
          項目を記述してください!
        </FormHelperText>
      </FormControl>{' '}
      <br />
      <FormControl>
        <InputLabel htmlFor="my-input">内容</InputLabel>
        <Input
          id="my-input"
          aria-describedby="my-helper-text"
          type="text"
          value={taskContentValue}
          onChange={(event) => onChangeFormContentValue(event)}
          placeholder="今日の気分はコカコーラ"
        />
        <FormHelperText id="my-helper-text">
          内容を記述してください!
        </FormHelperText>
      </FormControl>
    </>
  );
};


export default TaskForm;


useSetRecoilState

公式ドキュメント: https://recoiljs.org/docs/api-reference/core/useSetRecoilState

このフックはデータの読み取りを行わずに状態に書き込む場合に使用するフックとなっています。
Reactのステート管理に触れたことがある人なら共感いただけると思いますが
簡単にいうとSetState関数の部分を作成するためのフックと自分の方は思っています。(間違っていたらアドバイスください!)
こちらもアトムまたはセレクターが更新されたときに役立つものが多いのでわかりやすい仕様になっていると思います。

最後に保存するためのボタンを作成する

次にsrcディレクトリ$ mkdir components && cd components && touch Task Button.tsxを実行してみてください!
TaskButton.tsx

import { Button } from '@mui/material';
import { NextPage } from 'next';
import { useCallback } from 'react';
import { SetterOrUpdater, useRecoilValue, useSetRecoilState } from 'recoil';
import { taskContentFormAtom, taskListAtom, taskTitleFormAtom } from '../atoms';
import { Tasks } from '../types';


interface TaskButtonProps {}


const TaskButton: NextPage<TaskButtonProps> = () => {
  const taskList: Tasks = useRecoilValue<Tasks>(taskListAtom);
  const taskTitleFormValue: string = useRecoilValue<string>(taskTitleFormAtom);
  const taskContentFormValue: string =
    useRecoilValue<string>(taskContentFormAtom);
  const setTaskList: SetterOrUpdater<Tasks> =
    useSetRecoilState<Tasks>(taskListAtom);
  const setTaskTitleFormValue: SetterOrUpdater<string> =
    useSetRecoilState<string>(taskTitleFormAtom);
  const setTaskContentFormValue: SetterOrUpdater<string> =
    useSetRecoilState<string>(taskContentFormAtom);


  const onClickFormValue = useCallback(() => {
    // スプレッド構文を用いて追加する
    setTaskList([
      ...taskList,
      { title: taskTitleFormValue, content: taskContentFormValue },
    ]);
    // 値を初期化
    setTaskTitleFormValue('');
    setTaskContentFormValue('');
  }, [
    setTaskList,
    setTaskContentFormValue,
    taskList,
    setTaskTitleFormValue,
    taskContentFormValue,
    taskTitleFormValue,
  ]);


  return <Button onClick={() => onClickFormValue()}>タスクを追加する</Button>;
};


export default TaskButton;



ここの部分は特に解説することはありません!!!
今までの復習だと思います。

最後にコンポーネントを一つにまとめる

ここまで本当にお疲れ様でした!
かなりのコード量になったと思いますが最後に今までのコンポーネントを結合しましょう。(これが楽しい)
pages/index.tsx

import { NextPage } from 'next';
import { RecoilRoot } from 'recoil';
import TaskButton from '../components/TaskButton';
import TaskForm from '../components/TaskForm';
import TasKList from '../components/TaskList';


interface RootProps {}


const Root: NextPage<RootProps> = () => {
  return (
    <RecoilRoot>
      <TaskForm />
      <TasKList />
      <TaskButton />
    </RecoilRoot>
  );
};

export default Root;

こんな感じになると思います!!!!!
完成系の画面としては

こんな感じのUIのものになっていると思います。
実はこれ以外に使いやすいものになっていてデザイン的にも悪くないと思うのでもしよかったら使ってみてください!
(Stateなのでリロードでそこは注意✋)
もし何かありましたら上記の方にじぶんのGithubまたはDMなのでもいいので何なりと言ってください!!

感想

まずかなりAPIリファレンスがドキュメントとして完成されているように感じました。(日本語訳でもしっかり読めた!?)
やはりPropsリレーにならないという恩恵が今回の学習を通して自分自身は感じることができました。。。
値の変更やコンポーネントが増えていけばいくほど便利になるとあたらめて実感することができたと思います!!
TypeScriptなら型もしっかりと定義することができるので安心して開発をすることができるだけではなく開発体験が非常に向上するので相性がいいと思いました。
しかし、自分がまだまだ簡略化できていない部分が多いですが、無駄な部分も多いと思うのでさらにコードの質も高めていければと思います。
ついでにですが、React Hook Formというライブラリがあるのですがこれめっちゃフォーム作るときにはバリデーションとかもかけてくれるんで便利です。(使えばよかった笑)
今までプロダクトの大きさ問わず状態管理ライブラリというものを置き去りにしてきた部分がとてもあるのでしっかりとこれからはキャッチアップしてできる部分はアウトプットしていこうと思います。
自分自身現在プロダクトを作っていてまだまだ至らない部分も多いですがRedux含めRecoilなのかどうかという部分をしっかりと技術選定して導入していきたいです。
(もちろん今はバケツリレー状態になっています💦)
また、もしよければSNSも最近始めたのでフォローしていただけると幸いです。

  1. Acvation → こちらから
  2. Twitter → こちらから
  3. Instagram → こちらから
  4. Slack(技術者向け) → こちらから

是非是非質問やアドバイスなどもありましたらお願い申し上げます。。。