React Native + Expo アプリでunstatedのデータを永続化


この記事は、「【連載】初めてのReact Native + Expo開発環境構築入門」の子記事です。環境などの条件は、親記事をご覧ください。

※ この記事では、すでに非推奨となっているAsyncStorageを利用しています。最新の方法は、こちらの記事におまかせしました。


 unstatedで作ったグローバルStateは、アプリを終了したら失われますが、これを永続化することでアプリを次に起動したときも前のデータをキープします。
 前回までに、請求書情報をサーバーから取得して表示できるようになったので、今回はサーバーから取得した後と、データを修正した時に、その結果得られるデータ全体を永続化します。

 永続化には、React Native本体のAsyncStorageを使います。実は今後React Native本体から削除される予定で非推奨となっているのですが、Expoのドキュメントではまだこれを使うように書かれているので、Expoに従います。Expoを使わない場合は、本家が「こっちに乗り換えろ」と誘導しているAsyncStorage(@react-native-community/async-storage)を使うべきです。

Q. unstatedのコンテナ(グローバルState)全体を永続化しない理由は?
A. 通信中フラグをオンにしている状態などまで永続化したくない。

Q. unstated-persistを使わない理由は?
A. 上記の通り全体を永続化したくないことと、永続化のタイミングを制御したいため。

unstatedのコンテナに保存と読み込みメソッドを追加

 ローカルストレージにデータを保存するsetAndSaveState()と、読み込むload()を作ります。コードは本家サンプルまたはExpoサンプルからコピーして、自前Stateに合致するように少し修正。

containers/InvoiceContainer.js
import { AsyncStorage } from 'react-native';
...
export default class InvoiceContainer extends Container {
  constructor(props = {}) {
    super();
    this.state = {
      data: props.initialSeeding ? Seeder.getSeed() : this.getEmptyData(),
      isDataLoading: false
    };
  }
...
  // Save data to the local storage, then setState.
  setStateAndSave = async updateStates => {
    try {
      for (var k in updateStates) {
        await AsyncStorage.setItem(k, JSON.stringify(updateStates[k]));
      }
      this.setState(updateStates);
    } catch (error) {
      // Error saving data
      console.log("storage error");
    }
  };

  // Load data from the local storage
  load = async () => {
    try {
      const value = await AsyncStorage.getItem("data");
      if (value !== null) {
        // Data found
        this.setState({ data: JSON.parse(value) });
      } else {
        this.setState({ data: this.getEmptyData() });
      }
    } catch (error) {
      // Error retrieving data
      console.log("storage error");
    }
  };
...

 AsyncStorageで保管できるのは文字列だけなので、DataオブジェクトをJSON文字列に変換して保存し、読み出し字はその逆を実施します。ポイントは以下の部分です。

保存:単にsave()とすると、保存のタイミング制御が難しくなるので、setState()と動作を組み合わせたsetStateAndSave()としています。こうしないと、呼び出し側でsetState()した直後にsave()したくなりますが、setState()した直後は実際にはStateが更新されていないため、直前の状態を保存してしまう問題が発生します。この問題を意識させないため、setStateと保存を同時に行うメソッドを準備します。中のコードは単純で、指定されたStateについてローカルに保存してからsetStateしています。awaitが入っているので、保存に失敗するとsetStateに到達せず、State自体の書き換えも行われない、つまりユーザー側から見ても保存失敗が結果として見える、というのがポイントです。

        for (var k in updateStates) {
          await AsyncStorage.setItem(k, JSON.stringify(updateStates[k]));
        }
        this.setState(updateStates);

読み込み:setStateを使っていることに注意。

const value = await AsyncStorage.getItem('data');
...
this.setState({data:JSON.parse(value)});

保存したいタイミングのコーディング

 サーバーからデータを取得した直後や、アプリ内でデータを変更したときに、上で作ったsetAndSaveState()を呼びます。

サーバーからデータを取得した時:setStatesetStateAndSaveに置き換えるだけです。isDataLoadingは永続化したくないのでsetStateのままであることに注意してください。

containers/InvoiceContainer.js
  getDataFromServer(endpoint) {
    this.setState({ isDataLoading: true });
    console.log(endpoint);
    axios
      .get(endpoint, { params: {} })
      .then(results => {
        console.log("HTTP Request succeeded.");
        console.log(results);
        this.setStateAndSave({ data: results.data });
        this.setState({ isDataLoading: false });
      })
      .catch(() => {
        console.log("HTTP Request failed.");
        this.setState({ isLoading: false });
      });
  }

データを修正した時:setStatesetStateAndSaveに置き換えるだけです。

components/SummaryScreen.js
class SummaryScreenContent extends React.Component {
  render() {
    return (
      <View style={styles.container}>
        <Text>Summary Screen</Text>
        <Button
          title="Modify Inv#2"
          onPress={() => {
            let data = this.props.globalState.state.data;
            data.invoices[1].date = "2/2/2020";
            this.props.globalState.setStateAndSave({ data: data });
          }}
        />
      </View>
    );
  }
}

読み込みたいタイミングのコーディング

 アプリを起動したときに、以前のデータを読み込むべきなので、グローバルStateを作った時=unstatedコンテナのインスタンスを作った時に、load()を呼びます。

App.js
export default class App extends React.Component {
  ...
  render() {
    ...
    let globalState = new InvoiceContainer({ initialSeeding: true });
    globalState.load();
    return (
      <Provider inject={[globalState]}>
        <AppContainer />
      </Provider>
    );
  }
}