[React+Recoil] Recoil でつくるお天気アプリ

お天気アプリを作りながら、リロードができない問題の解決策である Query Refresh Pattern を含め Recoil について学びます。
2021.02.03

React の状態管理といえば Redux がまだまだ使われることが多そうですが、2020 年の 5 月に発表された状態管理ライブラリの Recoil を使ってシンプルなお天気アプリを作ってみました。

完成品は上記のリポジトリにあります。

今回つくるアプリについて

  • セレクトボックスで都市を選択する、選択できる都市の情報はローカルで持たせる
    • セレクトボックスの初期値は「選択なし」である
  • 決定ボタンを押すとセレクトボックスで選択された都市の天気情報を表示させる
  • API には OpenWeatherMap を使用する
  • ローディング中には loading と表示させる
  • エラー時には error と表示させる

上記のアプリを CRA で作成していくことを想定しています。

動作は上記の通りです(最終的にリロードボタンとデバッグボタンが追加でつきます)。

App.tsx の最終形

App.tsx の最終形は以下の通りです。

// App.tsx
import React, { Suspense } from "react";

import { ErrorBoundary } from "./components/ErrorBoundary";
import { WeatherForm } from "./components/WeatherForm";
import { WeatherResult } from "./components/WeatherResult";

export const App: React.VFC = () => {
  return (
    <>
      <h1>お天気アプリ</h1>

      <WeatherForm />

      <ErrorBoundary fallback={<p>error</p>}>
        <Suspense fallback={<p>loading</p>}>
          <WeatherResult />
        </Suspense>
      </ErrorBoundary>
    </>
  );
};

1 つずつ見ていきましょう。

ユーザに選択される都市データと atom の作成

まずはコンポーネントを作成する前に都市のデータを記述していきます。city ID については Current weather dataの該当項目から調べることができます。

// utils/cities.ts
export type CityId = typeof cities[number]["id"];

export const cities = [
  { id: "2128295", name: "札幌" },
  { id: "1850147", name: "東京" },
  { id: "1853908", name: "大阪" },
  { id: "1863958", name: "福岡" },
] as const;

次にユーザの選択した都市がどこであるかの状態を保持する atom を作成していきます。

今回ディレクトリ構成には Recoil Patterns: Hierarchic & Separation を参考にさせていただいています。

// states/rootStates/cityId.ts
export const cityIdState = atom<CityId | undefined>({
  key: "CityId",
  default: undefined,
});

接頭尾にある State は最初つけていなかったのですが local state の cityId がでてきたときに困るという問題が発生したため付けています。ここでは公式のチュートリアルに合わせて接頭尾に State を付けています。

この atom を使う場合には、下記のように何をするかで使用する hooks の使い分けることができます。

  • 値を読み取るだけでいい-> useRecoilValue
  • 値を set するだけでいい -> useSetRecoilState
  • どちらもする必要がある -> useRecoilState

コンポーネント WeatherForm の作成

実際のフォームのコンポーネントを見ていきましょう。このコンポーネントがやっていることは 2 点です。

  1. セレクトボックスが変更されたら、local state の cityId を変更する
  2. submit を押したら、atom の値を現在選択されている local state の cityId に変更する
// components/WeatherForm.tsx
import React, { useState } from "react";

import { useSetRecoilState } from "recoil";

import { cityIdState } from "../states/rootStates/cityId";
import { cities, CityId } from "../utils/city";

export const WeatherForm: React.VFC = () => {
  const [cityId, setCityId] = useState<CityId>();
  const setStateCityId = useSetRecoilState(cityIdState);

  const changeCity = (e: React.ChangeEvent<HTMLSelectElement>) => {
    const id = e.currentTarget.value as CityId;
    setCityId(id);
  };

  const submit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setStateCityId(cityId);
  };

  return (
    <form onSubmit={submit}>
      <select onChange={changeCity}>
        <option value="">選択なし</option>
        {cities.map((city) => (
          <option value={city.id} key={city.id}>
            {city.name}
          </option>
        ))}
      </select>
      <button type="submit">submit</button>
    </form>
  );
};

