Web開発

Typescript+Reactでエクセルのセルの仕組みを作ってみる


結論

Reactiveモナドっぽいものを作ってReactから使えるようにする

前がき

先日Reactでスプレッドシートを自作する機会があったので、その時に得られた知見を共有しようと思います。

データ構造

表計算の仕組みをどうやって作ろうかと考えた時に頭をよぎったのが、HaskellのReactiveモナドでした。これをTypescriptで書き起こしてみます。

 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
43
44
45
46
47
48
49
export class ReactiveCell {
  private value: number;

  private listeners: Set<() => void>;

  constructor(initialValue: number) {
    this.value = initialValue;
    this.listeners = new Set();
  }

  get(): number {
    return this.value;
  }

  set(newValue: number): void {
    this.value = newValue;
    this.notify();
  }

  subscribe(listener: () => void) {
    this.listeners.add(listener);

    return () => {
      this.listeners.delete(listener);
    };
  }

  private notify(): void {
    this.listeners.forEach((listener) => listener());
  }
  
  static combine(
    cells: ReactiveCell[],
    f: (values: number[]) => number,
  ): ReactiveCell {
    const initialValues = cells.map((cell) => cell.get());
    const combined = new ReactiveCell(f(initialValues));

    cells.forEach((cell) => {
      cell.subscribe(() => {
        const updated = cells.map((c) => c.get());
        combined.set(f(updated));
      });
    });

    return combined;
  }

}
  • getは現在のセルの値を取得します
  • setは現在のセルの値を更新し、notify()を呼び出して自分を参照しているセルに値が変更されたことを通知します
  • combineは複数のセルの値を組み合わせて、新しいセルを作ります。

このデータ構造はこんな感じで利用できます。

1
2
3
4
5
6
7
  const cellA = new ReactiveCell(1);
  const cellB = new ReactiveCell(2);
  const cellC = ReactiveCell.combine([cellA, cellB], ([a, b]) => a + b);

  console.log(cellA.get()) // 1
  console.log(cellB.get()) // 2
  console.log(cellC.get()) // 3

Reactから利用してみる

上記のデータ構造をReactから使ってみます。

React外で管理されている状態は useSyncExternalStore を使うといい感じに扱えます。

https://react.dev/reference/react/useSyncExternalStore

 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
function useCell(cell: ReactiveCell) {
  return useSyncExternalStore((listener) => cell.subscribe(listener), () => cell.get());
}

function CellValue({ cell }: { cell: ReactiveCell }) {
  const value = useCell(cell);
  return <div>{value}</div>;
}

function App() {
  const cellA = new ReactiveCell(1);
  const cellB = new ReactiveCell(2);
  const cellC = ReactiveCell.combine([cellA, cellB], ([a, b]) => a + b);
  
  return (
    <div>
      <CellValue cell={cellA} />
      <CellValue cell={cellB} />
      <CellValue cell={cellC} />
      <button onClick={() => cellA.set(cellA.get() + 1)}>Increment A</button>
      <button onClick={() => cellB.set(cellB.get() + 1)}>Increment B</button>
    </div>
  );
}

export default App

動作を見てみると…

やったぜ

まとめ

今回は表計算の仕組みを作ってReactから利用してみました。これを拡張していけばGoogle Spreadsheetっぽいものが作れると思います。