Skip to content
フレームワークにとらわれない状態変更ロジックの実装例です。
Branch: master
Clone or download
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
apps
case-study
docs
usecase
.editorconfig
.gitignore
README.md
jest.config.js
package-lock.json
package.json
tsconfig.json

README.md

フレームワークにとらわれない状態変更ロジックの実装例です。

状態の持ち方にとらわれない状態変更ロジックと言った方がより適切かもしれません。)

Angular, React.PureComponent, React.Component, Vue.js, Redux, rxjs, MobX など、利用するフレームワークによって状態の持ち方や監視方法が異なりますが、少なくとも状態変更ロジックはフレームワークにとらわれない書き方ができると考えます。

このリポジトリはそのサンプル実装です。ひとつの状態変更ロジックを異なる複数のフレームワークで使い回して、それぞれ同一の挙動をするアプリを作成しています。

状態オブジェクトのイミュータビリティを抽象化するために immer を利用しています。

背景

ブログ記事を参照してください。

解説

はじめに

(コードはシンプルなはずですが、解説が読みづらいせいで難解に感じるかもしれません。すみません。)

スプレッドシートを扱うアプリで、アクティブセルを更新する処理を例にしています。

まずはこの例で扱う状態オブジェクトの型定義を紹介しておきます。

usecase/src/Worksheet.ts

export interface Worksheet {
  readonly maxCellAddress: Readonly<WorksheetCellAddress>
  activeCellAddress: WorksheetCellAddress
}

export interface WorksheetCellAddress {
  rowIndex: number
  columnIndex: number
}

状態変更ロジック

  • 状態変更ロジックを記述するクラスは「状態を変更するロジック(関数)を受け取って実際の更新処理を行う関数 update: (mutate: (state: Worksheet) => void) => unknown」を受け取ります(状態の更新処理を抽象化します)。
  • 状態の変更メソッドでは前述の「実際の更新処理を行う関数 update()」に「オブジェクトの変更ロジック」を渡します update(state => state.xxx.yyy = zzz)
  • このクラスは状態そのものを保持しません。状態の持ち方を利用側の自由にするためです。

usecase/src/WorksheetOperations.ts

export class WorksheetOperations {
  constructor(private readonly update: (mutate: (state: Worksheet) => void) => unknown) {}

  setActiveCellAddress(rowIndex?: number, columnIndex?: number): this {
    this.update(({ maxCellAddress, activeCellAddress }) => {
      if (typeof rowIndex === 'number' && rowIndex >= 0 && rowIndex <= maxCellAddress.rowIndex) {
        activeCellAddress.rowIndex = rowIndex
      }
      if (typeof columnIndex === 'number' && columnIndex >= 0 && columnIndex <= maxCellAddress.columnIndex) {
        activeCellAddress.columnIndex = columnIndex
      }
    })
    return this
  }
}

Vue.js で利用する場合

  • Vue.js のように、状態オブジェクトがミュータブルな前提で使えるフレームワークでは、 update() 関数として単純に mutate => mutate(state) を渡します。
    • state.xxx.yyy = zzz のような普通の変更処理が実行されて、フレームワークが勝手に(Vue.js 2.x なら setter, 3.x なら proxy を介して)反応します。

apps/vuejs-app/src/components/AppWorksheet.vue

export default Vue.component(filename, {
  data() {
    const worksheet = Vue.observable(createWorksheet())
    const worksheetOperations = new WorksheetOperations(
      mutate => mutate(worksheet)
    )
    return { worksheet, worksheetOperations }
  }
}

React.PureComponent (あるいは React.memo() されたコンポーネント)の props に利用する場合

  • 状態オブジェクトがイミュータブルな前提で使う際には、 update() 関数として immer の produce() 関数を介した mutate => setState(state => produce(state, mutate)) のような関数を渡します。
    • produce(state, state => state.xxx.yyy = zzz) が新しいオブジェクトを生成してくれるので、返ってきたオブジェクトを保持するだけです。
  • produce(state, mutate)produce(mutate)(state) とも記述できるので、 React では mutate => setState(state => produce(state, mutate)) の代わりに mutate => setState(produce(mutate)) と記述できます。

apps/react-app/src/Worksheet.tsx

import produce from 'immer'

export default () => {
  const [state, setState] = React.useState(createWorksheet())
  const worksheetOperations = new WorksheetOperations(
    mutate => setState(produce(mutate))
  )
  return (
    <WorksheetTableMemoized
      worksheet={state}
      worksheetOperations={worksheetOperations}
    />
  )
}

Angular (rxjs) で利用する場合

  • 状態を rxjs の BehaviorSubject として持つ場合も、状態をイミュータブルとして扱うので、 React 同様 immer を介します。

apps/angular-app/src/app/worksheet.services.ts

import produce from 'immer'
import { BehaviorSubject } from 'rxjs'

@Injectable()
export class WorksheetService {
  private readonly _worksheet = new BehaviorSubject<Worksheet>(createWorksheet())
  readonly worksheetOperations = new WorksheetOperations(
    mutate => this._worksheet.next(produce(this._worksheet.value, mutate))
  )
}
  • 以上いずれのフレームワークでも、 worksheetOperations.setActiveCellAddress(rowIndx, columnIndex) を呼ぶことで状態が更新(変更または生成)されて画面に反映されます。

おとしあな・制限

ケーススタディ

スケールアップ: 状態オブジェクトの統合

複数の状態を統合したくなったら、次の CombinedStateCombinedStateOperations のような形でシンプルに実現できます。統合元の状態更新ロジックに手を加える必要はありません。(逆に言えば、状態更新ロジックは任意の切り口で分割できます。)

case-study/CombinedState.ts

export type Update<T> = (mutate: (state: T) => void) => unknown
export interface FirstState { /* ... */ }

export class FirstStateOperations {
  constructor(private readonly update: Update<FirstState>) {}

  /* ... */
}
export interface SecondState { /* ... */ }

export class SecondStateOperations {
  constructor(private readonly update: Update<SecondState>) {}

  /* ... */
}
export interface CombinedState {
  first: FirstState
  second: SecondState
}

export class CombinedStateOperations {
  readonly first = new FirstStateOperations(mutate => this.update(state => mutate(state.first)))
  readonly second = new SecondStateOperations(mutate => this.update(state => mutate(state.second)))

  constructor(private readonly update: Update<CombinedState>) {}
}

非同期処理

状態の更新が必要なタイミングで都度 update() を呼ぶようにします。非同期処理前に取得した state は非同期処理後には使えません。

case-study/AsyncState.ts

export interface AsyncState {
  url: string
  loading: boolean
  value?: any
  error?: any
}

export class AsyncOperations {
  constructor(private readonly update: (mutate: (state: AsyncState) => void) => unknown) {}

  async load() {
    let url: string | undefined
    this.update(state => {
      if (state.loading) {
        throw Error('loading yet.')
      }
      url = state.url
      state.loading = true
      state.value = undefined
      state.error = undefined
    })

    try {
      if (!url) {
        throw Error('url is not specified.')
      }
      const response = await fetch(url)
      const value = await response.json()
      this.update(state => {
        state.loading = false
        if (response.ok) {
          state.value = value
        } else {
          state.error = { status: response.status, value }
        }
      })
    } catch (error) {
      this.update(state => {
        state.loading = false
        state.error = error
      })
    }
  }
}
You can’t perform that action at this time.