Chapter 07

第6章 Reduxでstate管理 前編 -設計・インストール・items state-

てべすてん
てべすてん
2022.03.11に更新

用語のおさらい

設計にて登場した用語をおさらいしておきます。
さらーと流してください。

記号

  • 1つの流れ図記号のこと。これらを線で結ぶとフローチャートが出来上がる。
  • プログラムではsym(記号の英語symbolの頭文字)と呼ぶ

フロー

  • 記号の集まり。
  • 1つのフローは0以上の記号を持つ。つまりすべての記号は1つの親フローを持つ
  • プログラムではflowと呼ぶ。

アイテム

  • 記号やフローのこと。記号とフローはプログラムを作成するうえで似たようなものなので2つを抽象化してアイテムという名前を付けた。
  • プログラムではitemと呼ぶ

Reduxを使う訳

Reduxを使えばStateを1カ所に集めることができます。
今回はこのStateにアイテムをまとめていろいろなところから参照できるようにした方が効率がいいと考えたからです。(propsで頑張って渡すのはつらいでしょう..)

アイテムの設計

プログラムで扱うアイテムにはどのようなものが必要か考えます。TypeScript風に書くと

アイテムの持つ情報
1つのアイテム = {
  itemType:string;  //タイプによって表示の仕方(長方形なのかひし形なのかなど)を変える
}

となりました。

つづいて記号の持つ情報を抽出します。

記号の持つ情報
1つの記号 = アイテム & {
  options: /* オプションの情報を配列で保持 */
}

オプションとは記号に設定できる追加情報のこととします。具体的には以下のような情報を持ちます。

オプションの持つ情報
1つのオプション = {
  name:string;
  value:/* 数値や文字、論理値、配列などなど */
}

つまり記号は次のように設計することになります。

記号の持つ情報
1つのアイテム = {
  itemType : string ,
  options : { name:string,value:any }[] ,
} 
//※1つのアイテム.options[].value はいろいろな値が入るのでとりあえずanyにしておきました。

これで記号は大丈夫でしょう。

最後にフローを考えます。

Reduxのstateを設計

ここでstateツリーを考えてみます。
こんな感じで考えてます。とりあえずこれでStoreをつくり、新しいデータを追加したくなったらこれらに追加していく形で行こうと思います。

  • items
    • アイテムの一覧を保持する
    • アイテムIDをキー、アイテムを値にしたオブジェクトとして管理する
itemsの例
{
  "item-id-001" : { /*アイテムの情報*/ } ,
  "item-id-002" : { /*アイテムの情報*/ } ,
  ...
}
  • meta
    • フローチャートの情報を保持する(タイトルなど)
metaの例
{
  title : "面積計算プログラム",
  flows:[  //画面に表示するフローのID
    "item-id-003",
    "item-id-008",
    ...
  ],
  ...
}
  • app
    • アプリケーションに関する情報を保持する(UIに関する情報など。リロードすると消えてもいい情報を保持する)
    • 必要になり次第どんどん増やしていこうと思っています。
appの例
{
  /*いまは特にないのでとりあえず空にしておきます*/
}

Actionを設計

個人的にReduxにおけるActionはStateの変更依頼だと思っています。Actionを設計するとはStateを変更するのはどんな時があるのか考えることだと思いますので、各Stateをどのように変更するのか考えてみましょう。

  • items
    • アイテムを追加(更新)する
    • アイテムを削除する
    • アイテムを複製して1つ後に配置する
    • アイテムのオプションを設定する
  • meta
    • タイトルを設定する
    • flowsにフローを追加する
  • app
    • とりあえずなし

いよいよ実装

以下の手順で進めます。

  1. インストール

  2. store作成

  3. 各stateの実装

    1. type
    2. reducer
    3. selector
    4. action
  4. ↑を各stateごと(items,meta,app)に繰り返していきます。

  5. reactに流し込みます

  6. 各stateのhookを作っていきます。

インストール

Ctrl+Cで開発サーバを止めてから)次のコマンドを実行してください。

Redux関連のインストール
npm i redux react-redux typescript-fsa typescript-fsa-reducers 

store作成

Reduxに関するファイルはプロジェクトルート/src/redux内にRe-ducksパターン(もどき)で配置していくことにします。

