はじめに
この記事ではTypescript(.tsx)でNext.js@canaryのサーバーを立て、MobXの動くものをちょいと作ります。
- これはとりあえず動くものを素早く作るためのチュートリアルです
- Typescriptを手っ取り早く設定するために、執筆時点ではNext.jsのcanaryバージョンを使います
想定読者
以下のいずれか
- TypescriptとReactが読める
- これを読んだ人:なる早でTypescriptで(勉強用の)Next.jsサーバーを立ち上げる
今日作るもの
できたやつ: 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
{
"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"
}
}
.babelrc
あとあと、クラスデコレータを使うので".babelrc"を作成して、編集しておく、preset:"next/babel"
はnext@canary
に付いてくる。
{
"presets": [
"next/babel",
"mobx"
]
}
indexページの作成
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
にこのオプションを挿す。
{
"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`
{
"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,
を挿すとこうなる
{
"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を作る
storeを作る
まずは、データを取り扱うstoreから手をつけます。store/Item.ts
を新規作成しましょう。
IDEで補完をよしなにしてもらうためにまずは型から書きます。
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を書いていきましょう。
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
だとthis
がbind
されないので面白くないです。
storeをとりあえず使ってみる
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
これがMobXとmobx-react-liteの威力です。<Observer>{() =>
~}</Observer>
で囲った要素は@observable
および、@computed
な値の変化があれば適切に更新されます。
一通り動きがわかったところで、見て呉れを良くしましょう。
viewを作る
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>
);
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
品物のリストを作る
storeを作る
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);
}
}
IObservableArray
はobservable(array)
で作ることができる、observableな配列です。名前の通りですね。IObservableArray
はそれ自体がobservableなので、@observable
をつけなくても動きます。なお、つけても動きます。
参考: MobX公式ドキュメント:Array
このIObservableArray
は通常のArray
に加えていくつかの関数が生えており、特段に便利なのは、remove(value)
です。これはIObservableArray
の子要素を渡せば、その子要素を消す関数です。
とりあえずstoreを使ってみる
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>
</>
)
ほぼほぼ完成ですが、一応これもコンポーネントにしましょう。
viewを作る
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を作ってみる
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>
}
</>
)
};
Provider
とuseContext
を使って削除ボタンを作ることができました。これを、Item.tsx
に挿しておきましょう。また、これはProvideがないときには表示されません。
参考: 条件付きレンダー - React #論理 && 演算子によるインライン If
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>
);
動作確認用に適当に使ってみます。
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
品物追加コンポーネントを作る
これでラストです。
storeを作る
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を作る
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に今まで作ったものをまとめましょう。
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
を通さずにすると怒られが発生して嬉しい