前回、動作の基本となる画面遷移までできたので、次はInvoice(請求書)を格納しておく場所を作って、そのデータを表示できるようにしましょう。
簡単にやろうと思えば、unstatedを入れてデータを適当に登録すれば動くのですが、実務を考えてある程度お行儀よくコーディングすることにします。したがって、unstatedの話としては少々遠回りになりますが、ご容赦を。
この記事は連載です。親記事はこちら
前提
- Macを使います。でもWindows/Linuxでも同じで大丈夫なはず。
- React Nativeの前回までの内容の続きです。
- 前回までに、React Navigation V5 導入済みです。
unstated。Reduxではなく。
ReactのグローバルState管理には、Reduxがよく使われます。C#やPHPで多くのAPIを準備して、それらと連携して動作するような複雑なワンページアプリケーションであればRedux+ThunkでOKです。しかし、やりたいことに対して記述量が多いことと、記述場所がActionやらReducerやらばらばらで見通しが悪いことが欠点で、小さなアプリではオーバースペックに思えます。
そこで登場するのがunstatedです。ちょっと癖はあるものの、コード量は少ないし、なれれば楽勝になります。API通信のためのaxiosとの相性もばっちりです。
ネットがつながらないことを想定
実務でモバイルアプリ(Webアプリではなく)を作りたい理由の一つに、電波の届かないところでも止まらずに動いて、電波のあるところでデータの送受信をしたい、という要件があります。客先で請求書を作って印刷したいときに、その場所が地下でネットが使えないし、もちろんお客さんのWi-Fiをいちいち借りて接続なんてできない、といったことはよくある話です。
今回のように、請求書発行などの小さな目的のアプリであれば、メモリ上に配列オブジェクトなどで請求書情報を持っておいて、ネットがつながったら同期ボタンでサーバーと同期するという単純な動作で十分実用に耐えます。
そこで今回は、請求書オブジェクトInvoice
、請求書行オブジェクトItem
、顧客オブジェクトCustomer
、商品オブジェクトProduct
を作成し、それをグローバルStateに格納して取り扱うことにします。
データModelの作成
まず、グローバルstateに格納するデータの構造を整えておきます。
今回のアプリでは、以下のようなイメージでグローバルStateにデータを保持するようにします。
{ invoices: [ [0]: { id: 0, customer: 15, date: "2019/1/1", items: [ [0]: {product: 2, qty: 2, ...}, [1]: {product: 5, qty: 1, ...}, [2]: {product: 8, qty: 5, ...}, ], ... }, ... ], products: [ [0]: { id: 0, name: "Red X t-shirt", price: 10.5, ... }, ... ], customers: [ [0]: { id: 0, name: "ABCDEFG corp.", addr1: "123 West Street", ... }, ... ], }
このままハッシュ&配列でデータ構造を表現してもいいのですが、コードの品質を上げるために、データ構造をちゃんとクラス化(Model化)することにします。
Invoice
, Item
, Customer
, Product
のモデルを、以下のようにクラス化して作成します。MVCのModelと同じ考え方です。お行儀よくgetterとsetterを使い、要件に従ってextendしやすい構造にしています。ここではInvoiceにItemを追加したり削除したりするくらいの機能しか作っていません。
export default class Invoice{ constructor(id, date, customer, items) { this._id = id; this._date = date; this._customer = customer; this._items = items; } get id(){ return this._id; } set id(newValue){ this._id = newValue; } get date(){ return this._date; } set date(newValue){ this._date = newValue; } get customer(){ return this._customer; } set customer(newValue){ this._customer = newValue; } get items(){ return this._items; } set items(newValue){ this._items = newValue; } addItem(item){ this._items.push(item); } removeItem(productId){ this._items = this._items.filter(item => item.product != productId); } }
export default class Item{ constructor(product, qty, adjust, credit) { this._product = product; // Product id this._qty = qty; // Quantity scheduled this._adjust = adjust; // Adjustment on site this._credit = credit; // Returned quantity } get product(){ return this._product; } set product(newValue){ this._product = newValue; } get qty(){ return this._qty; } set qty(newValue){ this._qty = newValue; } get adjust(){ return this._adjust; } set adjust(newValue){ this._adjust = newValue; } get credit(){ return this._credit; } set credit(newValue){ this._credit = newValue; } }
export default class Customer{ constructor(id, name, addr1, addr2, city, state, zip) { this._id = id; this._name = name; this._addr1 = addr1; this._addr2 = addr2; this._city = city; this._state = state; this._zip = zip; } get id(){ return this._id; } set id(newValue){ this._id = newValue; } get name(){ return this._name; } set name(newValue){ this._name = newValue; } get addr1(){ return this._addr1; } set addr1(newValue){ this._addr1 = newValue; } get addr2(){ return this._addr2; } set addr2(newValue){ this._addr2 = newValue; } get city(){ return this._city; } set city(newValue){ this._city = newValue; } get state(){ return this._state; } set state(newValue){ this._state = newValue; } get zip(){ return this._zip; } set zip(newValue){ this._zip = newValue; } }
export default class Product{ constructor(id, name, shortName, price, cost) { this._id = id; this._name = name; this._shortName = shortName; this._price = price; this._cost = cost; } get id(){ return this._id; } set id(newValue){ this._id = newValue; } get name(){ return this._name; } set name(newValue){ this._name = newValue; } get shortName(){ return this._shortName; } set shortName(newValue){ this._shortName = newValue; } get price(){ return this._price; } set price(newValue){ this._price = newValue; } get cost(){ return this._cost; } set cost(newValue){ this._cost = newValue; } }
Seederの作成
動作テスト用に、テストデータを発生させるシーダーを準備しておきます。
import Customer from "../Customer"; import Invoice from "../Invoice"; import Item from "../Item"; import Product from "../Product"; export default class Seeder { static getSeed() { return { customers: [ new Customer(0, "ABC Store", "123 Abc St.", "", "New York", "NY", "10001"), new Customer(1, "123 Deli", "1 Def Ave.", "", "New York", "NY", "10002"), new Customer(2, "Xyz mart", "23 Xyz Blvd.", "", "New York", "NY", "10003") ], products: [ new Product(0, "Blue ribbon", "B.R.", 10.5, 7.2), new Product(1, "Red ribbon", "R.R.", 9.5, 6.0), new Product(3, "White shirt", "W.S.", 15.0, 9.3) ], invoices: [ new Invoice(0, "1/1/2019", 0, [new Item(0, 5, 0, 0), new Item(1, 3, 0, 0), new Item(2, 4, 0, 0)]), new Invoice(2, "1/1/2019", 0, [new Item(0, 7, 0, 0)]) ] }; } }
unstatedをインストール
サクッとインストールします。
$ npm install unstated
グローバルStateを作る
unstatedでグローバルStateを作るには、Provider、Subscribe、Containerの3つを使います。簡単に知るにはこの記事あたりがよろしいかと。
Providerでアプリ全体を囲む
import { Provider } from "unstated"; ... export default function App() { return ( <Provider> <NavigationContainer> <RootStack /> </NavigationContainer> </Provider> ); }
これでアプリ全体でSubscribeコンポーネントが使えるようになりました。
Containerを作る
Containerは、StateやStateに関連する処理の実体クラスです。Containerを1つ作って、アプリ内で使うグローバルStateを作り、そのStateに対する処理内容(メソッド)を追加します。当然、Containerはいろんなコンポーネントから使うことになるので、ファイル化してimportできるようにしておきましょう。containersフォルダを作って、その下にInvoiceContainerという名前のContainerを作ります。
import { Container } from "unstated"; import Seeder from "../models/seeder/Seeder.js"; export default class InvoiceContainer extends Container { constructor(props = {}) { super(); this.state = { data: props.initialSeeding ? Seeder.getSeed() : this.getEmptyData() }; } seed() { this.setState({ data: Seeder.getSeed() }); } clear() { this.setState({ data: this.getEmptyData() }); } getEmptyData() { return { customers: [], products: [], invoices: [] }; } }
ここでは、data
という名前のStateだけ登録します。今回はContainerをインスタンス化するときに引数を渡して、シードするかしないか動作を分けることにします。initialSeedingというプロパティとして引数を読んで、trueならdata
をSeederからとってくる、falseならdata
を空データにする、とします。このように引数によって初期Stateを変更したい場合は、constructorを使います。
初期化が終わった後、各コンポーネントからシードを実行したりデータをクリアできるように、シード動作をseed()として定義し、クリア動作をclear()として定義しています。
コンストラクタではthis.state =
のように直接stateを変更していますが、send()やclear()では変更検知をReactが検知できるようにthis.setStateを使っていることに注意してください。
さて、これでグローバルStateの準備ができましたので、次は利用側のコードを書いていきます。
Containerのインスタンスを先に作っておく
これは、初期値指定をしない場合は不要なのです。Containerのインスタンスを先に作っておいて、ProviderにInjectすることによって、SubscribeでContainerを使うときにはすでに「狙ったように」初期化されている状態にできます。そうでなければ、SubscribeでContainerを使うときには「よしなに」初期化される(constructorを使わずにstate =
で初期化することになるのと、もしインスタンスがまだ作られてなかったらインスタンスを作ってくれる)ことになります。
import InvoiceContainer from "./containers/InvoiceContainer"; ... export default function App() { let globalState = new InvoiceContainer({ initialSeeding: true }); return ( <Provider inject={[globalState]}> <NavigationContainer> <RootStack /> </NavigationContainer> </Provider> ); }
呼び出し時にinitialSeeding
にオンを指定してます。
SubscribeでグローバルStateにアクセスする
SubscribeをHomeScreenに入れて、HomeScreenでInvoiceリストを表示できるようにします。
まずはHomeScreenの出力を単純にSubscribeで囲んでみます。
import { StatusBar } from "expo-status-bar"; import React from "react"; import { Button, Text, View } from "react-native"; import { Subscribe } from "unstated"; import styles from "../styles.js"; import InvoiceContainer from "../containers/InvoiceContainer"; class HomeScreen extends React.Component { render() { return ( <Subscribe to={[InvoiceContainer]}> {(globalState) => ( <View style={styles.container}> <Text>Open up App.js to start working on your app!</Text> <Text>Hello World!</Text> <Button title="Go to InvoiceEdit" onPress={() => this.props.navigation.navigate("InvoiceEdit")} /> <Button title="Go to Summary" onPress={() => this.props.navigation.navigate("Summary")} /> <Text>{globalState.state.data.invoices[0].date}</Text> <StatusBar style="auto" /> </View> )} </Subscribe> ); } } export default HomeScreen;
<Subscribe to={[InvoiceContainer]}>
のように、InvoiceContainerのインスタンスではなくクラスそのものを指定していることに注目してください。このままだと初期化されなさそうですが、先の<Provider inject={[globalState]}>
で実態をインジェクトしているので、そのインスタンスが自動的に使われます。自分でpropsリレーをするなどの実体管理をしなくていいので、便利ですね。なお、injectをつかわなくても、実態を必要に応じで「よしなに」初期化してくれます。
ということで、ソースに追加した<Text>{globalState.state.data.invoices[0].date}</Text>
のところで、グローバルStateとして登録されているdata
のinvoices
配列の0番のdate
の内容が表示されることを期待しています。
実行すると、以下のようにSeederで入れた日付が見られます。
大成功!といいたいところですが、1つ困ったことがあります。InvoiceContainerがSubscribeの外で使えない、もちろんrender()の外でも使えないので、invoicesのリストを作るループをrender()の前にかけないのです。。。
画面コンポーネントをHOC化する
HOC化とは、すごく簡単に言うと、機能を追加するためにクラスを上位クラスで包むことです。百聞は一見に如かず、HomeScreenをHOC化します。
import { StatusBar } from "expo-status-bar"; import React from "react"; import { Button, Text, View } from "react-native"; import { Subscribe } from "unstated"; import styles from "../styles.js"; import InvoiceContainer from "../containers/InvoiceContainer"; class HomeScreenContent extends React.Component { render() { let globalState = this.props.globalState; let invoiceList = <Text>No invoice</Text>; if (globalState.state.data.invoices.length) { invoiceList = globalState.state.data.invoices.map((invoice) => { return ( <Text key={invoice.id}>{invoice.id + " : " + invoice.date}</Text> ); }); } return ( <Subscribe to={[InvoiceContainer]}> {(globalState) => ( <View style={styles.container}> <Text>Open up App.js to start working on your app!</Text> <Text>Hello World!</Text> <Button title="Go to InvoiceEdit" onPress={() => this.props.navigation.navigate("InvoiceEdit")} /> <Button title="Go to Summary" onPress={() => this.props.navigation.navigate("Summary")} /> {invoiceList} <StatusBar style="auto" /> </View> )} </Subscribe> ); } } const HomeScreen = ({ navigation }) => { return ( <Subscribe to={[InvoiceContainer]}> {(globalState) => ( <HomeScreenContent globalState={globalState} navigation={navigation} /> )} </Subscribe> ); }; export default HomeScreen;
もともとのHomeScreenクラスをHomeScreenContentと改名し、上位クラスをHomeScreenとしました。上位クラスのHomeScreenで、Subscribeの中にHomeScreenContentを内包することで、propsにInvoiceContainerクラスの実体(globalStateという名前にしました)を渡せました。これにより、HomeScreenContentでは通常のthis.propsのメンバーのように、グローバルStateにアクセスできます。一見、「結局propsを手動で渡してる」ように見えますが、完全にこのクラスのファイル内で自己完結しているので、親コンポーネントからpropsを渡すのとは全く異なります。
もうひとつ注意すべき点は、navigation
プロパティをHomeScreenContentに伝搬してあげることです。こうしないと、HomeScreenContentのほうでreact-navigationを使えません。
あとはいつものReactと同じで、invoiceをループしてinvoiceListにコンテンツを格納し、内に{invoiceList}として表示しています。
書き込み
最後に、グローバルStateに格納されているデータを変更するコードを入れてみましょう。
Summary画面にボタンを追加して、ID2番のInvoiceの日付を修正してみます。Summary画面もHOC化して、ボタンを追加し、ボタンイベント内でグローバルStateを変更してみます。
import React from "react"; import { Button, Text, View } from "react-native"; import { Subscribe } from "unstated"; import InvoiceContainer from "../containers/InvoiceContainer"; import styles from "../styles.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.setState({ data: data }); }} /> </View> ); } } const SummaryScreen = ({ navigation }) => ( <Subscribe to={[InvoiceContainer]}> {(globalState) => ( <SummaryScreenContent globalState={globalState} navigation={navigation} /> )} </Subscribe> ); export default SummaryScreen;
this.props.globalState.state.data
にデータが入っているのでそれを書き換えます。直接書き換えずに、必ずsetStateしてください。でないと、通常のState変更同様で、画面の内容が書き換わりません。
では実行してSummary画面を開けます。
新しいボタンを押してからBackすると、
2行目のInvoiceの日付が変わってます!
Reactらしい挙動をしてくれましたね。