Ecosyste.ms: Awesome

An open API service indexing awesome lists of open source software.

Awesome Lists | Featured Topics | Projects

https://github.com/dhmk083/dhmk-zustand-lens


https://github.com/dhmk083/dhmk-zustand-lens

Last synced: 3 days ago
JSON representation

Awesome Lists containing this project

README

        

# @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 = {
set: Setter; // `set` parameter
get: Getter; // `get` parameter
api: ResolveStoreApi; // `api` parameter
rootPath: ReadonlyArray; // path from root level of state
relativePath: ReadonlyArray; // path from parent lens or root
atomic: (fn: () => void) => void; // see `atomic` middleware
};
```

Setter has this signature: `(value: Partial | ((prev: T) => Partial), 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 = (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(
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((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`. 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()(
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 = (
fn: Lens>
): LensOpaqueType => lens(namedSetter(fn));
```

## Advanced options

### `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()(
withLenses({
// ...

[meta]: {
// ...
},
})
);
```

The `[meta]` object accepts the following optional properties:

#### `postprocess(state: T, prevState: T, ...args): Partial | 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.

### `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)
)
}
```