Record&TupleによるReactの補完

5 min read読了の目安(約4600字

現在TC39のproposalでstage2のRecord&Tupleというproposalがあります。babelのpreset-envやTypescriptのサポートがいつ頃入るかはまだわかりませんが、最近関連propsalも出てたり個人的にはだいぶ盛り上がってるproposalな印象です。
このproposalは要約すると、「JSに言語仕様としてimmutableなObjectや配列を導入しよう」というもので、React始め宣言的UIライブラリやFlux系のライブラリなどにとっては長い間待ち望まれた仕様となりそうです。

この仕様がなぜ待ち望まれたものなのか、そしてこの仕様によってReactがどう変わるのかを簡単にまとめたいと思います。

執筆段階ではstage2(ドラフト)段階のproposalの仕様なので、今後変更が行われることもありえますのでご了承ください。

結論だけ見たい人

こんな感じに書けるようになります。

// []ではなく#[]
const [todos, setTodos] = useState(#[initialTodo]);
// pushとかスプレッドではなくpushed
const addTodo = (newTodo) => setTodos(todos.pushed(newTodo))

useStateなどの問題

例として以下のようなTodoのコンポーネントがあるとします。

type Todo = {
  title: string
}

const initialTodo: Todo = {
  title: 'initial todo items',
}

const TodoApp = () => {
  const [todos, setTodos] = useState<Todo[]>([initialTodo]);

  return (
    <>
      <h1>Todo Apps</h1>
      <ul>
        {todos.map((todo: Todo) => (
          <li>{todo.title}</li>
        ))}
      </ul>
      <button onClick={() => setTodos([])}>reset</button>
    </>
  );
}

useStateした値に対し、代入するとreadonly系のエラーが発生します。

const [todos, setTodos] = useState<Todo[]>([initialTodo]);

todos = []

ところが、todosに対してpushなどの破壊的メソッドを呼び出すことはできます。

const [todos, setTodos] = useState<Todo[]>([initialTodo]);

// todos = []
todos.push({
  title: 'pushed todo items',
})

これだとリセットボタンを押下した際にtodosがちゃんと空配列にならないなど、いわゆる副作用的問題が発生します。
これはもちろん、多くの人が認識してるReactアンチパターンであり「そもそもこういう実装はしないよ」と言う人は多いことと思います。ただし、アンチパターンでありながら容易に実装できてしまうのはやはり望ましい状態ではありません。

この問題がRecord&Tupleによって改善されようとしています。

Record&Tuple proposal

tc39のproposalはこちらになります。

https://github.com/tc39/proposal-record-tuple

要約すると「Objectライク、配列ライクでimmutableなデータ構造を導入しよう」というもので、以下のような特徴を持ちます。

  • データ構造の破壊的変更は不可
  • 要素として含められるのはプリミティブ要素/Record/Tuple(Objectや配列などは不可)
  • 比較(===)が参照ではなくデータ構造比較になる

関連proposal1 change-array-by-copy

配列だとArray.prototype.popなどの破壊的変更によって「popされた値」と破壊後の「popされた後の配列」を得ることができました。TupleではこれらをTuple.prototype.popTuple.prototype.popedによって得ることができます。
このpopedなどのimmutableメソッド郡ははArrayにあっても便利だよね、という文脈からTupleのproposalから逆輸入する形で「Array.prototype.popedなどを作ろう」というproposalができたのがこのpropsalです。

https://github.com/tc39/proposal-change-array-by-copy

詳しいことはbabelやprettierのメンテナのsosukesuzukiさんの記事がとても参考になります。

https://sosukesuzuki.dev/posts/change-array-by-copy/

関連proposal2 deep-path-properties-for-record

単純な構造のRecordの宣言の糖衣構文のproposalで、以下のような構造の宣言が可能になります。

const rec = #{ a.b.c: 123 };
assert(rec === #{ a: #{ b: #{ c: 123 }}});

https://github.com/tc39/proposal-deep-path-properties-for-record

使い所としてはスプレッド演算子を使う場合に以下のようにして合成が可能になります。

const state2 = #{
  ...state1,
  counters[0].value: 2,
  counters[1].value: 1,
};
assert(state2.counters[0].value === 2);

Reactがどう変わるのか

useState

先述の通り、以下のように書くことが増え、従来の破壊的メソッドによるアンチパターンではそもそも書けなくなります。

const [todos, setTodos] = useState(#[initialTodo]);
const addTodo = (newTodo) => setTodos(todos.pushed(newTodo))
// ↓ではtodosは変わらない
todos.push({
  title: 'pushed todo items',
})
// 代入しようとするとReadonlyな旨のTypeError
todos[0] = {
  title: 'mutate todo items',
}

Reactのrender周りの最適化

また、Record&Tupleは===で構造比較をするので、従来のObjectの参照レベルの比較と比べ再レンダリングの最適化が比較的容易になります。
例えば以下のようなコンポーネントの場合、「categoryのObjectが参照レベルで同じかどうか」というのを気にしないとレンダリングの最適化ができない場合がありました。

const TodoCategoryName = React.memo(({ todo }) => (
  <>{todo.category.name}</>
))

これはReact.memoがデフォルトだとshallow equalで比較するので、第一階層のcategoryが===されるため、第二引数でちゃんとObjectの構造比較をする必要がありました。Recordが使えれば、ここで渡すtodoをRecordにするだけでレンダリングが「todoが構造的に異なる場合のみ」になるのでReact.memoの第二引数やObjectの参照を考える必要がなくなります。

Reduxのreducerなどもより簡潔に

React以外にも従来からimmutableなアプローチを重視してたReduxなどのFlux系ライブラリでimmerなしで書く場合、以下のようなスプレッド地獄が起きがちでしたが、だいぶシンプルに書けるようになりそうです。

// 従来のreducer
addTodo: (state, { newTodo }) => ({
  ...state,
  user: {
    ...state.user,
    todos: [
      ...state.user.todo,
      newTodo,
    ]
  }
})
// Record&Tupleでのreducer
addTodo: (state, { newTodo }) => ({
  ..state,
  user.todos: todos.pushed(newTodo),
})

TypescriptのRecord<>との衝突

すでにissueはあるしどうやらそこまで心配なさそうですが、名前的に衝突しちゃうがちょっと気になりますね。

https://github.com/microsoft/TypeScript/issues/39831

今後Record<>側の破壊的変更とかありうるのかはちょっと注視したいところです。

まとめ

個人的にはずっと、「immutableJSとかimmerあるし別に仕様として用意しなくても・・・」という気持ちだったのですが、いざstage2まで来て、こうやって書いたりまとめたりすると従来のObjectや配列に対してあった暗黙の了解が取り払われてとてもよかったです。
特に、JSで参照を気にしないといけないことが多いのはReactのハードルを高く難してる一つの要因だと思ってたので、多くの人に受け入れられるモノになっていくのではないかと感じました。
今回書かなかったんですが、Css in JS周りでも最適化に大きく寄与しそうな部分もあって、恩恵を受けるライブラリも結構多い気がしました。

早くstage3になって使いたいところですね。