コンテンツにスキップ

カウンターアプリでビュー・ロジック・状態の管理を考える

この記事では React で以下のようなカウンターアプリを作るとき、ビュー・ロジック・状態をどう管理するか考えてみる。

用語について

ビュー

この記事ではカウンターアプリの見た目の部分をビューと呼ぶことにする。

ロジック

この記事ではカウンターアプリの 1 足す処理やリセットする処理をロジックと呼ぶことにする。

状態

この記事ではカウンターアプリの現在の値を状態と呼ぶことにする。

1. ビュー・ロジック・状態をコンポーネントで管理する

簡単に実装できる。状態が増えたりロジックが長くなるとメンテナンスしにくくなる。でも小規模ならこれで良い。

1
2
3
4
5
6
.
└── src
     ├── App.tsx
     ├── counter
     │   └── Counter.tsx
     └── main.tsx
src/main.tsx
1
2
3
4
5
6
7
8
9
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.tsx";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>
);
src/App.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
import { Counter } from "./counter/Counter";

// 画面相当のコンポーネント
export function App() {
  return (
    <>
      <Counter />
    </>
  );
}
src/counter/Counter.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
import { useState } from "react";

// コンポーネント
export function Counter() {
  // 状態
  const [value, setValue] = useState(0);

  // ロジック
  function increment() {
    setValue((value) => value + 1);
  }

  // ロジック
  function reset() {
    setValue(() => 0);
  }

  // ビュー
  return (
    <>
      <p>{value}</p>
      <button onClick={increment}>Increment</button>
      <button onClick={reset}>Reset</button>
    </>
  );
}

なおsrc/counter/Counter.tsxについて、別解としてuseStateでなくuseReducerを使う方法もある。useStateがたくさんになってくると状態を一元管理したくなる。useReducerは状態の変更を関数一つで管理できるので、そういうとき役に立つかもしれない。

src/Counter.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import { useReducer } from "react";

export interface CounterState {
  value: number;
}

export interface CounterAction {
  type: string;
}

// コンポーネント
export function Counter() {
  function reducer(state: CounterState, action: CounterAction) {
    switch (action.type) {
      case "increment": {
        // ロジック
        return {
          value: state.value + 1,
        };
      }
      case "reset": {
        // ロジック
        return { value: 0 };
      }
      default: {
        throw new Error("Invalid action");
      }
    }
  }

  // 状態
  const [state, dispatch] = useReducer(reducer, { value: 0 });

  // ビュー
  return (
    <>
      <p>{state.value}</p>
      <button onClick={() => dispatch({ type: "increment" })}>Increment</button>
      <button onClick={() => dispatch({ type: "reset" })}>Reset</button>
    </>
  );
}

useStateを使うかuseReducerを使うかは好みで決めて良い。

ここまでのイメージは以下のような感じである。

以下useStateを使って、ビュー・ロジック・状態を分離できるのか考えることにする。

2. ロジックをカスタムフックに移す

見た目は場合によって変えたくなるだろうし、ビューとロジックは分けたほうが良さそうだ。

React ではロジックをカスタムフックに移すことができる。

カスタムフックはuseXxx()と命名する。

1
2
3
4
5
6
7
.
└── src
     ├── App.tsx
     ├── counter
     │   ├── Counter.tsx
     │   └── useCounter.ts
     └── main.tsx
src/useCounter.ts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { useState } from "react";

export interface ICounter {
  value: number;
  increment(): void;
  reset(): void;
}

// カスタムフック
export function useCounter(): ICounter {
  const [value, setValue] = useState(0);

  // ロジック
  function increment() {
    setValue((value) => value + 1);
  }

  // ロジック
  function reset() {
    setValue(() => 0);
  }

  return { value, increment, reset };
}
src/Counter.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
import type { ICounter } from "./useCounter";

// コンポーネント
export function Counter() {
  // 状態
  const { value, increment, reset } = useCounter();

  // ビュー
  return (
    <>
      <p>{value}</p>
      <button onClick={increment}>Increment</button>
      <button onClick={reset}>Reset</button>
    </>
  );
}

