Selectable

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