Record&TupleによるReactの補完
現在TC39のproposalでstage2のRecord&Tupleというproposalがあります。babelのpreset-envやTypescriptのサポートがいつ頃入るかはまだわかりませんが、最近関連propsalも出てたり個人的にはだいぶ盛り上がってるproposalな印象です。
このproposalは要約すると、「JSに言語仕様としてimmutableなObjectや配列を導入しよう」というもので、React始め宣言的UIライブラリやFlux系のライブラリなどにとっては長い間待ち望まれた仕様となりそうです。
この仕様がなぜ待ち望まれたものなのか、そしてこの仕様によってReactがどう変わるのかを簡単にまとめたいと思います。
結論だけ見たい人
こんな感じに書けるようになります。
// []ではなく#[]
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はこちらになります。
要約すると「Objectライク、配列ライクでimmutableなデータ構造を導入しよう」というもので、以下のような特徴を持ちます。
- データ構造の破壊的変更は不可
- 要素として含められるのはプリミティブ要素/Record/Tuple(Objectや配列などは不可)
- 比較(
===
)が参照ではなくデータ構造比較になる
関連proposal1 change-array-by-copy
配列だとArray.prototype.pop
などの破壊的変更によって「popされた値」と破壊後の「popされた後の配列」を得ることができました。TupleではこれらをTuple.prototype.pop
とTuple.prototype.poped
によって得ることができます。
このpoped
などのimmutableメソッド郡ははArrayにあっても便利だよね、という文脈からTupleのproposalから逆輸入する形で「Array.prototype.poped
などを作ろう」というproposalができたのがこのpropsalです。
詳しいことはbabelやprettierのメンテナのsosukesuzukiさんの記事がとても参考になります。
関連proposal2 deep-path-properties-for-record
単純な構造のRecordの宣言の糖衣構文のproposalで、以下のような構造の宣言が可能になります。
const rec = #{ a.b.c: 123 };
assert(rec === #{ a: #{ b: #{ c: 123 }}});
使い所としてはスプレッド演算子を使う場合に以下のようにして合成が可能になります。
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はあるしどうやらそこまで心配なさそうですが、名前的に衝突しちゃうがちょっと気になりますね。
今後Record<>
側の破壊的変更とかありうるのかはちょっと注視したいところです。
まとめ
個人的にはずっと、「immutableJSとかimmerあるし別に仕様として用意しなくても・・・」という気持ちだったのですが、いざstage2まで来て、こうやって書いたりまとめたりすると従来のObjectや配列に対してあった暗黙の了解が取り払われてとてもよかったです。
特に、JSで参照を気にしないといけないことが多いのはReactのハードルを高く難してる一つの要因だと思ってたので、多くの人に受け入れられるモノになっていくのではないかと感じました。
今回書かなかったんですが、Css in JS周りでも最適化に大きく寄与しそうな部分もあって、恩恵を受けるライブラリも結構多い気がしました。
早くstage3になって使いたいところですね。
Discussion