@nanoslices/core
v0.0.0-rc.13
Published
Centralized state management wrapper around nanostores
Downloads
13
Readme
@nanoslices/core
nanoslices is a wrapper library around nanostores.
It allows creating global stores composed of slices, each slice themselves composed of nanostores Stores: atoms, maps,
computed...
The generated store offers a way to declare actions that can modify atoms. Atoms are not writable from outside those actions by default, adding a safety layer around nanostores.
The store system is extensible through plugins to add functionality such as Redux DevTools support or testability through action spies.
Disclaimer: nanoslices is not affiliated to nanostores in any way, we are just wrapping the library. Many thanks for nanostores for offering solid foundations, and apologies if nanoslices' approach is somehow against nanostores base philosophy :)
Installation
npm >= 7
npm install --save @nanoslices/core@nanoslices/core depends on nanostores as peer dependency, so you need to install it manually if you
are not using NPM 7 or higher
npm < 7
npm install --save @nanoslices/core nanostoresyarn
yarn add @nanoslices/core nanostoresUsage
The building blocks of nanoslices are called Slices. Each Slice encapsulate a part of your final store, composed
of atoms, maps, computed, and actions. You can create a Slice using the exported createSlice helper:
import { atom, computed } from 'nanostores'
import { createSlice } from '@nanoslices/core'
export const profile = createSlice(() => ({
firstName: atom(''),
lastName: atom(''),
}))
.computed((slice) => ({
fullName: computed(
[slice.firstName, slice.lastName],
(firstName, lastName) => [firstName, lastName].join(' '),
),
}))
.actions((slice) => ({
setFullName(firstName: string, lastName: string) {
slice.firstName.set(firstName)
slice.lastName.set(lastName)
},
}))You can then use your Slice to create a Store. A Store simply takes a map of Slices, and turns them into a usable store:
import { createStore } from '@nanoslices/core'
import { profile } from './profile'
export const Store = createStore({ profile })From your Store, you can now read your atoms via the get method, or call an action via the act method:
import { Store } from './store'
console.log(Store.get(({ profile }) => profile.fullName))
// " "
Store.act(({ profile }) => profile.setFullName('General', 'Kenobi'))
console.log(Store.get(({ profile }) => profile.fullName))
// "General Kenobi"Store options
You can configure your store through the second argument of createStore. Just as the argument itself, all fields
of the configuration object are optional.
By default, only one option is available:
context: any: variable that will be passed to all slice functions (computed,actionsandinitialize). Use it to inject some shared context across your slices, such as API services or configuration.
More options can be added through plugins.
Slice methods
The object returned by createSlice will give you access to several methods to extend your slice. Note that methods
can only be called in a certain order, explained below. Each method returns the extended slice, and can therefore be called as a chain.
Here are the methods available:
context<C>(): TypeScript-only helper. Returns the slice as is, but injectsCas the type of thecontextoption passed to other methods. Cannot be called aftercomputedoractions.slices<S>(slices: S): TypeScript-only helper. Takes a map of slices, to type theslicesoption passed to other methods. The map of slices must correspond to slices you actually give to your store. See Advanced usage below. Cannot be called aftercomputedoractions.computed<C>((slice, options) => C): Receives the current slice state as defined bycreateSliceand previouscomputedcalls, and anoptionsobject. Returns a map of computed properties using thecomputednanostores store. Throughoptions.slices, it is possible to access atoms from other slices and compute them. Throughoptions.context, it can access the shared store context. Cannot be called afteractions, but can be chained to compose computed together.actions<A>((slice, options) => A): Receives the current slice state as defined bycreateSliceand potentiallycomputed, and anoptionsobject. Returns a map of actions that can modify the state. Throughoptions.slices, it can compose actions from other slices or read other slices' state, and throughoptions.contextit can access the shared store context. Can be chained multiple times to compose actions together.initialize((slice, options) => T): Receives the current slice and the standardoptions, and is called ifStore.initializeis called. The callback can be used to initialize the slice with asynchronous data in a controlled manner. Afterinitialize, the slice is considered final, it is not possible to callactionsorcomputedanymore.
Store methods
The Store object exposes several methods:
Base methods:
Main methods to use to consume the state in an application.
get(selector: (atoms) => Atom): Takes an atom selector in parameter, and returns the current value stored in the atom.act(selector: (actions) => any): Takes a store action selector in parameter, and returns anything. You can either return the action itself to be called later, or call the action in the selector and return its value.initialize(): Executes the callbacks passed to theinitializemethod of the various slices.
Advanced methods:
Additional methods used for advances use cases.
reset(): Restore the store to its initial state, before initialize() was called. Restores all the atoms to the default value they were given when creating slices. Can optionally take a snapshot as parameter, applied to the state after reset. The snapshot is a deep partial representation of the state, and updates all the atoms accordingly with the new value. Note that any value passed to acomputedstore will be ignored, and the actual computed value from other atoms will be used.snapshot(): Return a deep representation of the current state as a plain JS object, no atoms.setContext(context): Replace the context initially passed increateStorewith a new value. In TypeScript, the new context type must be compatible with the original context type.
Advanced usage
Composing computed
It's possible to chain multiple .computed methods to compose computed values in a slice:
import { computed } from 'nanostores'
import { createSlice } from '@nanoslices/core'
export const statistics = createSlice(() => ({
tasks: atom<{ done: boolean }>([]),
}))
.computed((slice) => ({
total: computed(slice.tasks, (tasks) => tasks.length),
done: computed(
slice.tasks,
(tasks) => tasks.filter((task) => task.done).length,
),
}))
.computed((slice) => ({
progress: computed(
[slice.total, slice.done],
(total, done) => Math.round(done * 100) / total,
),
}))Composing actions
It's also possible to chain multiple .actions methods to compose actions in a slice:
import { atom, computed } from 'nanostores'
import { createSlice, createStore } from '@nanoslices/core'
const profile = createSlice(() => ({
firstName: atom(''),
lastName: atom(''),
}))
.computed((slice) => ({
fullName: computed(
[slice.firstName, slice.lastName],
(firstName, lastName) => [firstName, lastName].join(' '),
),
}))
.actions((slice) => ({
setFirstName(firstName: string) {
slice.firstName.set(firstName)
},
setLastName(lastName: string) {
slice.lastName.set(lastName)
},
}))
.actions((slice) => ({
setFullName(firstName: string, lastName: string) {
slice.setFirstName(firstName)
slice.setLastName(lastName)
},
}))Composing slices
You can compose slices together through the options parameter passed to slice methods:
import { atom, computed } from 'nanostores'
import { createSlice, createStore } from '@nanoslices/core'
const profile = createSlice(() => ({
firstName: atom(''),
lastName: atom(''),
}))
.computed((slice) => ({
fullName: computed(
[slice.firstName, slice.lastName],
(firstName, lastName) => [firstName, lastName].join(' '),
),
}))
.actions((slice) => ({
setFullName(firstName: string, lastName: string) {
slice.firstName.set(firstName)
slice.lastName.set(lastName)
},
}))
const employeeCard = createSlice(() => ({
employeeId: atom(''),
job: atom(''),
}))
.slices({ profile })
.computed((slice, { slices }) => ({
employeeName: computed([slices.profile.fullName], (name) => name),
}))
export const Store = createStore({ profile, employeeCard })Reading the context
You can access the context passed to the store in any of the Slice methods:
// context.ts
import { employeeService } from './services/employee'
export const context = {
services: {
employee: employeeService,
},
}
export type Context = typeof context// employee-card.ts
import { atom } from 'nanostores'
import { createSlice } from '@nanoslices/core'
import { Context } from './context'
const employeeCard = createSlice(() => ({
employeeId: atom(''),
job: atom(''),
}))
.context<Context>()
.actions((slice) => ({
setEmployeeId: (id: string) => slice.employeeId.set(id),
setJob: (job: string) => slice.job.set(job),
}))
.actions((slice, { context }) => ({
loadEmployee: async (id: string) => {
const employee = await context.services.employee.getById(id)
slice.setEmployeeId(id)
slice.setJob(employee.job)
},
}))Initializing a slice
The initialize method of slices can take an optional callback that runs with the complete slice and options, when Store.initialize()
is called:
// employee-card.ts
import { atom } from 'nanostores'
import { createSlice } from '@nanoslices/core'
import { Context } from './context'
const employeeCard = createSlice(() => ({
employeeId: atom(''),
job: atom(''),
}))
.context<Context>()
.actions((slice) => ({
setEmployeeId: (id: string) => slice.employeeId.set(id),
setJob: (job: string) => slice.job.set(job),
}))
.actions((slice, { context }) => ({
loadEmployee: async (id: string) => {
const employee = await context.services.employee.getById(id)
slice.setEmployeeId(id)
slice.setJob(employee.job)
},
}))
.initialize(async (slice) => {
await slice.loadEmployee('defaultEmployee')
})// store.ts
import { createStore } from '@nanoslices/core'
import { employeeCard } from './employee-card'
export const Store = createStore({ employeeCard })
// This will trigger the `initialize` callback of all slices
.initialize()The Store's initialize method returns the Store itself, so it can be safely chained in the export statement
if you want to initialize the slices right away.
Plugins
Official plugins
Here is a list of plugins developed and maintained by the nanoslices team.
- @nanoslices/react: React wrapper, adding a
usemethod on the Store to subscribe to atoms in React components. - @nanoslices/devtools: Connects nanoslices to the Redux DevTools.
- @nanoslices/spy: Adds a
spymethod on the Store to help using nanoslices in tests.
Writing a plugin
A nanoslices plugins extends the store creation function to add new methods on the generated store. A plugin consists
in a standardize function passed to the registerExtension function exposed by @nanoslices/core.
The function receives three parameters:
- The raw store, as in the record of slices with unlimited access to the underlying atoms.
- The options passed to the
createStorefunction - A set of dedicated plugin options:
initialState: a snapshot of what the state looks like upon initializationsubscribeToActions: a function for subscribing to actions. The passed parameters is called for each slice of the store, and receives the linkedActionSpyobject which exposes its ownsubscribemethod.takeSnapshot: a function returning a snapshot of the current state of the store.restoreSnapshot: a function taking a partial state snapshot and applying it to the store.
It returns a map of methods to add to the final store.
Here is an example of plugins, adapted from our React wrapper:
import { useStore } from '@nanostores/react'
import { registerExtension } from '@nanoslices/core'
// It's good practice to re-export Core from each plugin
// This way for instance, in a React app, @nanoslices/react can become the main entry point
export * from '@nanoslices/core'
const createUseStoreAtom = (store) => {
return (mapper) => useStore(mapper(store))
}
// The extension receives the store as first parameter, and returns a map
// of method to add to the final store
registerExtension((store) => ({
use: createUseStoreAtom(store),
}))Since nanoslices is built with TypeScript users in mind, plugins can extend nanoslices' type definitions from the
@nanoslices/types package to add typing for additional options (by extending NanoSlicesOptions) and additional
methods (by extending NanoSlices).
Here is how the React wrapper extends the NanoSlices interface:
import { Store, StoreValue } from 'nanostores'
import { Slices, StoreMapper } from '@nanoslices/types'
type UseNanoSlices<M extends Slices> = <A extends Store>(
mapper: (model: StoreMapper<M>) => A,
) => StoreValue<A>
declare module '@nanoslices/types' {
interface NanoSlices<M extends Slices, C> {
use: UseNanoSlices<M>
}
}