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で大丈夫かなと。