このコンポーネントでは atom の値を読み取る必要はないため useSetRecoilState を使っています。

OpenWeatherMap からデータを取得する selector の作成

API へのリクエストなどの非同期処理をするには selector の get を async にする、あるいは Promise を戻すだけです。

// states/rootStates/weather.ts
import { selector } from "recoil";

import { CurrentWeather } from "../../models/currentWeather";
import { cityIdState } from "./cityId";

export const weatherState = selector<CurrentWeather>({
  key: "Weather",
  get: async ({ get }) => {
    const cityId = get(cityIdState);
    if (!cityId) {
      return;
    }

    const res = await fetch(`https://...`);
    const json = await res.json();

    return json;
  },
});

この Promise が解決されるまでは React Suspense を使ってレンダーを待機させ、ローディングなどを出すことができます。

また selector では、エラーを握りつぶさないように注意が必要です。これについては後述します。

コンポーネント WeatherResult の作成

このコンポーネントは実際に API から取得してきたデータを表示するものです。

weather が early return をしている理由は、初期表示、あるいは都市を選択しないで submit したケースを考慮しています。

// components/WeatherResult.ts
// 表示データを一部省略しています
import React from "react";

import { useRecoilValue } from "recoil";

import { weatherState } from "../states/rootStates/weather";

export const WeatherResult: React.VFC = () => {
  const weather = useRecoilValue(weatherState);

  if (!weather) {
    return null;
  }

  return (
    <section>
      <h2>{weather.name}の天気</h2>

      <div>気温: {weather.main.temp} 度</div>
    </section>
  );
};

このコンポーネントが API の呼び出し中にローディングを表示するためには Suspense で wrap します。

<Suspense fallback={<p>loading</p>}>
  <WeatherResult />
</Suspense>

エラー処理

selector でスローされたエラーは React の Error Boundary でキャッチします。先ほど書いたようにうっかりエラーを握り潰してしまうと、エラーをキャッチすることができません(私は最初やってしまい、しばらく原因がわからずハマりました……)。

// components/ErrorBoundary.tsx
import React from "react";

type ErrorBoundaryProps = {
  fallback: React.ReactNode;
};

type ErrorBoundaryState = {
  hasError: boolean;
  error?: Error;
};

export class ErrorBoundary extends React.Component<
  ErrorBoundaryProps,
  ErrorBoundaryState
> {
  state = { hasError: false, error: undefined };

  static getDerivedStateFromError(error: Error): ErrorBoundaryState {
    return {
      hasError: true,
      error,
    };
  }

  render(): React.ReactNode {
    if (this.state.hasError) {
      return this.props.fallback;
    }
    return this.props.children;
  }
}

Error Boundary は現時点(2021 年 2 月)で hooks に対応していないため、Class Component である必要があります。こちらのコードは公式のサンプルコードに型をつけただけのものです。

リロードができない問題と解決策

このアプリは一度取得したデータを再取得して更新ができません。

この挙動で問題にならない場合もありますが、今回は時間によって変化する天気の情報なのでリロードが可能であるとより親切です。

recoil で非同期処理の refresh したい場合には少し工夫が必要で、Query Refresh としてその方法が公式ドキュメントに記載されています。

下記の PR がリロード可能な状態になったものです。

再度リクエストを投げるトリガーとして、atomFamilyweatherRequestIdState を作成。weatherStatecityId を渡せる selectorFamily に変更し動的に生成し、weatherRequestIdState を取得しています。この状態で weatherRequestIdState のカウントアップさせると refresh できる、という仕組みのようです。

refresh が目的だとわからなければ、なぜこういったコードになっているのか把握するのがなかなか難しそうだと感じました。

デバッグについて

公式の上記のページに記載されているページは現時点(2021 年 2 月)でそのままだと動かないため、一部修正が必要になります。

  • getNodes -> getNodes_UNSTABLE
  • modified -> isModified

ただし、やはりデバッグの機能としては少しつらく、atomselector だけであればそこまででもありません。

ですが atomFamilyselectorFamily になってくると難しいのではないかなと感じました。