スコープの面で、状態を保持しているのはCounterコンポーネントと考えてよいだろう。

ここまでのイメージは以下のような感じである。

Counterコンポーネントは状態とビューを管理し、useCounter()はロジックを管理するという棲み分けを行うことができた。

3. リフトアップを使って状態を他のコンポーネントに共有できるようにする

状態を他のコンポーネントに共有したくなることがある。こういうとき React ではリフトアップするのが基本となる。

カスタムフックでロジックを再利用する – React

複数のコンポーネント間で state 自体を共有する必要がある場合は、リフトアップして下に渡すようにしてください。

リフトアップとは、上位のコンポーネントに何かを移動させることである。

これに従ってuseCounter()を上位のコンポーネントであるAppに移動させてみる。

src/counter/Counter.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
import type { ICounter } from "./useCounter";

// コンポーネント
export function Counter({ value, increment, reset }: ICounter) {
  // ビュー
  return (
    <>
      <p>{value}</p>
      <button onClick={increment}>Increment</button>
      <button onClick={reset}>Reset</button>
    </>
  );
}
src/App.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { Counter } from "./counter/Counter";
import { useCounter } from "./counter/useCounter";

// 画面相当のコンポーネント
export function App() {
  // 状態
  const counter = useCounter();

  return (
    <>
      <Counter
        value={counter.value}
        increment={counter.increment}
        reset={counter.reset}
      />

      {/* Counterコンポーネント以外も状態を参照できる */}
      <p>{counter.value}</p>
    </>
  );
}

ここまでのイメージは以下のような感じである。

ビュー・ロジック・状態を分けて管理することができた。

4. コンテクストを使って状態を他のコンポーネントに共有できるようにする

上で終わらせても構わないが、少し気になるところがある。

もし小画面の子画面のコンポーネントに状態を渡す場合、props 経由で状態を渡して、また props 経由で状態を渡して、ということをしないといけない。(これを俗に props のバケツリレーという)

これを面倒くさいと思う人の為に、React はコンテクストという仕組みを用意している。

状態をコンテクストで共有し、コンポーネントにコンテクスト経由で状態を取得させてみる。

1
2
3
4
5
6
7
8
.
└── src
     ├── App.tsx
     ├── counter
     │   ├── Counter.tsx
     │   ├── CounterContext.tsx
     │   └── useCounter.ts
     └── main.tsx
src/counter/CounterContext.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import { createContext } from "react";
import { useCounter, type ICounter } from "./useCounter";

const defaultCounter: ICounter = {
  value: 0,
  increment: () => {
    throw new Error("Not implemented");
  },
  reset: () => {
    throw new Error("Not implemented");
  },
};

// コンテクスト
export const CounterContext = createContext(defaultCounter);

// コンテクストプロバイダ
export function CounterProvider({ children }: { children: React.ReactNode }) {
  // 状態
  const counter = useCounter();

  return <CounterContext value={counter}>{children}</CounterContext>;
}
src/App.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
import { use } from "react";
import { Counter } from "./counter/Counter";
import { CounterContext } from "./counter/CounterContext";

// 画面相当のコンポーネント
export function App() {
  const counter = use(CounterContext);

  return (
    <>
      <Counter
        value={counter.value}
        increment={counter.increment}
        reset={counter.reset}
      />

      {/* Counterコンポーネント以外も状態を参照できる */}
      <p>{counter.value}</p>
    </>
  );
}
src/main.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App.tsx";
import { CounterProvider } from "./counter/CounterContext.tsx";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <CounterProvider>
      <App />
    </CounterProvider>
  </StrictMode>
);

ここまでのイメージは以下のような感じである。

実装は少し面倒だが設計はすっきりしている。状態をアプリケーション全体で共有したい場合はコンテクストを使うといいかもしれない。

リポジトリ

GitHub - kkawaharanet/react-counter