11
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

なる早でTypescript + Next + MobX + mobx-react-lite でグリグリ動く「買い物リスト」みたいなのを作る

Last updated at Posted at 2019-06-30

はじめに

この記事ではTypescript(.tsx)でNext.js@canaryのサーバーを立て、MobXの動くものをちょいと作ります。

  • これはとりあえず動くものを素早く作るためのチュートリアルです
  • Typescriptを手っ取り早く設定するために、執筆時点ではNext.jsのcanaryカナリヤバージョンを使います

想定読者

以下のいずれか

筆者の環境
  • OS: macOS Mojave 10.14.5
  • ブラウザ: Safari バージョン12.1.1
  • Node.js: v10.16.0
  • Yarn: 1.15.2

今日作るもの

画面収録 2019-06-30 22.26.11.gif

できたやつ: https://github.com/NanimonoDemonai/okaimono/tree/master

プロジェクト準備

パッケージインストール

yarn add react react-dom next@canary mobx mobx-react-lite uuid
yarn add -D typescript @types/react @types/react-dom @types/node @types/uuid babel-preset-mobx

/TODO: Next.js 8.1.1がリリースされたら@canaryを消す*/*

執筆当時のpackage.json
package.json
{
  "dependencies": {
    "mobx": "^5.10.1",
    "mobx-react-lite": "^1.4.1",
    "next": "^8.1.1-canary.63",
    "react": "^16.8.6",
    "react-dom": "^16.8.6",
    "uuid": "^3.3.2"
  },
  "devDependencies": {
    "@types/node": "^12.0.10",
    "@types/react": "^16.8.22",
    "@types/react-dom": "^16.8.4",
    "@types/uuid": "^3.4.4",
    "babel-preset-mobx": "^2.0.0",
    "typescript": "^3.5.2"
  }
}
#### 各種configの作成 以下のファイルを```package.json```と同階層に作成してください。

.babelrc

あとあと、クラスデコレータを使うので".babelrc"を作成して、編集しておく、preset:"next/babel"next@canaryに付いてくる。

.babelrc
{
  "presets": [
    "next/babel",
    "mobx"
  ]
}

indexページの作成

pages/index.tsxを作成する。

pages/index.tsx
export default () => (
   <h1>It Works!</h1>
);

tsconfig.json
"compilerOptions": {}のメンバに"experimentalDecorators": true,があれば良い。

tsconfig.jsonを作成せずに、yarn nextで開発サーバーを起動することで作成されるデフォルトのtsconfig.jsonにこのオプションを挿すか、tsconfig.jsonファイルを作りNext.jsのGitHubに書かれているtsconfig.jsonにこのオプションを挿す。

tsconfig.json
{
  "compilerOptions": {
    "allowJs": true, /* Allow JavaScript files to be type checked. */
    "alwaysStrict": true, /* Parse in strict mode. */
    "esModuleInterop": true, /* matches compilation setting */
    "isolatedModules": true, /* to match webpack loader */
    "jsx": "preserve", /* Preserves jsx outside of Next.js. */
    "lib": ["dom", "es2017"], /* List of library files to be included in the type checking. */
    "module": "esnext", /* Specifies the type of module to type check. */
    "moduleResolution": "node", /* Determine how modules get resolved. */
    "noEmit": true, /* Do not emit outputs. Makes sure tsc only does type checking. */

    "experimentalDecorators": true, /* ここに挿した */

    /* Strict Type-Checking Options, optional, but recommended. */
    "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
    "noUnusedLocals": true, /* Report errors on unused locals. */
    "noUnusedParameters": true, /* Report errors on unused parameters. */
    "strict": true /* Enable all strict type-checking options. */,
    "target": "esnext" /* The type checking input. */
  }
}
執筆当時のデフォルトの`tsconfig.json`
tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve"
  },
  "exclude": [
    "node_modules"
  ],
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx"
  ]
}

これに"experimentalDecorators": true,を挿すとこうなる

tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "experimentalDecorators": true
  },
  "exclude": [
    "node_modules"
  ],
  "include": [
    "next-env.d.ts",
    "**/*.ts",
    "**/*.tsx"
  ]
}

開発用サーバの起動

以下のコマンドを実行すると

yarn next

以下にサーバが立つ
http://localhost:3000

品物のViewとStoreを作る

まずはこれを作りましょう
ww.gif

storeを作る

まずは、データを取り扱うstoreから手をつけます。store/Item.tsを新規作成しましょう。

IDEで補完をよしなにしてもらうためにまずは型から書きます。

store/Item.ts
export interface ItemData {
    readonly name: string;  //商品名
    readonly price: number; //値段
}

export interface ItemModel extends ItemData {
    readonly uuid: string;  //map用のuuid
    readonly count: number; //個数
    readonly fullPrice: number;//合計
    readonly decrementable: boolean;//マイナスボタンが押せるか?
}

export const defaultItemData: ItemData = {
    name: "ダミー",
    price: 50
};

そして実際に操作するclassを書いていきましょう。

store/Item.ts
import {action, computed, observable, configure} from "mobx";
import uuid from "uuid";

configure({enforceActions: "observed"});

//中略

export class ItemController implements ItemModel {
    readonly name: string;
    readonly price: number;
    readonly uuid: string;
    @observable private _count: number;


    constructor(data?: Partial<ItemData>) {
        const initializer: ItemData = {...defaultItemData, ...data}
        this.name = initializer.name;
        this.price = initializer.price;

        //uuid作成
        this.uuid = uuid.v4();

        this._count = 0;
    }

    @computed
    get count(): number {
        return this._count;
    }

    @computed
    get fullPrice(): number {
        return this._count * this.price;
    }

    @computed
    get decrementable(): boolean {
        return this._count > 0;
    }

    @action.bound
    increment() {
        this._count++;
    }

    @action.bound
    decrement() {
        if (this.decrementable)
            this._count--;
    }
}

上から順に説明します、
configure({enforceActions: "observed"});observableな値をactionの外で変更できないようにするための厳し目モードにするための設定です。

このクラスは@observableな値である_countを操作するためのクラスです。

@computed_countによって計算される値であり、_countの変化に応じて、値が更新されます。@action.bound@observableな値を更新する関数です。@actionだとthisbindされないので面白くないです。

storeをとりあえず使ってみる

pages/itemTest
import {ItemController} from "../store/Item";
import {Observer} from "mobx-react-lite";

const itemController1 = new ItemController();

const itemController2 = new ItemController({
    name: "金塊",
    price: 50000
});


export default () => (
    <>
        <div>
            <p>品名:{itemController1.name}</p>
            <p>値段:{itemController1.price}</p>
            <p>UUID:{itemController1.uuid}</p>
            <p>個数:{itemController1.count}</p>
            <p>総額:{itemController1.fullPrice}</p>
            <button
                onClick={()=>{
                    itemController1.increment();
                }}
            >
                +
            </button>
            <button
                onClick={()=>{
                    itemController1.decrement();
                }}
                disabled={!itemController1.decrementable}
            >
                -
            </button>
        </div>

        <div>
            <p>品名:{itemController1.name}</p>
            <p>値段:{itemController1.price}</p>
            <p>UUID:{itemController1.uuid}</p>
            <Observer>{() =>
                <p>個数:{itemController1.count}</p>
            }</Observer>
            <Observer>{() =>
                <p>総額:{itemController1.fullPrice}</p>
            }</Observer>
            <button
                onClick={()=>{
                    itemController1.increment();
                }}
            >
                +
            </button>
            <Observer>{() =>
                <button
                    onClick={() => {
                        itemController1.decrement();
                    }}
                    disabled={!itemController1.decrementable}
                >
                    -
                </button>
            }</Observer>
        </div>

        <div>
            <p>品名:{itemController2.name}</p>
            <p>値段:{itemController2.price}</p>
            <p>UUID:{itemController2.uuid}</p>
            <Observer>{() =>
                <p>個数:{itemController2.count}</p>
            }</Observer>
            <Observer>{() =>
                <p>総額:{itemController2.fullPrice}</p>
            }</Observer>
            <button
                onClick={()=>{
                    itemController2.increment();
                }}
            >
                +
            </button>
            <Observer>{() =>
                <button
                    onClick={() => {
                        itemController2.decrement();
                    }}
                    disabled={!itemController2.decrementable}
                >
                    -
                </button>
            }</Observer>
        </div>
    </>
)

