【React / TypeScript】Recoil での状態管理サンプルアプリ

# はじめに
以前``QiitaAPI``を使ったこんなアプリ（↓↓下記記事）を作成しました。そのアプリを``Recoil``を使って少し拡張してみました。
[【React / TypeScript】axios と QiitaAPI を使ったサンプルアプリ](https://qiita.com/tkmd35/items/6ab8c4e6e37178f81472)

ディレクトリ構成とか諸々、前の記事の状態から原型とどめてない状態になった。笑
だいぶこの記事も冗長になってしまいましたが、お手柔らかにお願いします。

何かあれば気軽にコメントいただければ非常に嬉しいです！

#### Recoil
Recoilの導入方法などはこちらから。
https://recoiljs.org/docs/introduction/getting-started

また``Recoil``の使い方について簡単にまとめた記事でも書けたらいいなー、と思っております。

#### 使用技術
ざっくりとこのような感じ

- Next.js
- TypeScript
- Recoil
- axios
- Tailwind CSS

一応このプロジェクトのソースコード↓↓
https://github.com/TomoyukiMatsuda/React-pra/tree/next-sample/axios-recoil/next-sample

# 作ったアプリ
## アプリ概要
**できること**

- Qiita記事を検索キーワードで検索して表示する
- 検索履歴キーワードが残り、表示される
- 検索履歴キーワードごとのQiita記事検索結果を確認できる
- 検索結果のQiita記事をLGTM数で絞り込みができる

**２ページの構成**

- Qiita記事検索ページ
- Qiita記事絞り込みページ（LGTM数に応じて）


## Gif動画

![qiita-recoil-sample.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621162/65802fea-f8ef-a2da-ad9b-0932634a427e.gif)

画質悪くてすみません。



# ディレクトリ構成
``src``フォルダで管理。
![スクリーンショット 2021-09-23 7.39.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621162/6044fcb4-1f36-126d-12ba-fb9334e185b2.png)




ディレクトリの中身をもう少し細かく見てみるとこんな感じ。
![スクリーンショット 2021-09-23 7.40.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/621162/37c1c668-579f-0ffb-cfe6-370968ab0bd4.png)


# 各ファイルのコード

## 型定義（types/QiitaItem.ts）
（正直「レスポンスの型」と「アプリ内で利用するための型」分ける必要なかったかもしれない、）

```src/types/QiitaItem.tsx
// アプリ内で利用するためのQiita記事型定義
export type QiitaItem = Pick<
  QiitaItemResponse,
  "id" | "title" | "likes_count" | "user"
>;

// Qiita Api レスポンスの型定義
export interface QiitaItemResponse {
  rendered_body: string;
  body: string;
  coediting: boolean;
  comments_count: number;
  created_at: string;
  group: {
    created_at: string;
    description: string;
    name: string;
    private: boolean;
    updated_at: string;
    url_name: string;
  };
  id: string;
  likes_count: number;
  private: boolean;
  reactions_count: number;
  tags: [
    {
      name: string;
      versions: string[];
    }
  ];
  title: string;
  updated_at: string;
  url: string;
  user: {
    description: string;
    facebook_id: string;
    followees_count: number;
    followers_count: number;
    github_login_name: string;
    id: string;
    items_count: number;
    linkedin_id: string;
    location: string;
    name: string;
    organization: string;
    permanent_id: number;
    profile_image_url: string;
    team_only: boolean;
    twitter_screen_name: string;
    website_url: string;
  };
  page_views_count: number;
  team_membership: {
    name: string;
  };
}
```

## ApiClient（lib/apiClient.tsx）
``apiClient``には``axios``を利用。ここで基本的な設定をしておく。
[axios](https://github.com/axios/axios#instance-methods)：こちらに詳しく載っている

- ``baseURL:``APIを叩くときの基本となる url を設定
- ``responseType:``レスポンスの形式を設定（ ``arraybuffer``, ``document``, ``json``, ``text``, ``stream``が指定できるっぽい、ほとんどの場合``json``を指定すると思いますが、、 ）
- ``headers:`` HTTPヘッダーを指定
- ``params:`` 送信するリクエストパラメータを指定。今回はAPI叩くタイミング``hooks/useListQiitaArticles.ts``の中で設定してます。（今回叩くAPI[/api/v2/items](https://qiita.com/api/v2/docs#get-apiv2items)には``page`` ``per_page`` ``query`` を付与できる）

```src/lib/apiClient.tsx
import axios from "axios";

export const apiClient = axios.create({
  baseURL: "https://qiita.com/api/v2",
  headers: {
    "Content-Type": "application/json",
  },
  responseType: "json",
});
```

## グローバルなステート管理（grobalStates/...）
ここで``Recoil``を使う。
今回は３つの``Atom``と2つの``Selector``を用意。

**Atom**

- ``articleListAtom.ts:``検索キーワードごとのQiita記事リストの状態を保持する
- ``searchWordsAtom.ts:``検索キーワード履歴の状態を保持する
- ``minLikesCountAtom.ts:``Qiita記事絞り込みに利用するLGTM数の状態を保持する

**Selector**

- ``articleListSelector.ts:``Qiita記事リストを加工して返す(``get``)、更新する(``set``)セレクタ-
  - ``get:``検索キーワードごとのQiita記事リストをLGTM数に応じて動的に返す
  - ``set:``検索キーワードごとのQiita記事リストを``Atom``にセットする（更新する）
- ``searchWordsSelector.ts:``検索キーワード履歴を加工して返す(``get``)、更新する(``set``)セレクタ-
  - ``get:``ここでは検索キーワードのリストを``Atom``からそのまま返す（特に加工処理せず）
  - ``set:``「検索キーワードが重複しない」かつ「新しいキーワードが配列の先頭に来るように」``Atom``にセット（更新する）

### Atom / Selector を管理するキーファイル（grobalStates/recoilKeys.ts）
``Atom``/``Selector``のキーを一元管理。

```src/grobalStates/recoilKeys.ts
// Atomを管理するキー
export enum AtomKeys {
  ARTICLE_LIST_STATE = "articleListState",
  SEARCH_WORDS_STATE = "searchWordsState",
  MIN_LIKES_COUNT_STATE = "minLikesCountState",
}

// Selectorを管理するキー
export enum SelectorKeys {
  ARTICLE_LIST_SELECTOR = "articleListSelector",
  SEARCH_WORDS_SELECTOR = "searchWordsSelector",
}
```

### Qiita記事リストのステート管理
#### articleListAtom.ts
``articleListAtom.ts:``検索キーワードごとのQiita記事リストの状態を保持する

``atomFamily``を使うことで、``state``を利用するときに引数を受け取ることができる。
そして、その引数からユニークなキーを自動的に生成してくれる。
**同じ``atomFamily``を用いて同じ型を持つ別の値の取得・更新ができる。**

```ts
// atomFamily<stateの型, 引数の型>
atomFamily<QiitaItem[], string>
```
今回は利用するとき（``selector``で``get``or``set``するとき）に「検索キーワード」を引数に渡すことで **「検索キーワード」ごとの``state``管理を可能にする。**

```src/grobalStates/atoms/articleListAtom.ts
import { atomFamily } from "recoil";
import { QiitaItem } from "../../types/QiitaItem";
import { AtomKeys } from "../recoilKeys";

// searchArticleList（Qiita記事リスト）のAtom（データストア）
export const articleListState = atomFamily<QiitaItem[], string>({
  key: AtomKeys.ARTICLE_LIST_STATE,
  default: [],
});
```

#### articleListSelector.ts
- ``articleListSelector.ts:``Qiita記事リストを加工して返す(``get``)、更新する(``set``)セレクタ-
  - ``get:``検索キーワードごとのQiita記事リストをLGTM数に応じて動的に返す
  - ``set:``検索キーワードごとのQiita記事リストを``Atom``にセットする（更新する）

```ts
// selectorFamily<stateの型, 引数の型>
selectorFamily<QiitaItem[], string>
```

今回は``selector``を利用するときに
**「検索キーワード」を引数に渡す→それを``atomFamily``に渡す（``articleListState(id)``）ことで「検索キーワード」ごとの``state``管理を可能にしている。**

```src/grobalStates/selectors/articleListSelector.ts
import { selectorFamily } from "recoil";
import { QiitaItem } from "../../types/QiitaItem";
import { articleListState } from "../atoms/articleListAtom";
import { minLikesCountState } from "../atoms/minLikesCountAtom";
import { SelectorKeys } from "../recoilKeys";

// searchArticleList（Qiita記事リスト）のSelector（データ加工所）だよという宣言
export const articleListSelector = selectorFamily<QiitaItem[], string>({
  key: SelectorKeys.ARTICLE_LIST_SELECTOR,
  get:
      // 引数 id には検索キーワードを渡す
      (id) =>
      ({ get }) => {
        // get(articleListState(id)) で検索キーワードに紐づくstate(Qiita記事リスト）を取得
        return get(articleListState(id)).filter((item) => {
          // LGTM数でフィルタリングした値を返す
          return item.likes_count >= get(minLikesCountState);
        });
      },
  set:
    // 引数 id には検索キーワードを渡す
    (id) =>
    ({ set }, newValue) => {
      // articleListState(id)を第一引数に指定することで、idをユニークなキーとしたstateをセットする
      set(articleListState(id), newValue);
    },
});
```

### 検索キーワードのステート管理
#### searchWordsAtom.ts
``searchWordsAtom.ts:``検索キーワード履歴の状態を保持する
こちらには``atomFamily``ではなく``atom``を使う。

```ts
// atom<stateの型>
atom<string[]>
```

```src/grobalStates/atoms/searchWordsAtom.ts
import { atom } from "recoil";
import { AtomKeys } from "../recoilKeys";

// 「検索キーワード」のAtom（データストア）
export const searchWordsState = atom<string[]>({
  key: AtomKeys.SEARCH_WORDS_STATE,
  default: [],
});
```

#### searchWordsSelector.ts

- ``searchWordsSelector.ts:``検索キーワード履歴を加工して返す(``get``)、更新する(``set``)セレクタ-
  - ``get:``ここでは検索キーワードのリストを``Atom``からそのまま返す（特に加工処理せず）
  - ``set:``「検索キーワードが重複しない」かつ「新しいキーワードが配列の先頭に来るように」``Atom``にセット（更新する）

```src/grobalStates/selectors/searchWordsSelector.ts
import { DefaultValue, selector } from "recoil";
import { searchWordsState } from "../atoms/searchWordsAtom";
import { SelectorKeys } from "../recoilKeys";

export const searchWordsSelector = selector<string[]>({
  key: SelectorKeys.SEARCH_WORDS_SELECTOR,
  get: ({ get }) => {
    return get(searchWordsState);
  },
  set: ({ set }, newValue) => {
    if (newValue instanceof DefaultValue) return; // 型がDefaultValueであれば return
    // 重複を防いだ値をセットする
    set(searchWordsState, (currVal) => {
      return Array.from(new Set<string>([...newValue, ...currVal]));
    });
  },
});
```

#### LGTM数のステート管理
``minLikesCountAtom.ts:``Qiita記事絞り込みに利用するLGTM数の状態を保持する
LGTM数のステート管理には``selector``は用意せず、``atom``の値を直接参照する。
(※特に``selector``にて値を加工する必要がないため）

```src/grobalStates/atoms/minLikesCountAtom.ts
import { atom } from "recoil";
import { AtomKeys } from "../recoilKeys";

// 絞り込みに利用する「最低LGTM数」のAtom（データストア）
export const minLikesCountState = atom<number>({
  key: AtomKeys.MIN_LIKES_COUNT_STATE,
  default: 0,
});
```


## API処理のhooks（hooks/useListQiitaArticles.ts）
APIを叩くフェッチ処理のカスタムフック。
以下をの５つを返す。

- ``articles：`` 取得したQiita記事リスト
- ``searchWord：`` 検索キーワード
- ``errorMessage：`` エラーレスポンスメッセージ
- ``isLoading：`` APIを叩いているときにローディング中かどうかを判断するフラグ
- ``fetchArticles：`` API[（/api/v2/items）](https://qiita.com/api/v2/docs#get-apiv2items)を叩く関数

**API叩く処理**
今回は
HTTPメソッドがGETで
この↓↓urlでリクエストを送信している
``https://qiita.com/api/v2/items?per_page=25&query=検索キーワード``

例） 検索ワード（query: formText）が「react」の場合
``https://qiita.com/api/v2/items?per_page=25&query=react``

```src/hooks/useListQiitaArticles.ts
import {
  Dispatch,
  FormEvent,
  SetStateAction,
  useCallback,
  useState,
} from "react";
import { apiClient } from "../lib/apiClient";
import { QiitaItem, QiitaItemResponse } from "../types/QiitaItem";
import { useSetRecoilState } from "recoil";
import { searchWordsSelector } from "../grobalStates/selectors/searchWordsSelector";

export const useListQiitaArticles = () => {
  const setSearchWords = useSetRecoilState(searchWordsSelector);
  const [articles, setArticles] = useState<QiitaItem[]>([]);
  const [searchWord, setSearchWord] = useState("");
  const [errorMessage, setErrorMessage] = useState<string>("");
  const [isLoading, setIsLoading] = useState(false);

  const fetchArticles = useCallback(
    async (
      e: FormEvent<HTMLFormElement>,
      formText: string,
      setFormText: Dispatch<SetStateAction<string>>
    ) => {
      e.preventDefault(); // フォームのデフォルトの動作（リロード）を防ぐ
      setIsLoading(true); // ローディング開始
      setErrorMessage(""); // エラーメッセージを初期化

      await apiClient
        .get<QiitaItemResponse[]>("/items", {
          params: {
            query: formText, // フォーム入力を検索ワードとして設定
            per_page: 100, // 100件 の記事を取得するように設定
          },
        })
        .then((response) => {
          // データを利用したい値だけの形に整形
          const searchArticleResponse = response.data.map<QiitaItem>((d) => {
            return {
              id: d.id,
              title: d.title,
              likes_count: d.likes_count,
              user: d.user,
            };
          });

          setArticles(searchArticleResponse);
          setSearchWord(response.config.params.query); // 検索キーワードをレスポンスから取得してセット

          // 検索結果が1件以上ある場合だけ検索履歴ワードをセットする（グローバルステート）
          if (searchArticleResponse.length !== 0) {
            // セットする値は配列なので、検索キーワード１件だけの配列をセット
            setSearchWords([response.config.params.query]);
          }
        })
        .catch((error) => {
          setErrorMessage(error.message); // エラーメッセージをセット
        });

      setIsLoading(false); // ローディング終了
      setFormText(""); // 最終的にフォーム入力を空にする
    },
    [setIsLoading, setErrorMessage, setArticles, setSearchWord, setSearchWords]
  );

  return {
    articles,
    searchWord,
    errorMessage,
    isLoading,
    fetchArticles,
  };
};
```

API成功時には、検索キーワードのステートを``selector``で更新している。

```ts
// 一部抜粋
const setSearchWords = useSetRecoilState(searchWordsSelector);


// 検索結果が1件以上ある場合だけ検索履歴ワードをセットする（グローバルステート）
if (searchArticleResponse.length !== 0) {
    // セットする値は配列なので、検索キーワード１件だけの配列をセット
    setSearchWords([response.config.params.query]);
}
```

## ページ（pages/...）とコンポーネント（components/...）
### _app.tsx
``Recoil``での状態管理を利用したいスコープを``<RecoilRoot>``で囲む

```src/pages/_app.tsx
import "tailwindcss/tailwind.css";
import type { AppProps } from "next/app";
import { RecoilRoot } from "recoil";

function MyApp({ Component, pageProps }: AppProps) {
  return (
    // <RecoilRoot>で囲うことで、その中でRecoilが利用できるようになる
    <RecoilRoot>
      <Component {...pageProps} />
    </RecoilRoot>
  );
}
export default MyApp;
```

囲わずに``Recoil``を利用しようとするとエラーになる。
	
```ts	
Error: This component must be used inside a <RecoilRoot> componen.
```

### ページで共通利用のコンポーネント
#### BaseContainer.tsx
ページ要素のルートを囲うコンポーネント

```src/components/BaseContainer/index.tsx
import React, { ReactNode } from "react";

interface Props {
  children: ReactNode;
}

// ベースとなるレイアウトコンテナー
export const BaseContainer: React.VFC<Props> = (props) => {
  return <div className="max-w-5xl mx-auto px-12">{props.children}</div>;
};
```

### Qiita記事リストのコンポーネント（３つ）
#### 「検索キーワードと記事数ラベル」と「Qiita記事リスト」を表示するコンポーネント
```src/components/common/ArticleList/index.tsx
import React from "react";
import { QiitaItem } from "../../../types/QiitaItem";
import { ArticleItem } from "./ArticleItem";
import { SearchWordWithCountLabel } from "./SearchWordWithCountLabel";

interface Props {
  articles: QiitaItem[];
  searchWord: string;
}

// Qiita記事リスト＋検索キーワードと記事数表示コンポーネント
export const ArticleList: React.VFC<Props> = (props) => {
  return (
    <>
      <SearchWordWithCountLabel
        searchWord={props.searchWord}
        articles={props.articles}
      />
      {props.articles?.map((article) => {
        return <ArticleItem key={article.id} article={article} />;
      })}
    </>
  );
};
```

#### 「検索キーワード と 記事数」表示のコンポーネント
検索キーワード と 記事数を表示している

```src/components/common/ArticleList/SearchWordWithCountLabel/index.tsx
import React from "react";
import { QiitaItem } from "../../../../types/QiitaItem";

interface Props {
  searchWord: string;
  articles: QiitaItem[];
}

// 「検索キーワード と 記事数」ラベルコンポーネント
export const SearchWordWithCountLabel: React.VFC<Props> = (props) => {
  return (
    <>
      {props.searchWord && (
        <p className="mb-4 text-xl">
          検索キーワード と 記事数：
          <span className="ml-2 mr-3 font-bold text-blue-700 border-b-2 border-blue-700">
            {props.searchWord}
          </span>
          <span className="font-bold text-blue-700 border-b-2 border-blue-700">
            {props.articles.length} 件
          </span>
        </p>
      )}
    </>
  );
};
```

#### Qiita記事アイテムのコンポーネント
記事の下記要素を表示している

- タイトル
- LGTM数
- ユーザー名

```src/components/common/ArticleList/ArticleItem/index.tsx
import React from "react";
import { QiitaItem } from "../../../../types/QiitaItem";

interface Props {
  article: QiitaItem;
}

// Qiita記事アイテムコンポーネント
export const ArticleItem: React.VFC<Props> = ({ article }) => {
  return (
    <div className="mb-3 py-2 px-8 bg-blue-100 rounded-lg shadow">
      <p className="text-center font-bold  text-blue-800 mb-4 border-b-2 border-blue-800">
        {article.title}
      </p>
      <p className="text-blue-700">LGTM 👍：{article.likes_count}</p>
      <p className="text-blue-700">ユーザー：{article.user.name}</p>
    </div>
  );
};
```

#### 検索履歴（リンク）を表示するコンポーネント
検索履歴のキーワードを横並びで一覧表示するコンポーネント。
検索履歴をクリックすると``Next.js``の``Link``コンポーネントの機能で、「記事絞り込みページ」に遷移させる。

参考：[next/link](https://nextjs.org/docs/api-reference/next/link)

```src/components/common/ArticleList/SearchHistoryWords/index.tsx
import React from "react";
import Link from "next/link";
import { useRecoilValue } from "recoil";
import { searchWordsSelector } from "../../../grobalStates/selectors/searchWordsSelector";

// 検索履歴リンクコンポーネント
export const SearchHistoryWords: React.VFC = () => {
  // グローバルステートから検索履歴を取得
  const searchWords = useRecoilValue(searchWordsSelector);

  return (
    <>
      {searchWords.length !== 0 &&
        searchWords?.map((searchWord, index) => {
          return (
            <Link
              key={`${searchWord}_${index}`}
              href={`/filter-articles/${searchWord}`}
            >
              <span className="p-2 mx-1 bg-green-100 text-green-800 rounded-lg shadow cursor-pointer">
                {searchWord}
              </span>
            </Link>
          );
        })}
    </>
  );
};
```

### 記事検索ページ
#### pages/search-articles/index.tsx
##### レンダリング要素
- 記事検索フォーム
- 記事検索結果

#### ポイント
- API処理のカスタムフックを呼ぶ
- 検索結果のQiita記事リストを``Recoil``の``selector``でセット

```src/pages/search-articles/index.tsx
import React, { useEffect } from "react";
import { useListQiitaArticles } from "../../hooks/useListQiitaArticles";
import { SearchForm } from "../../components/SearchArticlesPage/SearchForm";
import { useSetRecoilState } from "recoil";
import { articleListSelector } from "../../grobalStates/selectors/articleListSelector";
import { SearchResult } from "../../components/SearchArticlesPage/SearchResult";
import { BaseContainer } from "../../components/BaseContainer";

// Qiita記事検索ページ
const SearchArticles: React.VFC = () => {
  const { articles, searchWord, errorMessage, isLoading, fetchArticles } =
    useListQiitaArticles();
  // 検索キーワードを引数に渡した articleListSelector のセッター
  const setSearchArticleList = useSetRecoilState(
    articleListSelector(searchWord)
  );

  useEffect(() => {
    // グローバルステートをセット
    setSearchArticleList(articles);
  }, [articles, setSearchArticleList]);

  return (
    <BaseContainer>
      <SearchForm fetchArticles={fetchArticles} isLoading={isLoading} />
      <SearchResult
        articles={articles}
        searchWord={searchWord}
        errorMessage={errorMessage}
        isLoading={isLoading}
      />
    </BaseContainer>
  );
};

export default SearchArticles;
```

#### components/SearchArticlesPage/SearchForm/index.tsx
フォーム入力を検索キーワードとしてAPIフェッチ処理を実施するコンポーネント

```src/components/SearchArticlesPage/SearchForm/index.tsx
import React, { Dispatch, FormEvent, SetStateAction, useState } from "react";
import { SearchHistoryWords } from "../../common/SearchHistoryWords";

interface Props {
  fetchArticles: (
    e: FormEvent<HTMLFormElement>,
    formText: string,
    setFormText: Dispatch<SetStateAction<string>>
  ) => void;
  isLoading: boolean;
}

// Qiita記事検索フォームコンポーネント
export const SearchForm: React.VFC<Props> = (props) => {
  const [formText, setFormText] = useState<string>("");
  const buttonColor =
    formText && !props.isLoading
      ? "bg-blue-500 hover:bg-blue-400"
      : "bg-gray-300";

  return (
    <form
      className="mt-12 mb-6"
      onSubmit={(e) => props.fetchArticles(e, formText, setFormText)}
    >
      <label className="block text-gray-700 text-lg font-bold mb-2">
        Qiita 記事 検索キーワードを入力
      </label>
      <input
        className="shadow appearance-none border rounded w-full py-2 px-3 mb-4 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
        placeholder="例：React"
        value={formText}
        disabled={props.isLoading}
        onChange={(e) => setFormText(e.target.value)}
      />
      <button
        className={`${buttonColor} text-white font-bold mr-3 py-2 px-4 rounded focus:outline-none focus:shadow-outline`}
        type="submit"
        disabled={!formText || props.isLoading}
      >
        検索
      </button>
      <SearchHistoryWords />
    </form>
  );
};
```

#### components/SearchArticlesPage/SearchResult/index.tsx

``props``で受け取った検索結果に応じた内容を表示するコンポーネント

```src/components/SearchArticlesPage/SearchResult/index.tsx
import React from "react";
import { QiitaItem } from "../../../types/QiitaItem";
import { ArticleList } from "../../common/ArticleList";

interface Props {
  articles: QiitaItem[];
  searchWord: string;
  errorMessage: string;
  isLoading: boolean;
}

// 検索結果表示コンポーネント
export const SearchResult: React.VFC<Props> = (props) => {
  // ローディング中
  if (props.isLoading) {
    return (
      <p className="mb-2 p-8 bg-yellow-100 rounded-lg">
        ローディング中.......やで
      </p>
    );
  }
  // エラー（API失敗）
  if (props.errorMessage) {
    return (
      <p className="mb-2 p-8 bg-red-100 rounded-lg">{props.errorMessage}</p>
    );
  }
  // 成功したものの、検索結果0件
  if (props.searchWord && props.articles?.length === 0) {
    return (
      <p className="mb-2 p-8 bg-green-100 rounded-lg">
        検索ワード
        <span className="font-bold border-b-2 border-black">
          {props.searchWord}
        </span>
        に該当なーーーし！！
      </p>
    );
  }
  return (
    <ArticleList articles={props.articles} searchWord={props.searchWord} />
  );
};
```

### 記事絞り込みページ
#### pages/filter-articles/[word]/index.tsx
##### レンダリング要素
- 「＜ もどる」ボタン
- LGTM数での記事絞り込みフォーム
- 絞り込み後の記事一覧

#### ポイント
- ``Next.js``の``useRouter``でパスから検索ワードを取得する
- 検索ワードとLGTM数に応じた記事リストを``Recoil``の``selector``で取得して``レンダリング``

参考：[next/router](https://nextjs.org/docs/api-reference/next/router)

```src/pages/filter-articles/[word]/index.tsx
import React from "react";
import { useRouter } from "next/router";
import { useRecoilValue } from "recoil";
import { articleListSelector } from "../../../grobalStates/selectors/articleListSelector";
import { LikesCountFilter } from "../../../components/FilterArticlesPage/LikesCountFilter";
import { ArticleList } from "../../../components/common/ArticleList";
import { BaseContainer } from "../../../components/BaseContainer";

// LGTM数でQiita記事絞り込みページ
const FilterArticlesByWord: React.VFC = () => {
  const router = useRouter();
  const searchWord = (router.query.word as string) || ""; // router.query.word の word はディレクトリ名[word]に対応している
  const articleList = useRecoilValue(articleListSelector(searchWord));

  return (
    <BaseContainer>
      <button
        className={`bg-blue-500 hover:bg-blue-400 text-white font-bold mt-2 py-1 px-3 rounded focus:outline-none focus:shadow-outline`}
        onClick={() => router.push("/search-articles")}
      >
        ＜ もどる
      </button>
      <LikesCountFilter />
      <ArticleList articles={articleList} searchWord={searchWord} />
    </BaseContainer>
  );
};

export default FilterArticlesByWord;
```

#### components/FilterArticlesPage/LikesCountFilter/index.tsx

LGTM数を変更して絞り込みをするフィルターのコンポーネント。

LGTM数をグローバルステート（``Recoil``の``atom``に直接）に更新することで取得してレンダリングする記事を制御している。

```src/components/FilterArticlesPage/LikesCountFilter/index.tsx
import React, { useCallback, useEffect } from "react";
import { SearchHistoryWords } from "../../common/SearchHistoryWords";
import { useRecoilState, useResetRecoilState } from "recoil";
import { minLikesCountState } from "../../../grobalStates/atoms/minLikesCountAtom";

// LGTM数でQiita記事絞り込みフォームコンポーネント
import React, { useCallback, useEffect } from "react";
import { SearchHistoryWords } from "../../common/SearchHistoryWords";
import { useRecoilState, useResetRecoilState } from "recoil";
import { minLikesCountState } from "../../../grobalStates/atoms/minLikesCountAtom";

// LGTM数でQiita記事絞り込みフォームコンポーネント
export const LikesCountFilter: React.VFC = () => {
  // [取得した値, セッター] LGTM数のグローバルステートより取得
  const [minLikesCount, setMinLikeCount] = useRecoilState(minLikesCountState);
  // Recoilのリセット関数：実行でデフォルト値に戻す
  const resetMinLikesCount = useResetRecoilState(minLikesCountState);
  const buttonColor =
    minLikesCount !== 0 ? "bg-red-400 hover:bg-red-300" : "bg-gray-300";

  const onClickReset = useCallback(
    (e) => {
      e.preventDefault(); // リロードを防ぐ
      resetMinLikesCount();
    },
    [resetMinLikesCount]
  );

  useEffect(() => {
    // アンマウント時にLGTM数リセット
    return () => resetMinLikesCount();
  }, []);

  return (
    <form className="mt-3 mb-2">
      <label className="block text-gray-700 text-lg font-bold mb-2">
        LGTM👍 数で絞り込み（下限を指定）
      </label>
      <input
        className="w-24 shadow appearance-none border rounded py-2 px-3 mr-2 mb-4 text-gray-700 leading-tight focus:outline-none focus:shadow-outline"
        type="number"
        min="0"
        value={minLikesCount}
        onChange={(e) => setMinLikeCount(parseInt(e.target.value) || 0)}
      />
      <button
        className={`${buttonColor} text-white font-bold py-2 px-4 mr-1 rounded focus:outline-none focus:shadow-outline`}
        onClick={onClickReset}
        disabled={minLikesCount === 0}
      >
        リセット
      </button>
      <SearchHistoryWords />
    </form>
  );
};
```


# 最後に
何はともあれ``Recoil``も``React``も``Next.js``も使っていて、書いていてホントに楽しいです！

比較的新しい技術にもかかわらず日本語の記事なんかも非常に多くていつも助けられています！
今回もいろいろな記事の知見に助けていただきました。
ありがとうございました。

だいぶわかりずらい部分もあると思います。誤りなどあれば気軽にコメントお願いしたいです！

### 参考にさせていただいた記事
- https://zenn.dev/susiyaki/articles/95dea88e673e1f854130
- https://engineering.linecorp.com/ja/blog/line-sec-frontend-using-recoil-to-get-a-safe-and-comfortable-state-management/
- https://zenn.dev/ria/articles/2427d0e998c557cdbb96
- https://zenn.dev/nekoniki/articles/406087e43f4865