Skip to content

Commit

Permalink
Add setStore proxy (#79)
Browse files Browse the repository at this point in the history
* Add setStore proxy

* Add setStore types

* Write docs

* Add another example on docs

* fix example

* refactor example

* Amplify setStore docu

* fix

* fix

* fix
  • Loading branch information
aralroca authored Jan 12, 2022
1 parent 3815021 commit 97d343c
Show file tree
Hide file tree
Showing 5 changed files with 208 additions and 15 deletions.
87 changes: 78 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ _Tiny, easy and powerful **React state management** library_
- [How to export](#how-to-export)
- [3. Manage the store 🕹](#manage-the-store-)
- [useStore hook](#usestore-hook)
- [setStore helper](#setstore-helper)
- [getStore helper](#getstore-helper)
- [withStore HoC](#withstore-hoc)
- [4. Register events after an update 🚦](#register-events-after-an-update-)
Expand Down Expand Up @@ -111,6 +112,7 @@ _Output:_
| ----------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| `useStore` | `Proxy` | Proxy hook to consume and update store properties inside your components. Each time the value changes, the component is rendered again with the new value. More [info](#usestore-hook). | `const [price, setPrice] = useStore.cart.price()` |
| `getStore` | `Proxy` | Similar to `useStore` but without subscription. You can use it as a helper outside (or inside) components. Note that if the value changes, it does not cause a rerender. More [info](#getstore-helper). | `const [price, setPrice] = getStore.cart.price()` |
| `setStore` | `Proxy` | It's a proxy helper to modify a store property outside (or inside) components. More [info](#setstore-helper). | `setStore.user.name('Aral')` or `setStore.cart.price(price => price + 10)` |
| `withStore` | `Proxy` | HoC with `useStore` inside. Useful for components that are not functional. More [info](#withstore-hoc). | `withStore.cart.price(MyComponent)` |

### How to export
Expand Down Expand Up @@ -231,6 +233,75 @@ Is an `Array` with **2** items:
| update value | `function` | Function to update the store property indicated with the proxy. | Updating a store portion:<div>`const [count, setCount] = useStore.count(0)`</div>Way 1:<div>`setCount(count + 1)`</div>Way 1:<div>`setCount(c => c + 1)`</div><div>-------</div>Updating all store:<div>`const [store, updateStore] = useStore()`</div>Way 1:<div>`updateStore({ ...store, count: 2 }))`</div>Way 1:<div>`updateStore(s => ({ ...s, count: 2 }))`</div> |


### setStore helper

Useful helper to modify the store from anywhere (outside/inside components).

Example:

```js
const initialStore = { count: 0, name: 'Aral' }
const { setStore } = createStore(initialStore);

const resetStore = () => setStore(initialStore);
const resetCount = () => setStore.count(initialStore.count);
const resetName = () => setStore.name(initialStore.name);

// Component without any re-render (without useStore hook)
function Resets() {
return (
<>
<button onClick={resetStore}>
Reset store
</button>
<button onClick={resetCount}>
Reset count
</button>
<button onClick={resetName}>
Reset name
</button>
</>
);
}
```

Another example:

```js
const { useStore, setStore } = createStore({
firstName: '',
lastName: ''
});

function ExampleOfForm() {
const [formFields] = useStore()

return Object.entries(formFields).map(([key, value]) => (
<input
defaultValue={value}
type="text"
key={key}
onChange={e => {
// Update depending the key attribute
setStore[key](e.target.value)
}}
/>
))
}
```

This second example only causes re-renders in the components that consume the property that has been modified.

In this way:

```js
const [formFields, setFormFields] = useStore()
// ...
setFormFields(s => ({ ...s, [key]: e.target.value })) //
```

This causes a re-render on all components that are consuming any of the form properties, instead of just the one that has been updated. So using the `setStore` proxy helper is more recommended.

### getStore helper

It works exactly like `useStore` but with **some differences**:
Expand Down Expand Up @@ -515,12 +586,11 @@ setStore({ count: 10, username: "" });
If you have to update several properties and you don't want to disturb the rest of the components that are using other store properties you can create a helper with `getStore`.

```js
export const { useStore, getStore } = createStore(initialStore);
export const { useStore, setStore } = createStore(initialStore);

export function setStore(fields) {
Object.keys(fields).forEach((key) => {
const setStoreField = getStore[key]()[1];
setStoreField(fields[key]);
export function setFragmentedStore(fields) {
Object.entries(fields).forEach(([key, value]) => {
setStore[key](value);
});
}
```
Expand All @@ -537,12 +607,12 @@ setStore({ count: 10, username: "" });

### Define calculated properties

It's possible to use the `getStore` together with the function that is executed after each update to have store properties calculated from others.
It's possible to use the `setStore` together with the function that is executed after each update to have store properties calculated from others.

In this example the cart price value will always be a value calculated according to the array of items:

```js
export const { useStore, getStore } = createStore(
export const { useStore, setStore } = createStore(
{
cart: {
price: 0,
Expand All @@ -558,8 +628,7 @@ function onAfterUpdate({ store }) {

// Price always will be items.length * 3
if (price !== calculatedPrice) {
const [, setPrice] = getStore.cart.price();
setPrice(calculatedPrice);
setStore.cart.price(calculatedPrice);
}
}
```
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "teaful",
"version": "0.9.2",
"version": "0.10.0",
"description": "Tiny, easy and powerful React state management (less than 1kb)",
"license": "MIT",
"keywords": [
Expand Down
9 changes: 8 additions & 1 deletion package/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ declare module "teaful" {

import React from "react";

type HookReturn<T> = [T, (value: T | ((value: T) => T | undefined | null) ) => void];
type setter<T> = (value?: T | ((value: T) => T | undefined | null) ) => void;
type HookReturn<T> = [T, setter<T>];
type initialStoreType = Record<string, any>;

type Hook<S> = (
Expand Down Expand Up @@ -35,6 +36,11 @@ declare module "teaful" {
? useStoreType<S[key]> & Hook<S[key]> : Hook<S[key]>;
};

type setStoreType<S extends initialStoreType> = {
[key in keyof S]: S[key] extends initialStoreType
? setStoreType<S[key]> & setter<S[key]> : setter<S[key]>;
};

type withStoreType<S extends initialStoreType> = {
[key in keyof S]: S[key] extends initialStoreType
? withStoreType<S[key]> & HocFunc<S>
Expand All @@ -47,6 +53,7 @@ declare module "teaful" {
): {
getStore: HookDry<S> & getStoreType<S>;
useStore: Hook<S> & useStoreType<S>;
setStore: setter<S> & setStoreType<S>;
withStore: HocFunc<S> & withStoreType<S>;
};

Expand Down
16 changes: 12 additions & 4 deletions package/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {useEffect, useReducer, createElement} from 'react';
let MODE_GET = 1;
let MODE_USE = 2;
let MODE_WITH = 3;
let MODE_SET = 4;
let DOT = '.';
let extras = [];

Expand Down Expand Up @@ -58,9 +59,11 @@ export default function createStore(defaultStore = {}, callback) {
//
// MODE_GET: let [store, update] = useStore()
// MODE_USE: let [store, update] = getStore()
// MODE_SET: setStore({ newStore: true })
if (!path.length) {
let updateAll = updateField();
if (mode === MODE_USE) useSubscription(DOT, callback);
if (mode === MODE_SET) return updateAll(param);
return [allStore, updateAll];
}

Expand All @@ -72,6 +75,9 @@ export default function createStore(defaultStore = {}, callback) {
let value = getField(prop);
let initializeValue = param !== undefined && !existProperty(path);

// MODE_SET: setStore.cart.price(10)
if (mode === MODE_SET) return update(param);

if (initializeValue) {
value = param;
allStore = setField(allStore, path, value);
Expand All @@ -90,9 +96,11 @@ export default function createStore(defaultStore = {}, callback) {
return [value, update];
},
};
let useStore = new Proxy(() => MODE_USE, validator);
let getStore = new Proxy(() => MODE_GET, validator);
let withStore = new Proxy(() => MODE_WITH, validator);
let createProxy = (mode) => new Proxy(() => mode, validator);
let useStore = createProxy(MODE_USE);
let getStore = createProxy(MODE_GET);
let withStore = createProxy(MODE_WITH);
let setStore = createProxy(MODE_SET);

/**
* Hook to register a listener to force a render when the
Expand Down Expand Up @@ -165,7 +173,7 @@ export default function createStore(defaultStore = {}, callback) {
let result = extras.reduce((res, fn) => {
let newRes = fn(res, subscription);
return typeof newRes === 'object' ? {...res, ...newRes} : res;
}, {useStore, getStore, withStore});
}, {useStore, getStore, withStore, setStore});

/**
* createStore function returns:
Expand Down
109 changes: 109 additions & 0 deletions tests/setStore.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {render, screen} from '@testing-library/react';
import userEvent from '@testing-library/user-event';

import '@babel/polyfill';

import createStore from '../package/index';

describe('setStore', () => {
it('should avoid rerenders on component that use the setStore', () => {
const renderCart = jest.fn();
const renderOther = jest.fn();
const renderUpdateProps = jest.fn();

const {useStore, setStore} = createStore({cart: {price: 0}, name: 'Aral', count: 0});

function UpdateProps() {
renderUpdateProps();
return (
<button data-testid="click" onClick={() => {
setStore.name('ARAL');
setStore.count(10);
}}
/>
);
}

function Cart() {
const [cart] = useStore.cart();
renderCart();
return <div data-testid="price">{cart.price}</div>;
}

function Other() {
const [name] = useStore.name();
const [count] = useStore.count();
renderOther();
return <div data-testid="other">{name} {count}</div>;
}

render(
<>
<Cart />
<Other />
<UpdateProps />
</>,
);

expect(renderCart).toHaveBeenCalledTimes(1);
expect(renderOther).toHaveBeenCalledTimes(1);
expect(renderUpdateProps).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('price').textContent).toContain('0');
expect(screen.getByTestId('other').textContent).toContain('Aral 0');

userEvent.click(screen.getByTestId('click'));
expect(renderUpdateProps).toHaveBeenCalledTimes(1);
expect(renderCart).toHaveBeenCalledTimes(1);
expect(renderOther).toHaveBeenCalledTimes(2);
expect(screen.getByTestId('price').textContent).toContain('0');
expect(screen.getByTestId('other').textContent).toContain('ARAL 10');
});

it('Update serveral portions should avoid rerenders in the rest', () => {
const renderCart = jest.fn();
const renderOther = jest.fn();
const {useStore, setStore} = createStore({cart: {price: 0}, name: 'Aral', count: 0});

function setFragmentedStore(fields) {
Object.keys(fields).forEach((key) => {
setStore[key](fields[key]);
});
}

function UpdateProps() {
return <button data-testid="click" onClick={() => setFragmentedStore({name: 'ARAL', count: 10})} />;
}

function Cart() {
const [cart] = useStore.cart();
renderCart();
return <div data-testid="price">{cart.price}</div>;
}

function Other() {
const [name] = useStore.name();
const [count] = useStore.count();
renderOther();
return <div data-testid="other">{name} {count}</div>;
}

render(
<>
<Cart />
<Other />
<UpdateProps />
</>,
);

expect(renderCart).toHaveBeenCalledTimes(1);
expect(renderOther).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('price').textContent).toContain('0');
expect(screen.getByTestId('other').textContent).toContain('Aral 0');

userEvent.click(screen.getByTestId('click'));
expect(renderCart).toHaveBeenCalledTimes(1);
expect(renderOther).toHaveBeenCalledTimes(2);
expect(screen.getByTestId('price').textContent).toContain('0');
expect(screen.getByTestId('other').textContent).toContain('ARAL 10');
});
});

0 comments on commit 97d343c

Please sign in to comment.