UNPKG

@dhmk/zustand-lens

Version:

Lens support for zustand

478 lines (356 loc) 12.5 kB
# @dhmk/zustand-lens Lens support for [zustand](https://github.com/pmndrs/zustand). With this package you can easily manage nested state inside your main state. Lenses allow you to create isolated and reusable components. A lens has a pair of functions `set` and `get` which have same signatures as zustand's functions, but they operate only on a particular slice of main state. A quick comparison: ```ts import create from "zustand"; import { withLenses, lens } from "@dhmk/zustand-lens"; create( withLenses((set, get) => { // write and read whole state return { subStore: lens((subSet, subGet) => { // write and read `subStore` state }), }; }) ); ``` ## Install ```sh npm install @dhmk/zustand-lens ``` ## Usage ```ts import { create } from 'zustand' import { withLenses, lens } from '@dhmk/zustand-lens' // set, get - global const useStore = create(withLenses((set, get, api) => { return { // set, get - only for storeA storeA: lens((set, get, api) => ({ data: ..., action: (arg) => set({data: arg}) })), // set, get - only for storeB storeB: lens((set, get, api) => ({ data: ..., action: (arg) => set({data: arg}) })), globalStore: { data: ..., action: () => set({...}) // global setter } } })) // or use a shorter version if you don't need global `set` and `get` create(withLenses({ storeA: lens(...), storeB: lens(...) })) ``` ## API ### `withLenses(config: (set, get, api) => T): T` ### `withLenses(obj: T): T` Middleware function. It calls `config` function with the same args as the default zustand's `create` function and then converts returned object expanding all `lens` instances to proper objects. You can also provide a plain object instead of a function. ### `lens(fn: (set, get, api, context) => T): T` Creates a lens object. The first two parameters `set` and `get` are functions which write and read a subset of global state relative to a place where `lens` is appeared. The third, `api` parameter is zustand store and the last parameter `context` is a lens context. ```ts type LensContext<T, S> = { set: Setter<T>; // `set` parameter get: Getter<T>; // `get` parameter api: ResolveStoreApi<S>; // `api` parameter rootPath: ReadonlyArray<string>; // path from root level of state relativePath: ReadonlyArray<string>; // path from parent lens or root atomic: (fn: () => void) => void; // see `atomic` middleware }; ``` Setter has this signature: `(value: Partial<T> | ((prev: T) => Partial<T>), replace?: boolean, ...args) => void`. It passes unknown arguments to a top-level `set` function. **WARNING**: you should not use return value of this function in your code. It returns opaque object that is transformed into a real object by `withLenses` function. **NOTE**: this function used to throw an error if it was called outside `withLenses` function. It was meant for accenting, that `lens` can not be created dynamically after `withLenses` has been called. But it's fine to create lens beforehand, so I removed that error (1.0.3 and 2.0.3). Now you can call it like this: ```js const todosSlice = lens(() => ...) const usersSlice = lens(() => ...) const useStore = create(withLenses({ todosSlice, usersSlice, })) ``` Also, you can use type helper if you want to separate your function from `lens` wrapper: ```ts import { Lens, lens } from "@dhmk/zustand-lens"; /* type Lens< T, // slice type S, // store state type or store api type Setter // `set` function type > */ type MenuState = { isOpened: boolean; toggle(open); }; // `set` and `get` are typed const menuState: Lens<MenuState> = (set, get, api) => ({ isOpened: false, toggle(open) { set({ isOpened: open }); }, }); const menuSlice = lens(menuState); ``` ### `createLens(set, get, path: string | string[]): [set, get]` Creates explicit lens object. It takes `set` and `get` arguments and `path` and returns a pair of setter and getter which operates on a subset of parent state relative to `path`. You can chain lenses. Also, you can use this function as standalone, without `withLenses` middleware. ```ts import { create } from "zustand"; import { createLens } from "@dhmk/zustand-lens"; const useStore = create((set, get) => { const lensA = createLens(set, get, "a"); const lensB = createLens(...lensA, "b"); const [setC] = createLens(...lensB, "c"); return { a: { b: { c: { value: 111, }, }, }, changeValue: (value) => setC({ value }), }; }); useStore.getState().changeValue(222); console.log(useStore.getState()); /* a: { b: { c: { value: 222 } } } */ ``` ## Typescript ```ts type Store = { id: number; name: string; nested: Nested; }; type Nested = { text: string; isOk: boolean; toggle(); }; // option 1: type whole store const store1 = create<Store>( withLenses({ id: 123, name: "test", nested: lens((set) => ({ text: "test", isOk: true, toggle() { set((p /* Nested */) => ({ isOk: !p.isOk })); }, })), }) ); // option 2: type lens const store2 = create( withLenses({ id: 123, name: "test", nested: lens<Nested>((set) => ({ text: "test", isOk: true, toggle() { set((p /* Nested */) => ({ isOk: !p.isOk })); }, })), }) ); ``` ## Immer Immer is supported out-of-the-box. There is one caveat, however. Draft's type will be `T` and not `Draft<T>`. You can either add it yourself, or just don't use readonly properties in your type. ```ts import { immer } from "zustand/middleware/immer"; const store = create<Store>()( immer( withLenses({ id: 123, name: "test", nested: lens((set) => ({ text: "test", isOk: true, toggle() { set((p /* Nested */) => { p.isOk = !p.isOk; }); }, })), }) ) ); ``` ## Lens middleware Since `lens` takes an ordinary function, you can pre-process your lens object with various middleware, in the same way zustand does. This example uses custom `set` function which takes a new state and an action name for logging. See the source code for tips on how to write and type your middleware. ```ts import { lens, namedSetter } from "@dhmk/zustand-lens"; const test = lens( namedSetter((set) => ({ name: "abc", setName() { set({ name: "def" }, "@test/setName"); }, })) ); ``` You can even create custom lenses. ```ts import { lens, namedSetter } from "@dhmk/zustand-lens"; const lensWithNamedSetter = <T, S = unknown>( fn: Lens<T, S, NamedSet<T>> ): LensOpaqueType<T, S> => lens(namedSetter(fn)); ``` ## Advanced options <a id="atomic"></a> ### `atomic(stateCreator)` Middleware for atomic set operations. Atomic operations can have multiple calls of `setState` function, but callbacks attached by `subscribe` function will only be called once at the end of an atomic block. This middleware enables `atomic` function from lens context and also makes `[meta].setter` function atomic. ### `[meta]` Advanced lens configuration. You can place this symbol inside lens or root state. If you are using Typescript and want to add this symbol to a root state, you may encounter an error. In this case use the following workaround: ```ts // add { [meta] } to your state type create<State & { [meta] }>()( withLenses({ // ... [meta]: { // ... }, }) ); ``` The `[meta]` object accepts the following optional properties: #### `postprocess(state: T, prevState: T, ...args): Partial<T> | void` This function is called after calling `set` function before comitting new state to a parent `set` function. It is called with a new temporary state that will be comitted, current state and all extra arguments, that were passed to a `set` function. You may return new state and it will me merged with a `state` argument. This function must be pure. You may mutate `state` argument only if using `immer` middleware. #### `setter(next: Function, context: LensContext): void` This function is called whenever you call lens (or root) `set` function. This way you can customize pre-set and post-set behavior. You can run side-effects here. You should call `next` function once and synchronously to delegate set operation to a parent lens (or root), similar to `next` function in `express.js`. If you are using [`atomic`](#atomic) middleware, this function will be executed atomically. Also you may want to use [`watch`](#watch) helper to conveniently run side-effects on state changes. ### `Understanding order of invocation.` Given the following store: ```ts const store = create( withLenses({ someSlice: lens(() => ({ nested: lens((set) => ({ id: 1, test() { console.log("test before"); set({ id: 2 }); console.log("test after"); }, [meta]: { postprocess() { console.log("nested postprocess"); }, setter(set) { console.log("nested setter before"); set(); console.log("nested setter after"); }, }, })), [meta]: { postprocess() { console.log("someSlice postprocess"); }, setter(set) { console.log("someSlice setter before"); set(); console.log("someSlice setter after"); }, }, })), [meta]: { postprocess() { console.log("root postprocess"); }, setter(set) { console.log("root setter before"); set(); console.log("root setter after"); }, }, }) ); store.getState().someSlice.nested.test(); ``` Console log would be the following: ``` test before nested setter before someSlice setter before root setter before nested postprocess someSlice postprocess root postprocess root setter after someSlice setter after nested setter after test after ``` ## Misc ### `mergeDeep(a, b)` ### `mergeDeep(b)(a)` Merges object `b` with `a` recursively (doesn't merge arrays). ### `mergeDeepLeft(a, b)` Merges object `a` with `b` (note order). Useful with `persist` middleware. ### `persistOptions` Helper for `persist` middleware. Can be used without lenses. First, you need to add these options to persist's config. Now you can attach options to any object in your state, just call `persistOptions` as function and provide an object with two optional functions: `save` and `load`. Whenever your state needs to be persisted, `save` function will be called and return value will be persisted. Similarly, `load` function will be called on hydration. This allows you to control, which data you want to save/restore. Both functions must be pure, don't mutate provided arguments. ```ts const store = create(persist(() => ({ // ... some state ...persistOptions({ save(state) { // return an object that will be saved }, load(persistedState) { // return an object that will be used as new state } }) nested: { // ... some state // can be nested too ...persistOptions({ save load }) } }), { name: 'my-store', // don't forget to add options to persist config ...persistOptions })) ``` ### `subscribe(store, selector, effect, options?)` Alternative to [`subscribeWithSelector`](https://github.com/pmndrs/zustand#using-subscribe-with-selector) middleware. <a id="watch"></a> ### `watch(selector, effect, options?)` Similar to `subscribe` function, meant to be used in `setter` hook. It calls lens' `set` function first and then runs `effect` function if needed. Doesn't require to unsubscribe. ### `combineWatchers(...watchers)` Runs watchers (or any setter-like functions) sequentially. Useful if you have multiple watchers. Example: ```ts [meta]: { setter: combineWatchers( watch(state => state.id, handleIdChange), watch(state => state.name, handleNameChange) ) } ```