http://localhost:3000/itemTest
画面収録 2019-07-01 0.30.33.gif

これがMobXとmobx-react-liteの威力です。<Observer>{() =>~}</Observer>で囲った要素は@observableおよび、@computedな値の変化があれば適切に更新されます。

一通り動きがわかったところで、見て呉れを良くしましょう。

viewを作る

component/Item.tsx
import {FC} from "react";
import {ItemController, ItemData} from "../store/Item";
import {Observer} from "mobx-react-lite";

export const ItemDescription: FC<ItemData> = props => (
    <div>
        <p>商品名:{props.name}</p>
        <p>価格:{props.price}</p>
    </div>
);

export interface ItemCountProps {
    count: number;
    fullPrice: number;
}

export const ItemCount: FC<ItemCountProps> = props => (
    <div>
        <span className={"count"}>個数:{props.count}</span>
        <span>合計金額:<b>{props.fullPrice}</b></span>

        { /*language=CSS*/}
        <style jsx>{`
            .count {
                padding-right: 2em;
            }
        `}</style>
    </div>
);

export const Item: FC<{ controller: ItemController; }> = props => (
    <div className={"item"}>
        <ItemDescription name={props.controller.name} price={props.controller.price}/>
        <hr/>
        <Observer>{() =>
            <ItemCount count={props.controller.count} fullPrice={props.controller.fullPrice}/>
        }</Observer>
        <button onClick={
            () => {
                props.controller.increment()
            }
        }>
            +
        </button>
        <Observer>{() =>
            <button
                onClick={
                    () => {
                        props.controller.decrement()
                    }
                }
                disabled={!props.controller.decrementable}
            >
                -
            </button>
        }</Observer>
        {/* language=CSS*/}
        <style jsx>{`
            .item {
                border: double;
                padding: 1em;
                margin: 1em;
            }
        `}</style>
    </div>
);
pages/itemViewTest
import {ItemController} from "../store/Item";
import {Item} from "../components/Item";

const itemController = new ItemController({
    name: "金塊",
    price: 50000
});


export default () => (
    <>
        <Item controller={itemController}/>
    </>
)

http://localhost:3000/itemViewTest
スクリーンショット 2019-07-01 0.48.00.png

品物のリストを作る

storeを作る

store/ItemList.ts
import {ItemController, ItemData} from "./Item";
import {action, computed, configure, observable, IObservableArray} from "mobx";

configure({enforceActions: "observed"});

export interface ItemListModel {
    readonly items: ReadonlyArray<ItemController>;
    readonly fullPrice: number;
}

export class ItemListController implements ItemListModel {
    private readonly _items: IObservableArray<ItemController>;

    constructor(items?: ItemController[] = []) {
        this._items = observable(items);
    }

    @computed get items(): ReadonlyArray<ItemController> {
        return this._items;
    }

    @computed get fullPrice() {
        return this._items.reduce((acc, cur) => acc + cur.fullPrice, 0)
    }

    @action.bound
    addItem(data: Partial<ItemData>) {
        this._items.push(new ItemController(data));
    }

    @action.bound
    removeChildren(child: ItemController) {
        this._items.remove(child);
    }
}

IObservableArrayobservable(array)で作ることができる、observableな配列です。名前の通りですね。IObservableArrayはそれ自体がobservableなので、@observableをつけなくても動きます。なお、つけても動きます。
参考: MobX公式ドキュメント:Array