まずはstoreの作成です。プロジェクトルート/src/redux/store.tsに置きます。

プロジェクトルート/src/redux/store.ts
import { combineReducers, createStore } from "redux" ;

export const rootReducer = combineReducers({
  //ここに各stateのreducerを設定
}) ;
export const store = createStore(rootReducer) ;
export type StoreState = ReturnType<typeof store.getState> ;

やっていること
  1. rootReducerを定義(各reducerをくっつける)
  2. storeを作成
  3. store の state を定義する

各stateの実装

items state

まずはこのアプリで一番重要なstate,items stateを実装します。

  1. type
  2. reducer
  3. selector
  4. action

これらをプロジェクトルート/src/redux/items/内に

  1. types.ts
  2. reducers.ts
  3. selectors.ts
  4. actions.ts

に定義していきます。また定義したらstore.ts内のrootReducerにreducerを追加するのも忘れずに。。。

items/types.ts

ここではitemの型を定義します。用語のおさらいで触れたとおり、アイテムの方は少々やることが多いですがやっていきましょう。

まずはアイテムIDです。アイテムIDは全アイテムで被らない文字列です。なので型はstring型。

プロジェクトルート/src/redux/items/types.ts

export type ItemId = string ;

「わざわざ型エイリアス作らなくてもいいでしょ」と思うかもしれませんが、TypeScriptが進化して10文字の文字列みたいな指定ができるようになったらより強固な型を作れるようになった時に備えてあえてエイリアスにしています。(状のstring型だと"001も"item-001"でもなんでも入れることができてしまいます)

続いて本命のアイテム・記号・フローの型を実装します。
(アイテムの設計も参考にしてください)

プロジェクトルート/src/redux/items/types.ts

export type ItemId = string ;

//以下を追加

export interface Item {
    itemType:string;
}

export interface Sym extends Item {
    options: any[] /* とりあえず */;
}

export interface Flow extends Item {
    childrenSyms:ItemId[] ;
}

Symのオプションをanyにしておきましたが、ここを詳しく取り上げます。

オプションはアイテムの設計でふれたとおり下のようになっています。

オプションの持つ情報
1つのオプション = {
  name:string:;
  value:/* 数値や文字、論理値、配列などなど */
}

つまりオプションは

オプションの型
export interface Option {
  name:string;
  value: string | number | boolean ;/*後で追加するかも*/ 
}

となります。
ここでOption.valueはほかにも追加するかもしれませんので別途,型として定義しなおします。

Option.valueの型
export type OptionValue = string | number | boolean ;

以上より記号周りの型は次のようになります。

記号の型

export type ItemId = string ;

export type OptionValue = 
    string | number | boolean ;
export interface Option {
    name:string;
    value:OptionValue ;
}

export interface Sym extends Item {
    options:Option[];
}

あとアイテムの一覧はキーをItemId型,値をItem型としたオブジェクトとして管理したいので、Items型として定義しておきます。

Items型

export type Items = {
    [key:ItemId] :Item ;
} ;

これでtypes.tsは完成しました。

ごちゃごちゃしてきたのでここまでのコードは下にまとめてあります。

ここまでのソースコード
プロジェクトルート/src/redux/items/types.ts

export type ItemId = string ;

export type OptionValue = 
    string | number | boolean | 
    string[] | number[] | boolean[] ;
export interface Option {
    name:string;
    value:OptionValue ;
}

export interface Item {
    itemType:string;
}

export interface Sym extends Item {
    options:Option[];
}

export interface Flow extends Item {
    childrenSyms:ItemId[] ;
}

export type Items = {
    [key:ItemId] :Item ;
} ;

items/reducers.ts

つづいてReducerの作成です。
といっても詳しい実装はactionの実装時にお任せするので今はとりあえずひな形だけ作っておきましょう。

プロジェクトルート/src/redux/items/reducers.ts
import { reducerWithInitialState } from "typescript-fsa-reducers";
import { Items } from "./types";

export const init :Items = {} ;

export const items = reducerWithInitialState(init) ;
  /* ここにcaseメソッドを使ってactionに対するstateの更新処理を定義していく */

items/selectors.ts

つづいてstateから値を取得するときに使用するselectorを定義していきます。

今回は1つのアイテムを取得したいのか、すべてのアイテムを取得したいのかでselectorを分けたいと思うので、それぞれgetItemgetItemsとして切り分けていきたいと思います。

プロジェクトルート/src/redux/items/selectors.ts
import { StoreState } from "redux/store";
import { ItemId } from "./types";


//1つのアイテムを参照したいときのselector。どのアイテム化を指定するためにitemIdを渡す
export const getItem = (itemId:ItemId)=>{
    return (state:StoreState)=>state.items[itemId] ;
} ;

//複数のアイテムを参照したいときのselector。引数を省略するとすべてのアイテムを取得
export const getItems = (itemIds?:ItemId[])=>{
    if(itemIds){
        //引数の指定があった場合はitemIdsに含まれるアイテムを取得
        return (state:StoreState)=>{
            return Object.entries(state.items).reduce((items, [itemId,item])=>{
                if(itemIds.includes(itemId)){
                    items[itemId] = item ;
                }
                return items ;
            }, {} as Items) ;
        } ;
    }
    return (state:StoreState)=>state.items ;
} ;

selectorっていつ使うの?

selectorは各コンポーネントで次のように使います。

...
const item = useSelector(getItem("item-id-001")) ;
...

items/actions.ts

最後にactionを実装していきます。

設計では

  • items
    • アイテムを追加(更新)する
    • アイテムを削除する
    • アイテムを複製して1つ後に配置する
    • アイテムのオプションを設定する

と設計したので上記4つをActionCreatorとして実装しましょう。今回はtypescript-fsaを使用しているので、typescript-fsaactionCreatorFactoryメソッドでActionCreatorを定義していきましょう。

プロジェクトルート/src/redux/items/actions.ts
import actionCreatorFactory from "typescript-fsa" ;
import { Item, ItemId } from "./types";

const actionCreator = actionCreatorFactory() ;

//いつ使うのかわからないけど公式ドキュメントに載っていたのでおいておきます
export const init = actionCreator("items/init");
//アイテムの追加(更新)
export const set = actionCreator<{ itemId:ItemId,item:Item, }>("items/set");
//アイテムの削除
export const remove = actionCreator<{ itemId:ItemId,item:Item, }>("items/remove");

actionCreator関数

actionCreator関数の型引数にはアクションのpayloadの型を指定します。
例えば

export const remove = actionCreator<{itemId:string}>("items/remove");//アイテムの追加(更新)

は、

set({itemId:"item-id-001"})

と呼び出すと

{
  itemId:"item-id-001",
}

を返します。

actionを定義できたのでreducersにactionsごとのstate変更処理を書いていきましょう。

プロジェクトルート/src/redux/items/reducers.ts
import { reducerWithInitialState } from "typescript-fsa-reducers";
import * as actions from "./actions" ;
import { Items } from "./types";

export const init :Items = {} ;

export const items = reducerWithInitialState(init)
    //以下追加
    .case( actions.set ,(state,payload)=>{
        const newState = {
            ...state,
            [payload.itemId] : payload.item,
        } ;
        return newState ;
    })
    .case( actions.remove ,(state,payload)=>{
        let newState = {...state} as Items ;
        let isUpdate = false ;
        //アイテム一覧から削除
        const keys = Object.keys(state)
        if(keys.includes(payload.itemId)){
            isUpdate = true ;
            delete newState[payload.itemId] ;
        }
        //戻り値
        if(isUpdate){
            return newState ;
        }
        return state ;
    })

typescript-fsaを使うときのreducerの定義は

export const reducer = reducerWithInitialState(<<initialState>>)
  .case( <<action 1>> , 更新関数 )
  .case( <<action 2>> , 更新関数 )
  ...

と定義します。

それぞれの更新関数の処理については以下を参照してください。

actions.set
.case( actions.set ,(state,payload)=>{
	const newState = {
	    ...state,
	    [payload.itemId] : payload.item,
	} ;
	return newState ;
})

これは簡単ですね。
まず{...state,}でstateのコピーを作ります。
その後[payload.itemId] : payload.itemで新しいitemを追加します。

actions.remove

準備中です。

storeに追加

items stateを実装できたので動作確認するためにreactに流し込んで動作確認してみましょう。

プロジェクトルート/src/redux/store.ts
  import { combineReducers, createStore } from "redux" ;
+ import { items } from "redux/items/reducers" ;

  export const rootReducer = combineReducers({
+     items,
  }) ;
  export const store = createStore(rootReducer) ;
  export type StoreState = ReturnType<typeof store.getState> ;

Reactに流し込む

とりあえずApp.tsxでitems stateを表示します。

プロジェクトルート/src/App.tsx
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Grid from '@mui/material/Grid';
import BuildPanel from 'components/App/BuildPanel';
import Header from 'components/App/Header';
import Sidebar from 'components/App/Sidebar';
import { FC, } from 'react';
import { useSelector } from 'react-redux';  //追加
import { getItems } from 'redux/items/selectors';  //追加

import "./App.css" ;

interface AppProps{}
const App :FC<AppProps> = ()=>{
  const items = useSelector(getItems()) ;  //追加
  return (
    <Grid container>
      <Grid item xs={12}>
        <Header />
      </Grid>
      <Grid item xs>
        <Card>
          <CardContent>
            <BuildPanel />
            {JSON.stringify(items)}  //追加
          </CardContent>
        </Card>
      </Grid>
      <Grid item xs="auto">
        <Card>
          <CardContent>
           <Sidebar />
          </CardContent>
        </Card>
      </Grid>

    </Grid>
  ) ;
};

export default App;

まだ何も追加していないので{}ですがアイテムを追加したら増えていくはずです。ボタンを押したら追加するようにしましょう。

プロジェクトルート/src/App.tsx
  import Card from '@mui/material/Card';
  import CardContent from '@mui/material/CardContent';
  import Grid from '@mui/material/Grid';
  import BuildPanel from 'components/App/BuildPanel';
  import Header from 'components/App/Header';
  import Sidebar from 'components/App/Sidebar';
+ import Button from 'components/util/Button';
  import { FC, } from 'react';
+ import { useDispatch, useSelector } from 'react-redux';
+ import * as itemAction from 'redux/items/actions';
  import { getItems } from 'redux/items/selectors';

  import "./App.css" ;

  interface AppProps{}
  const App :FC<AppProps> = ()=>{
  const items = useSelector(getItems()) ;

+   const dispatch = useDispatch() ;
+   function handleClick (){
+     const itemId = "test-item-id-"+Math.floor(Math.random()*1000) ;
+     dispatch(itemAction.set({
+       itemId,
+       item:{
+         itemType:"test-item",
+       },
+     }));
+   }
    return (
      <Grid container>
        <Grid item xs={12}>
          <Header />
        </Grid>
        <Grid item xs>
          <Card>
            <CardContent>
              <BuildPanel />
              {JSON.stringify(items)}
+             <Button onClick={handleClick}>
+               アイテムの追加
+             </Button>
            </CardContent>
          </Card>
        </Grid>
        <Grid item xs="auto">
          <Card>
            <CardContent>
             <Sidebar />
            </CardContent>
          </Card>
        </Grid>

      </Grid>
    ) ;
  };

  export default App;

itemsに追加が出来ているのが確認できます。

次の章でmeta,appも同様に実装していきましょう。

App.tsxは元に戻しておきましょう。

プロジェクトルート/src/App.tsx
import Card from '@mui/material/Card';
import CardContent from '@mui/material/CardContent';
import Grid from '@mui/material/Grid';
import BuildPanel from 'components/App/BuildPanel';
import Header from 'components/App/Header';
import Sidebar from 'components/App/Sidebar';
import Button from 'components/util/Button';
import { FC, } from 'react';

import "./App.css" ;

interface AppProps{}
const App :FC<AppProps> = ()=>{
  return (
    <Grid container>
      <Grid item xs={12}>
        <Header />
      </Grid>
      <Grid item xs>
        <Card>
          <CardContent>
            <BuildPanel />
          </CardContent>
        </Card>
      </Grid>
      <Grid item xs="auto">
        <Card>
          <CardContent>
           <Sidebar />
          </CardContent>
        </Card>
      </Grid>

    </Grid>
  ) ;
};

export default App;

質問・指摘などはこちらから

https://zenn.dev/tbsten/scraps/7123b1257c2097