By
Raqueebuddin Aziz
January 16, 2023 (Updated on February 20, 2023)
Freelance Web Designer & Developer
By
January 16, 2023 (Updated on February 20, 2023)
Freelance Web Designer & Developer
This guide assumes you have been through solidjs tutorial and are familiar with mutable vs immutable data types in javascript.
Simply put for the most part it is just syntactic sugar that makes your life easier to gain full advantage of solid’s fine-grained reactivity when using collection data types like objects and arrays.
Before stores were introduced you would need to wrap values in signals yourself like:
const GET = 0
const SET = 1
function MyCompNoStore() {
const [users, setUsers] = createSignal(
[
{
id: createSignal(0),
username: createSignal('johndoe420'),
address: createSignal({
city: createSignal('Mumbai'),
country: createSignal('India')
})
},
{
id: createSignal(1),
username: createSignal('janedoe420'),
address: createSignal({
city: createSignal('Tokyo'),
country: createSignal('Japan')
})
}
].map((value) => createSignal(value))
)
return (
<For each={users()}>
{(user) => {
const { id, address, username } = user[GET]()
return (
<div>
id: {id[GET]()}
username: {username[GET]()}
city: {address[GET]().city[GET]()}
country: {address[GET]().country[GET]()}
</div>
)
}}
</For>
)
}
You might think that this is over wrapping, and we don’t need to wrap every value/object/array in signals. But if you think about it.
The point is this is not over wrapping and each signal has a justified use case for existing in terms of achieving the most efficient path to fine-grained updates.
import { createStore } from 'solid-js/store'
function MyCompWithStores() {
const [users, setUsers] = createStore([
{
id: 0,
username: 'johndoe420',
address: {
city: 'Mumbai',
country: 'India'
}
},
{
id: 1,
username: 'janedoe420',
address: {
city: 'Tokyo',
country: 'Japan'
}
}
])
return (
<For each={users}>
{(user) => (
<div>
id: {user.id}
username: {user.username}
city: {user.address.city}
country: {user.address.country}
</div>
)}
</For>
)
}
Now in the stores example. We just have a top level wrapper that is a store. All objects/arrays under a store are recursively wrapped in stores automatically and all values (numbers/strings/boolean etc…) are wrapped in signals automatically.
You might have noticed we don’t destructure id
, username
and address
. The reason being that the getter of the underlying signal is called at the time of accessing the property of a store. So if we destructure the user param like { id, username, address }
, the getter would be called and the subscription would be made at this level rather than when we actually use the values.
We can actually destructure address
safely out of the user store without losing any fine-grained reactivity as address
itself is a store so no getters would be called. You only need to worry about not destructuring before usage when the underlying value itself is wrapped in a signal (i.e numbers/strings/boolean/Sets/Maps etc…) and not wrapped in stores (i.e. objects/arrays). This is the reason why we could “destructure” each user
out of the users
array without losing fine-grained updates.
So one thing we haven’t talked about yet is how do we update data in our stores. Your first instinct would be to do something like:
users[0].username = 'newusername'
But this doesn’t work with “normal” stores for various reasons that deserves an article of its own, but I’ll try to summarize the main point. Let’s say you could do that, and then you pass the users store to another component/file through props/context/imports. Now that component/file can update the store even though your intention was only to let it read the value of users
.
You might still want this syntax for convenience sometimes, or maybe you are just prototyping and don’t care. For that reason there is a createMutable
function that creates a store that can be set with assignments.
const users = createMutable([
{
id: 0,
username: 'johndoe420',
address: {
city: 'Mumbai',
country: 'India'
}
},
{
id: 1,
username: 'janedoe420',
address: {
city: 'Tokyo',
country: 'Japan'
}
}
])
See how it doesn’t return a setUsers
function as you can just update the value by assignment.
Okay so how do we update values if not through assignments. The createStore
function returns a setter that can be used to update stores, the advantage of separating the getter and setter being that you can pick and choose when passing props/contexts/imports on who can get and who can set values leading to easier to debug code as you know who does what.
const [users, setUsers] = createStore([
{
id: 0,
username: 'johndoe420',
address: {
city: 'Mumbai',
country: 'India'
}
},
{
id: 1,
username: 'janedoe420',
address: {
city: 'Tokyo',
country: 'Japan'
}
}
])
setUsers(0, 'username', 'newusername') // this is equivalent to users[0].username = 'newusername' if using normal objects/arrays
The syntax for setters is simple yet powerful. In the simplest case you can think of it just as accessing values like object[key] = newValue
but instead of doing it with square brackets you pass the keys as function arguments and the last argument being the new value like setStore(key, newValue)
.
And just like with nested objects and arrays you can chain square bracket keys you can do the same with store setters for e.g.
users[0]['address']['city'] = 'Delhi'
// is equivalent to
setUsers(0, 'address', 'city', 'Delhi')`
This chaining of arguments as object keys is known as path syntax
in solidjs docs. You can do much more with path syntax like maps
, filters
, find
etc… Let’s take a look at those syntaxes.
Let’s say you wanted to increment all id’s by one. This is how you would do it.
const [users, setUsers] = createStore([
{
id: 0,
username: 'johndoe420',
address: {
city: 'Mumbai',
country: 'India'
}
},
{
id: 1,
username: 'janedoe420',
address: {
city: 'Tokyo',
country: 'Japan'
}
},
{
id: 2,
username: 'janedoe420',
address: {
city: 'Tokyo',
country: 'Japan'
}
},
{
id: 3,
username: 'janedoe420',
address: {
city: 'Tokyo',
country: 'Japan'
}
},
{
id: 4,
username: 'janedoe420',
address: {
city: 'Tokyo',
country: 'Japan'
}
}
])
setUsers({ from: 0, to: users.length - 1 }, 'id', (id) => id + 1)
We see two new things here.
from
and to
.Let’s say you only want to increment only even indices. This is how you would do it.
setUsers({ from: 0, to: users.length - 2, by: 2 }, 'id', (id) => id + 1)
The by
key specifies how much should the index increment every time like a for loop. Notice that we do users.length - 2
this time because our store has 5 items, so if did users.length - 1
the counter would go 0, 2, 4, 6 and as we don’t have a 6th user it would error out. This is exactly how for loops work as well, so it’s easy to map this out in your mind.
You can also specify multiple keys using an array. Like:
setUsers([0, 1, 5], 'id', (id) => id + 1)
Technically you can do this with objects too, although I can’t think of any use case for that, I will still provide an example for completeness’s sake.
setUsers(0, 'address', ['city', 'country'], 'some-place-where-the-city-and-country-has-same-name')
Now let’s say we wanted to increment id of every use whose username starts with a “j”.
We can’t simply do this with array indices or from/to syntax as we don’t know ahead of time which users satisfy the condition.
For this use case you can pass in a function instead of a key as an argument which is equivalent to the Array.prototype.filter()
method.
The function is passed the old value and index as the argument.
This is how you would do it.
setUsers(
(user, index) => user.username.startsWith('j'),
'id',
(id) => id + 1
)
You can also use this as a Array.prototype.find
alternative as a find is just a filter that returns the first value that satisfies the condition.
To append to an array just set the new value at index array.length
. For e.g.
setUsers(users.length, {
id: users.length,
username: 'newuser',
address: {
city: 'DC',
country: 'Marvel'
}
})
When updating javascript Sets
and Maps
inside a store you need to be careful as just mutating them would not trigger changes, you need to create a new reference each time you update them. For e.g.
const [data, setData] = createStore({
colors: new Set(['blue', 'red', 'green']),
fruitsScore: new Map([
['apple', 1],
['banana', 2]
])
})
// these would not work
setData('colors', (colors) => colors.add('yellow'))
setData('fruitsScore', (fruitsScore) => fruitsScore.set('apple', 420))
// do these instead
setData('colors', (colors) => {
colors.add('yellow')
return new Set(colors)
})
setData('fruitsScore', (fruitsScore) => {
fruitsScore.set('apple', 420)
return new Map(fruitsScore)
})
One caveat to keep in mind when using store setters is that if a new value is an object it will be shallow merged with the old value automatically. For e.g.
setUsers(0, {
id: 69420
})
// is equivalent to
setUsers(0, (user) => ({
...user, // the spread here is unnecessary as even without it solid would auto merge it anyways, it's here just to showcase the fact it auto merges using spreads.
id: 69420
}))
To completely remove a key from an object, set it to undefined
.
setUsers(0, {
id: 69420,
username: undefined
})
The path syntax is powerful and is convenient to use in most cases, but sometimes it is convenient to think in terms of mutability, or it is more terse to write updates in a mutable fashion. To address this issue solid came up with the produce
utility function which allows you to think in terms of mutability without all the downsides of using mutable stores. Let us see one scenario like that and see how produce
make it cleaner. Let’s try to change username and city of the first user in the store using with and without produce
.
import { batch } from 'solid-js'
import { createStore, produce } from 'solid-js/store'
const [users, setUsers] = createStore([
{
id: 0,
username: 'johndoe420',
address: {
city: 'Mumbai',
country: 'India'
}
},
{
id: 1,
username: 'janedoe420',
address: {
city: 'Tokyo',
country: 'Japan'
}
},
{
id: 2,
username: 'janedoe420',
address: {
city: 'Tokyo',
country: 'Japan'
}
},
{
id: 3,
username: 'janedoe420',
address: {
city: 'Tokyo',
country: 'Japan'
}
},
{
id: 4,
username: 'janedoe420',
address: {
city: 'Tokyo',
country: 'Japan'
}
}
])
// without produce
batch(() => {
setUsers(0, 'username', 'newusername')
setUsers(0, 'address', 'city', 'newcity')
})
// with produce
setUsers(
0,
produce((user) => {
user.username = 'newusername'
user.address.city = 'newcity'
})
)
NOTE: produce only works with arrays/objects and not other collection types like javascript Sets and Maps.
The reconcile
utility function basically does a recursive diff on the new value for you when using store setters. This is useful when you are not in control of the new value for e.g. the new value is an API Response or a value from a file imported by a user.
import { createStore, reconcile } from 'solid-js/store'
const [data, setData] = createStore({
colors: ['red', 'blue', 'orange']
})
const userColors = getUserColors() // Lets say this is ["red", "blue", "orange", "black"]
setData('colors', reconcile(userColors))
This would auto diff the userColors
and don’t trigger updates for red, blue and orange and only trigger updates for black i.e. the value that changed.
The unwrap
function can be used to convert a store to a normal object. Think of it like the opposite to createStore
.
import { createStore, unwrap } from 'solid-js/store'
const [data, setData] = createStore({
colors: ['red', 'blue', 'orange']
})
const rawData = unwrap(data)
The modifyMutable
function can be used to modify multiple properties of a mutable store in one batched update.
import { batch } from 'solid-js'
import { createMutable, modifyMutable, reconcile } from 'solid-js/store'
const data = createMutable({
colors: ['red', 'blue', 'orange'],
fruits: ['apple', 'banana']
})
// this will trigger two updates
data.colors = ['red', 'blue', 'orange', 'black']
data.fruits = ['apple', 'banana', 'orange']
// using batch, this will trigger one update
batch(() => {
data.colors = ['red', 'blue', 'orange', 'black']
data.fruits = ['apple', 'banana', 'orange']
})
// using modifyMutable, this is equivalent to the batch example thus this will trigger one update
modifyMutable(
data,
reconcile({
colors: ['red', 'blue', 'orange', 'black'],
fruits: ['apple', 'banana', 'orange']
})
)
SolidJS stores are extremely powerful and cover all use cases you could encounter while dealing with complex state in an app plus you get the fine-grained updates. And with createMutable
and produce
, no matter where you stand on the mutability vs immutability spectrum, solid got you covered.
Did I miss anything? What do you think about solid stores? Leave a comment down below
© 2024 Raqueebuddin Aziz. All rights reserved.