このIObservableArrayは通常のArrayに加えていくつかの関数が生えており、特段に便利なのは、remove(value)です。これはIObservableArrayの子要素を渡せば、その子要素を消す関数です。

とりあえずstoreを使ってみる

itemListTest.tsx
import {ItemListController} from "../store/ItemList";
import {Observer} from "mobx-react-lite";
import {Item} from "../components/Item";

const list = new ItemListController();
list.addItem({
    name: "金塊",
    price: 50000
});
list.addItem({
    name: "",
    price: 300000
});
let dummy = 0;

export default () => (
    <>
        <Observer>{() =>
            <>
                {list.items.map(e => <Item controller={e} key={e.uuid}/>)}
            </>
        }</Observer>
        <Observer>{() =>
            <p>{list.fullPrice}</p>
        }</Observer>
        <button
            onClick={()=>{
                list.addItem({
                    name: `ダミーくん${dummy}号`,
                    price: 3
                });
                dummy++;
            }}
        >
            ダミーを増やす
        </button>
    </>
)

2qvh4-81l0o.gif

ほぼほぼ完成ですが、一応これもコンポーネントにしましょう。

viewを作る

components/ItemList.tsx
import {Observer} from "mobx-react-lite";
import {ItemListController} from "../store/ItemList";
import {Item} from "./Item";
import {createContext, FC} from "react";

export const ItemListControllerContext = createContext<ItemListController | null>(null);

export const ItemList: FC<{ controller: ItemListController; }> = props => (
    <div>
        <ItemListControllerContext.Provider value={props.controller}>
            <Observer>{() =>
                <div>
                    {props.controller.items.map(e => <Item controller={e} key={e.name}/>)}
                </div>
            }</Observer>

            <hr/>

            <p>合計金額総和:
                <Observer>{() =>
                    <span className={"goukei"}>{props.controller.fullPrice}</span>
                }</Observer>
            </p>
            {/* language=CSS*/}
            <style jsx>{`
                .goukei {
                    padding-left: 1em;
                    color: red;
                }
            `}</style>
        </ItemListControllerContext.Provider>
    </div>
);

工夫した点は、React純正のProviderを使って、ItemList以下のモジュールでItemListControllerを使えるようにしたことです。これを使ってみましょう。
参考: useContextのしくみ - Qiita

ProviderとuseContextを使った、Removerを作ってみる

component/Remover.tsx
import {FC, useContext} from "react";
import {ItemController} from "../store/Item";
import {ItemListControllerContext} from "./ItemList";

export const Remover: FC<{ controller: ItemController; }> = props => {
    const list = useContext(ItemListControllerContext);
    return (
        <>
            {list != null &&
            <button onClick={() => {
                list.removeChildren(props.controller)
            }}>
                削除
            </button>
            }
        </>
    )
};

ProvideruseContextを使って削除ボタンを作ることができました。これを、Item.tsxに挿しておきましょう。また、これはProvideがないときには表示されません。

参考: 条件付きレンダー - React #論理 && 演算子によるインライン If

Item.tsx
import {FC} from "react";
import {ItemController, ItemData} from "../store/Item";
import {Observer} from "mobx-react-lite";
import {Remover} from "./Remover"; //追加

//中略

export const Item: FC<{ controller: ItemController; }> = props => (
    <div className={"item"}>
        <ItemDescription name={props.controller.name} price={props.controller.price}/>
        <hr/>
        <Observer>{() =>
            <ItemCount count={props.controller.count} fullPrice={props.controller.fullPrice}/>
        }</Observer>
        <button onClick={
            () => {
                props.controller.increment()
            }
        }>
            +
        </button>
        <Observer>{() =>
            <button
                onClick={
                    () => {
                        props.controller.decrement()
                    }
                }
                disabled={!props.controller.decrementable}
            >
                -
            </button>
        }</Observer>
        <Remover controller={props.controller}/> {/* ここに挿した*/}
        {/* language=CSS*/}
        <style jsx>{`
            .item {
                border: double;
                padding: 1em;
                margin: 1em;
            }
        `}</style>
    </div>
);

