createSignal
vs createStore
createSignal
を使い、オブジェクトをシグナルとして保存するのは理想的でない場合がほとんどです。
言葉で説明するよりコードを見たほうが早いと思うのでさっそくいきましょう。
createSignal
の場合
import { createSignal, createEffect } from "solid-js";
const App = () => {
const initialUser = () => (
{ name: { first: "John", last: "Doe" },
age: 69,
}
)
const [user, setUser] = createSignal(initialUser());
createEffect(() => {
console.log("My age is", user().age);
});
createEffect(() => {
console.log("My first name is", user().name.first);
});
const updateFirstName = () => {
setUser((prev) => {
const next = { ...prev };
next.name.first = "Dan";
return next;
});
};
return (
<>
<div>
Name: {user().name.first} {user().name.last}
</div>
<div>Age: {user().age}</div>
<button onClick={updateFirstName}>Update Signal</button>
</>
);
};
createSignal
に渡すオブジェクトはクローンされません。
const initialUser =
{ name: { first: "John", last: "Doe" },
age: 69,
}
const [user, setUser] = createSignal(initialUser);
こう書いた場合、initialUser
とuser
は同一のインスタンスです。
後で初期値を使いたい場合は、元の例のように関数として定義しましょう。
使わないなら、下のようにcreateStore
内に直接初期値を書くほうが好ましいです。
const [user, setUser] = createSignal(
{ name: { first: "John", last: "Doe" },
age: 69,
});
同じ初期値のシグナルを複数作成したいだけなら、そのためのプリミティブを作成するのがいいでしょう。
const createUser = () => {
return createSignal({
name: { first: "John", last: "Doe" },
age: 69,
})
}
const [user, setUser] = createUser()
この特性は、後ほど紹介するcreateStore
でも同様です。
まず前提として、作成したシグナルはsignalObj
というオブジェクトそのものであり、そのプロパティではありません。
つまりSolidJSは、プロパティ一つの更新をオブジェクトそのものの更新と認識してしまいます。
この特性はsignalObj
に依存している全ての箇所の更新を引き起こします。
例ではクリック時に、.name.first
のみを更新していますが、ボタンがクリックされる度にsignalObj()
と呼ばれている部分全てが再レンダーしてしまいます。
.name.last
も.age
も、値は変わっていないにも関わらずです。
.age
にのみアクセスしているように見える副作用も、クリックの度に実行されてしまいます。
実際に試してみたい方はこちらのplaygroundでどうぞ。インスペクターを使用すれば、.name.last
や.age
が再レンダーされている事が分かるはずです。
もちろんこんな動作は理想的ではありません。こちらの理想は:
- 更新されたプロパティに依存している箇所のみ更新される
- 更新されたプロパティに依存している副作用のみ実行される
というものです。
この理想を実現させてくれるのがcreateStore
です。
createStore
の場合
//インポート元がcreateSignalとは違う点に注意
import { createStore } from "solid-js/store";
import { createEffect } from "solid-js";
const App = () => {
const initialUser = () => (
{ name: { first: "John", last: "Doe" },
age: 69,
}
)
const [user, setUser] = createStore(initialUser());
createEffect(() => {
console.log("My age is", user.age);
});
createEffect(() => {
console.log("My first name is", user.name.first);
});
const updateFirstName = () => {
setUser("name", "first", "Dan");
};
return (
<>
<div>
Name: {user.name.first} {user.name.last}
</div>
<div>Age: {user.age}</div>
<button onClick={updateFirstName}>Update Signal</button>
</>
);
};
playground
これで理想通りに動きます。が、createSignal
とは構文が少し違うので一つづつ見ていきましょう。
ゲッター関数がない
上の例で、user
は関数ではなくオブジェクトです。
createStore
によって作成されるオブジェクトは「プロキシ」と呼ばれる特殊な物で、裏ではそのプロパティ全てがシグナル化されています。(ネストされたプロパティも含む)
user.age
にアクセスする時、裏ではシグナル化されたage
のゲッター関数にアクセスしている事になります。
ちなみにprops
もこのプロキシと呼ばれるオブジェクトです。
こちらの記事でprops
をうかつに分割代入するべきでない理由を解説していますが、store
にも同じことが当てはまります。
特殊なセッター関数
setUser("name", "first", "Dan");
とありますが、これは気持ち的に
user.name.first = "Dan"
と書いている感じです。
他にも様々な使い方ができるので詳しくは公式ドキュメントを読んでみてください。
ver.1.4.0以前はトップレベルで配列を扱う事ができなかったので
const [store, setStore] = createStore({arr: []})
のように、オブジェクトに包んで書く他ありませんでしたが、ver.1.4.0以降は
const [store, setStore] = createStore([])
のように、トップレベルでの配列にも対応しています。
ただ、これは2022/6/11時点の日本語版ドキュメントには掲載されていないので注意してください。
オブジェクトや配列は全てcreateStore
でいいのか
ほとんどの場合はcreateStore
を使って問題ありません。
ですが、先程も述べた通りcreateStore
から返されるプロキシオブジェクトは全てのプロパティをシグナル化してしまいます。
これによって更新される予定のないプロパティや値までウォッチされてしまい、パフォーマンスの低下に繋がる可能性もあります。
例1
次の例は、100万個の数字を配列に保存し、その平均値を表示するというものです。
import { createStore } from "solid-js/store";
const produceBigArr = () => {
return Array.from({ length: 1e6 }, () => Math.random());
};
const App = () => {
const [arr, setArr] = createStore(produceBigArr());
const updateArr = () => setArr(produceBigArr());
const calculateAverage = () => {
return (
arr.reduce((sum, value) => {
return sum + value;
}, 0) / arr.length
);
};
return (
<>
<button onClick={updateArr}>Update Array</button>
<div>Average: {calculateAverage()}</div>
</>
);
};
createStore
を用いると、100万個の値全てがシグナルとして登録されてしまうのでパフォーマンスがガタ落ちします。
playgroundで実際に体験してみてください。(重いので注意)
createSignal
を使ったバージョンは軽々と動作します。
例2
こちらは少し極端な例ですが、「プロパティは沢山あるが、実際更新するのはほんの数個のみ」といったオブジェクトに関してもcreateStore
だとパフォーマンスが低下する可能性があります。
const [bigStore, setBigStore] = createStore(
{
foo: "foo",
bar: false,
baz: [1, 2, 3],
// ...その他沢山のプロパティ
}
)
例えばこの例の場合、foo
のみが実際に更新される値だとすると、fooのみをシグナル化するほうがパフォーマンスは向上します。
const [foo, setFoo] = createSignal("foo")
const bigObj = {
foo,
setFoo,
bar: false,
baz: [1, 2, 3],
// ...その他沢山のプロパティ
}
クロージャにしてもいいですね。
const bigObj = (() => {
const [foo, setFoo] = createSignal("foo")
return {
foo,
setFoo,
bar: false,
baz: [1, 2, 3],
// ...その他沢山のプロパティ
}
})()
まとめ
今回は、createStore
のメリットとデメリットを解説させて頂きました。
色々書きましたが、ぶっちゃけcreateStore
のデメリットが致命的になるケースは珍しいと思います。
値やプロパティの数がそれこそ数千単位になってやっと影響が出てくるようなものなので。
createSignal
を用いて最小限のシグナルを作成する方法もあくまで「最適化」ですので、そこまで突き詰めなくてもいいケースも存在すると思います。
とりあえずオブジェクトや配列を状態として保存したくなったらcreateStore
で大丈夫かなと。