VueからReact
普段はVueを使うことが多いです。
Reactも使ったことがあったけれど、最近、深く理解できていないことに気づいたので、年末年始の宿題として、Reactを勉強して理解したことを書いていこうと思います。
過程なので間違いなどあるかもしれません。
Reactのコンポーネントは2種類に大別できる
まず、基本となるuseStateフックだけを使ったアプリを考えてみます。
するとコンポーネントは2種類に大別できます。
- 状態を持つコンポーネント
- 状態を持たないコンポーネント
Vueもあまり意識はしていなかったけれど、同じように2つに分かれていたような気がします。しかし、Reactのようにはっきりと区分けされていなかったと思います。
Vueは1つのコンポーネントにref()
とprops
が共存していても違和感が無かったのですが、ReactだとNGになる場合があります。
状態を持つコンポーネント
状態を持つコンポーネントはこんな感じです。
function App() {
let [x, setX] = useState(0)
return <button onClick={() => setX(x+1)}>{x}</button>
}
特徴
- 引数(プロパティ)は必須ではない
- 再利用しにくい
状態を持たないコンポーネント
状態を持たないコンポーネントは、プロパティを受け取りJSXを返す純粋関数になっています。
function DisplayX({ x }) {
return <>{x}</>
}
特徴
- 引数(プロパティ)は必須
- 再利用できる
状態を持つコンポーネントをステートフルコンポーネント、状態を持たないコンポーネントをステートレスコンポーネントあるいはVueでは関数型コンポーネントといいます。
Reactのコンポーネントはただの関数
Vue.jsもReactもコンポーネント指向ですが、Vue.jsはステートフルコンポーネントが主なのに対して、Reactはステートレスコンポーネントが主になっているように思います。
例えば次のJSXがあるとします。
<section>
<h1>Title</h1>
<MainContent text={text} />
</section>
このとき、ステートフルコンポーネントの観点からするとMainContentというコンポーネントがそこに存在しているのだと考えてしまいます。
しかし、ステートレスコンポーネントの観点としては、関数MainContent()
に引数text
を渡して、結果をそこに展開するということになります。コンポーネントはただの関数なのです。
Reactを使うときは、まずこの2種類のコンポーネントの振る舞いを理解しておかなければなりませんでした。
私はこの基本をすっ飛ばして、ReactをVue.jsと同じように扱おうとしてしまったために、凝ったことをしようとしたときに苦戦を強いられてしまいました。
Reactで状態はイミュータブルである
イミュータブルとは不変ということです。つまり変更してはいけないということです。
次のコンポーネントを見てみます。
function App() {
let [x, setX] = useState(0)
return <button onClick={() => setX(x+1)}>{x}</button>
}
このコンポーネントのボタンを押すたびにxの値が増えていきます。イミュータブルなのに変更してるように見えます。
しかし、この関数App()
に限って言えば、x
への代入は行われていません。
また、setX()
が記述されていますが、setX()
が呼び出されるのは関数App()
からではなく、クリックされたときのイベントです。
そしてそのときであっても、x
に代入するのではなく必ずsetX()
経由で代入しています。
setX()
は、useStateフックで作った状態の値を更新すると同時に、値が以前と異なる場合は仮想DOMの構築をReactに指示します。構築した仮想DOMが以前と異なる場合は、異なる部分だけDOMを更新します。
Vue.jsは代入を監視されてDOMが勝手に更新されていましたが、Reactは明示しないといけないということです。
状態がオブジェクトのとき
Reactは更に状態がオブジェクトのとき、そのオブジェクトもイミュータブルとみなします。
例えばVue.jsでx
とy
のプロパティを持つ座標があったとします。
let point = reactive({ x: 0, y: 0 })
もしこの座標が横に移動した場合はxに移動した分の値を足せば問題ありません。
point.x += 10
しかし、Reactの場合はこれはNGです。
Reactで同じように座標の状態を作ります。
let [point, setPoint] = useState({ x: 0, y: 0 })
Vue.jsと同じようにxプロパティに代入しても、Reactはそれを検知しませんので、DOMは更新されません。
そのため新しくオブジェクトを作ってそれを新しい値として状態を変更する必要があります。
setPoint({ ...point, x: point.x + 10 })
面倒ですね。
関数型プログラミングでは、座標{ x: 0, y: 0 }
と{ x: 10, y: 0 }
は異なるものです。そのため、Reactのほうがより関数型プログラミングに忠実です。
とはいえ、xとyの座標程度ならばともかく、ネストしたオブジェクトになると記述が複雑になってきます。たった1つネストがあるだけでこんな感じです。
setObj({
...obj,
point1: { ...obj.point1, x: obj.point1.x + 10 }
})
この問題はHaskellのような関数型プログラミング言語でも起きているようです。
オブジェクトのネストを辿りながらオブジェクトを再生成するというのは、記述も長くなりますし、処理も遅くなります。そのため、プロパティへの代入は非関数型プログラミング言語では暗黙的に許可されているのですが、Reactはそれを許しません。
それによってReactでは記述量は多くなりますが、意図しない挙動が減るらしいです。
シャロ―コピー
ちなみに、オブジェクトを再生成するときにディープコピーではなく、シャロ―コピーを行っています。
ディープコピーじゃないと完全に別のオブジェクトだと言えないんじゃないかと、私は最初考えました。
しかし、よくよく考えるとネストされたプロパティに代入されることが無いので、シャロ―コピーでも全く問題無いのです。
{ point1: { x: 0, y: 0 }, point2: { x: 5, y: 5 } }
これと
{ point1: { x: 10, y: 0 }, point2: { x: 5, y: 5 } }
これにおいて、point2はディープコピーしなくても、代入されない以上、片方を書き換えたらもう片方も変わってしまうという問題が起きないので、同じオブジェクトを使い回して良いのです。
これが代入を禁止したメリット・・・ではないですね。そもそも代入が禁止されていなければコピーすら必要ないですから。
ネストがあるオブジェクトをどう扱うか
私がReactワカンネとなった理由がこれです。
色々と調べてみるとどうやら、先のような理由があり、ネストがあるオブジェクトを状態にするのはReactでは良くないようです。
なるべくフラットにしろと。
一言でフラットにしろと言われても、それはなかなか難しいです。
このオブジェクトをフラットにしてみましょう。
{
title: '',
sections: [
{
subTitle: '',
tags: [
{ icon: '', text: '' },
{ icon: '', text: '' }
]
},
{
subTitle: '',
tags: [
{ icon: '', text: '' },
{ icon: '', text: '' }
]
}
]
}
前提として、作りたいのは入力フォームです。
-
sections
は任意の数に増やすことができますし、減らすこともできます。ドラッグ&ドロップで並び替えもできます。 -
tags
も任意の数に増やすことができますし、減らすこともできます。ドラッグ&ドロップで並び替えもできます。
それではやってみよう。
・・・・・・どうやって?
idなどを使って、RDBのようにテーブルを分けるのがなんとなくベストのように思いますが、tagsを1つのテーブルに入れると各tagどのsectionに属すのか判別するためにsectionのidを持つ必要があります。
RDBに保存しているのであればidはすでに振られていますが、その前であれば仮のidが必要になります。
さらに、一つのテーブルの中でsectionごとにドラッグ&ドロップでの並び替えが必要となります。フロントエンドでできればこんなことはやりたくない。
オブジェクトにネストが無いと見なせばいい
頭の中がごちゃごちゃとなっていましたが、Reactが関数型プログラミングを強制してくるということは、関数型プログラミングで考えれば上手くいきそうです。
先の構造をTypeScriptで型にしてみます。
type Page = {
title: string
sections: Section[]
}
type Section = {
subTitle: string
tags: Tag[]
}
type Tag = {
icon: string
text: string
}
この時点でもう、ネストが無くなったのが分かります。
では、トップダウンでコンポーネントを作っていきます。まずはPage
の状態を持つステートフルコンポーネントです。
function App() {
let [page, setPage] = useState({
title: '',
sections: []
})
return <InputPage {...{ page, setPage }} />
}
あとはステートレスコンポーネントを作っていきます。
ステートレスコンポーネントは再利用できる構造です。そしてネストもしていませんので、ほぼすべて似た作りになっています。
Keyとドラッグ&ドロップは長くなるので省略しています。
function InputPage({ page, setPage }) {
let { title, sections } = page
let setTitle = (value) => setPage({ ...page, title: value })
let setSections = (value) => setPage({ ...page, sections: value })
return (
<>
Title: <input value={title} onChange={(event) => setTitle(event.target.value)} /><br />
Sections: <InputSections {...{ sections, setSections }} />
</>
)
}
function InputSections({ sections, setSections }) {
let addSection = () => setSections([...sections, { subTitle: '', tags: [] }])
return (
<>
<button onClick={addSection}>Add Section</button>
{sections.map((section, index) => {
let setSection = (section) => setSections([...sections.slice(0, index), section, ...sections.slice(index + 1)])
return <InputSection {...{ section, setSection }}/>
})}
</>
)
}
function InputSection({ section, setSection }) {
let { subTitle, tags } = section
let setSubTitle = (value) => setSection({ ...section, subTitle: value })
let setTags = (value) => setSection({ ...section, tags: value })
return (
<div style={{ marginLeft: '1rem' }}>
Sub Title: <input value={subTitle} onChange={(event) => setSubTitle(event.target.value)} /><br />
Tag: <InputTags {...{ tags, setTags }} />
</div>
)
}
function InputTags({ tags, setTags }) {
let addTag = () => setTags([...tags, { icon: '', text: '' }])
return (
<>
<button onClick={addTag}>Add Tag</button>
{tags.map((tag, index) => {
let setTag = (tag) => setTags([...tags.slice(0, index), tag, ...tags.slice(index + 1)])
return <InputTag {...{ tag, setTag }}/>
})}
</>
)
}
function InputTag({ tag, setTag }) {
let { icon, text } = tag
let setIcon = (value) => setTag({ ...tag, icon: value })
let setText = (value) => setTag({ ...tag, text: value })
return (
<div style={{ marginLeft: '1rem' }}>
Icon: <input value={icon} onChange={(event) => setIcon(event.target.value)} /><br />
Text: <input value={text} onChange={(event) => setText(event.target.value)} /><br />
</div>
)
}
関数型プログラミングを使えば型からそのままコンポーネントができていき、ネストという概念がそもそもありません。Reactが人気の理由が分かってきました。