動作確認用に適当に使ってみます。

pages/itemListViewTest.tsx
import {ItemListController} from "../store/ItemList";
import {ItemList} from "../components/ItemList";

const list = new ItemListController();
list.addItem({
    name: "金塊",
    price: 50000
});
list.addItem({
    name: "",
    price: 300000
});

export default () => (
    <ItemList controller={list}/>
);

http://localhost:3000/itemListViewTest
c80e2-ypyoj.gif

品物追加コンポーネントを作る

これでラストです。

storeを作る

store/ItemAdder.ts
import {action, computed, observable,configure} from "mobx";
import {ItemListController} from "./ItemList";

configure({enforceActions: "observed"});

export interface ItemAdderModel {
    readonly name: string;
    readonly price: string;

}

export class ItemAdderController implements ItemAdderModel{
    @observable private _name: string;
    @observable private _price: string;
    private _list: ItemListController;

    constructor(list: ItemListController) {
        this._name = "";
        this._price = "0";
        this._list = list;
    }

    @computed get name(){
        return this._name;
    }
    @computed get price(){
        return this._price;
    }

    @computed get isNumberError(): boolean {
        return this._price == "" || isNaN(Number(this._price));
    }

    @computed get isAddable(): boolean {
        return !this.isNumberError && this._name.length != 0;
    }

    @action.bound
    onNameChange(text: string) {
        this._name = text;
    }

    @action.bound
    onNumberChange(text: string) {
        this._price = text;
    }

    @action.bound
    onAdd() {
        if (this.isAddable) {
            this._list.addItem({
                name: this._name,
                price: Number(this._price)
            });

            this._name = "";
            this._price = "0";
        }

    }


}

viewを作る

components/ItemAdder.tsx
import {FC} from "react";
import {Observer} from "mobx-react-lite";
import {ItemAdderController} from "../store/ItemAdder";


export const ItemAdder: FC<{ controller: ItemAdderController }> = props => (
    <>
        <Observer>{() =>
            <p>商品名:<input type="text"
                          onChange={
                              event1 => {
                                  props.controller.onNameChange(event1.target.value)
                              }
                          }
                          value={props.controller.name}
            /></p>
        }</Observer>
        <Observer>{() =>
            <p>値段:<input type="number"
                         onChange={
                             event1 => {
                                 props.controller.onNumberChange(event1.target.value)
                             }
                         }
                         value={props.controller.price}
            />
                {props.controller.isNumberError &&
                <span style={{color: "red"}}>入力エラー(数値を入力してください)</span>
                }
            </p>
        }</Observer>
        <Observer>{() =>
            <button disabled={!props.controller.isAddable}
                    onClick={props.controller.onAdd}
            >
                作成
            </button>
        }</Observer>
    </>
);

ポイントは、valueもonChangeも全部MobX任せにすることです。

完成させる

indexに今まで作ったものをまとめましょう。

pages/index.tsx
import {ItemListController} from "../store/ItemList";
import {ItemList} from "../components/ItemList";
import {ItemAdderController} from "../store/ItemAdder";
import {ItemAdder} from "../components/ItemAdder";

const list = new ItemListController();
list.addItem({
    name: "金塊",
    price: 50000
});
list.addItem({
    name: "",
    price: 300000
});

const adder = new ItemAdderController(list);

export default () => (
    <>
        <ItemAdder controller={adder}/>
        <ItemList controller={list}/>
    </>
);

これで冒頭に作ったものが動いているはずです。

まとめ

  • MobXとmobx-react-liteを使えばチャチャっとお買い物リストみたいなアプリを作れる
  • MobXのデコレータを使えるようにするには、少し設定がいる
  • @observable@computedの変化は<Observer>{() =>~}</Observer>で囲めばよしなに更新される
  • configure({enforceActions: "observed"});を入れておくと、@observableの変更を@actionを通さずにすると怒られが発生して嬉しい

続き見たいみたいなのを書きました

11
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?