Selectable
Selectable extends MutableSelectable and implements the SelectableInterface. The only difference between this and MutableSelectable is that this uses immer
(opens in a new tab) to ensure that mutations in .set
calls are immutable
Usage
Creating a store
Creating a store is as simple as creating a new instance of Selectable
const store = new Selectable({
username: "",
password: "",
})
Updating state
The only way to update state is by calling .set
on the store
store.set((state) => {
state.username = "John"
state.password = "password123"
})
Subscribing to changes
You can subscribe to changes on the store with .subscribe
.
This will run the listener immediately and then every time .set
is called.
const unsubscribe = store.subscribe(() => {
console.log(store.state.username)
})
Subscribing to a slice of state
If you only want your listener to run when a subset of state changes you can use the .select
method to "select" which part of state you are interested in.
For example the listener in the snippet below would run once immediately and then whenever the "username" value changes
const listener = (username) => console.log(username)
const unsub = store.select((state) => state.username, listener)
// we selected the username above so this will trigger the listener
store.set((state) => {
state.username = "example_password"
})
// the listener is not called because we haven't selected the password
store.set((state) => {
state.password = "example_password"
})
Importantly the listener also receieves the previous value as it's second argument, so you can decide if you are interested in a change by comparing the current and previous selection
Computing values from state
You can compute a value from any part of the store in the selector function. The listener will then only rerun when the computed value changes
const selectHasMatchingPassword = (state) => state.username === state.password
const matchingPasswordListener = (isMatching) => {
if (isMatching) {
console.log("Your password can't be the same as your username!")
}
}
const unsubscribe = store.select(
selectHasMatchingPassword,
matchingPasswordListener
)
Custom equality functions
By default the equality function used is Object.is
.
This means that if you return a new object or array in the selector the listener will run on every change.
To avoid your listener running on every change you can supply your own equality function as the third argument to .select
const unsubscribe = store.select(
(state) => [state.username, state.password],
([username, password]) => {
console.log("Username or password changed")
},
// returns true if an array has equal length and every item is strictly equal
shallowEqualArray
)
Subscribing to multiple parts of a store
As alluded to above you can return an object or array in the selector function to subscribe to multiple parts of state.
import { shallowEqual, shallowEqualArray, deepEqual, strictEqual } from "@selkt/core"
// updates when either username or password changes
store.select(
(state) => [state.username, state.password]
([username, password]) => {
console.log('Username or password changed')
},
shallowEqualArray
)
// updates when either username or password changes
store.select(
state => ({ username: state.username, password: state.password }),
({ username, password }) => {
console.log('Username or password changed')
},
shallowEqual
)
// updates when any value in state changes
store.select(
state => state,
state => {
console.log('Username or password changed')
},
deepEqual
)
Forcing a listener to re-run
If you need to force a listener to re-run you can call the .update
method on the returned unsubscribe function
Calling .update
is a last resort and if you find a situation where you need
to call it you should probably try and refactor your code to avoid it.
const listener = store.select(
(state) => state.username,
(username) => {
// runs once on mount and again when listener.update() is called
console.log(username)
}
)
listener.update()
Delay updating listeners while making multiple changes to state
If you need to make multiple changes to state and only want the listeners to run once you can use the .flush
method.
const store = new Selectable({
username: "",
password: "",
})
store.select(
(state) => state.username,
(username) => {
console.log(username)
}
)
// logs "" on mount
store.flush(() => {
store.set((state) => {
state.username = "John"
})
store.set((state) => {
state.username = "Johnny"
})
})
// logs "Johnny" after the .flush callback has finished executing
Because both calls to .set
happen inside the callback above it would only trigger a single listener update
Immutability!
Thanks to state being immutable you are able to select any object in state and have your listener run whenever any nested property is changed.
const store = new Selectable({
user: {
username: "",
password: "",
},
})
store.select(
(state) => state.user,
(user) => {
console.log("user changed")
}
)
store.set((state) => {
state.user.username = "John"
})
Unsubscribing from a store
To unsubscribe from a store simply call the returned unsubscribe function.
In the example below the listeners would be called once on mount but will not be called again when .set
is called
const listener1 = store.subscribe(() => {
console.log(store.state.username)
})
listener1()
// or
const listener2 = store.select(
(state) => state.username,
(username) => {
console.log(username)
}
)
listener2()
store.set((state) => {
state.username = "John"
state.password = "password123"
})
Playground
import { MutableSelectable } from "@selkt/core" console.clear() const myStore = new MutableSelectable({ username: "", password: "", }) const listener = (username) => console.log(username) myStore.select( (state) => state.username, listener // runs immediately with "" ) myStore.set((state) => { state.username = "John" }) // listener reruns with "John"
API
export class Selectable<StateType>
Selectable<StateType>.subscribe
Subscribes to a store. Returns an unsubscribe function. The returned function also has an .update
method that can be used to force the listener to rerun
subscribe(
listener: (state: StateType) => void,
): (Unsubscribe & { update: () => void })
Selectable<StateType>.select
Subscibes to changes on a slice of state. The listener runs immediately and then whenever the SelectedSlice
changes.
The listener also recieves the previous value of SelectedSlice
as the second argument so you can compare the current and previous values in your listener
select<SelectedSlice>(
selector: (state: StateType) => SelectedSlice,
listener: (state: SelectedSlice, prevState?: SelectedSlice | undefined) => void,
equalityFn?: (a: SelectedSlice, b: SelectedSlice) => boolean
): (Unsubscribe & { update: () => void })
Selectable<StateType>.set
This is the only way to change the state of a store. It accepts a single callback which is called with the current state. The callback can return a new state or mutate the current state.
Any mutations inside the recipe function will be made immutable by immer
and the store will be updated with the new state.
set(
recipeFunction: (currentState: ImmerDraft<StateType>) => void | StateType
) => void
If you mutate a property and it has no effective change then the store will not be updated and listeners will not be called.
const store = new Selectable({
username: "John",
password: "",
})
let initialState = store.state
// no update as immer is able to tell that nothing has changed
store.set((state) => {
state.username = "John"
})
// store.state === initialState
store.set((state) => {
state.username = "Johnny"
})
// store.state !== initialState
Selectable<StateType>.state
The current state of the store.
state: StateType
Selectable<StateType>.version
The current version of the store. This is incremented every time the state is changed.
version: number
Selectable<StateType>.flush
Delays calling listeners until the callback has finished executing. This is useful when you want to make multiple changes to state and only want to the listeners to update once.
flush(
callback: () => void
): void