diff --git a/docs/api.md b/docs/api.md index a25df991..54e61586 100644 --- a/docs/api.md +++ b/docs/api.md @@ -7,7 +7,7 @@ The `createStore` is a main function exported from the library, which creates a ## createStore -`createStore(models)` +`createStore(models, options)` The function called to create a store. @@ -25,7 +25,9 @@ const { } = createStore(models); ``` -### models +### Arguments + +#### models ```js import { createStore } from '@ice/store' @@ -43,83 +45,17 @@ const models = { createStore(models); ``` -#### state - -`state: any`: Required - -The initial state of the model. - -```js -const model = { - state: { loading: false }, -}; -``` +See [model](#model) for details. -#### reducers +#### options -`reducers: { [string]: (prevState, payload) => any }` - -An object of functions that change the model's state. These functions take the model's previous state and a payload, and return the model's next state. These should be pure functions relying only on the state and payload args to compute the next state. For code that relies on the "outside world" (impure functions like api calls, etc.), use effects. - -```js -const counter = { - state: 0, - reducers: { - add: (state, payload) => state + payload, - } -}; -``` +- `disableImmer` (boolean, optional, default=false) -#### effects + If you set this to true, then [immer](https://github.com/immerjs/immer) will be disabled, meaning you can no longer mutate state directly within actions and will instead have to return immutable state as in a standard reducer. -`effects: { [string]: (prevState, payload, actions, globalActions) => void }` +### Returns -An object of functions that can handle the world outside of the model. Effects provide a simple way of handling async actions when used with async/await. - -```js -const counter = { - state: 0, - effects: { - async add(prevState, payload, actions) { - // wait for data to load - const response = await fetch('http://example.com/data'); - const data = await response.json(); - // pass the result to a local reducer - actions.update(data); - }, - }, - reducers: { - update(prev, data) { - return {...prev, ...data}; - } - }, -}; -``` - -You can call another action by useing `actions` or `globalActions`: - -```js -const user = { - state: { - foo: [], - }, - effects: { - like(prevState, payload, actions, globalActions) => { - actions.foo(payload); // call user's actions - globalActions.user.foo(payload); // call actions of another model - }, - }, - reducres: { - foo(prevState, payload) { - return { - ...prevState, - }; - }, - } -}; -``` - -### Provider +#### Provider `Provider(props: { children, initialStates })` @@ -175,7 +111,7 @@ ReactDOM.render( ); ``` -### useModel +#### useModel `useModel(name: string): [ state, actions ]` @@ -187,7 +123,9 @@ const counter = { value: 0, }, reducers: { - add: (prevState, payload) => ({...prevState, value: prevState.value + payload}), + add: (state, payload) => { + state.value = state.value + payload; + }, }, }; @@ -202,7 +140,7 @@ function FunctionComponent() { } ``` -### useModelActions +#### useModelActions `useModelActions(name: string): actions` @@ -215,7 +153,7 @@ function FunctionComponent() { } ``` -### useModelEffectsState +#### useModelEffectsState `useModelEffectsState(name: string): { [actionName: string]: { isLoading: boolean, error: Error } } ` @@ -235,7 +173,7 @@ function FunctionComponent() { } ``` -### withModel +#### withModel `withModel(name: string, mapModelToProps?: (model: [state, actions]) => Object = (model) => ({ [name]: model }) ): (React.Component) => React.Component` @@ -298,7 +236,7 @@ export default withModel( )(TodoList); ``` -### withModelActions +#### withModelActions `withModelActions(name: string, mapModelActionsToProps?: (actions) => Object = (actions) => ({ [name]: actions }) ): (React.Component) => React.Component` @@ -326,7 +264,7 @@ export default withModelActions('todos')(TodoList); You can use `mapModelActionsToProps` to set the property as the same way like `mapModelToProps`. -### withModelEffectsState +#### withModelEffectsState `withModelEffectsState(name: string, mapModelActionsStateToProps?: (effectsState) => Object = (effectsState) => ({ [name]: effectsState }) ): (React.Component) => React.Component` @@ -356,11 +294,10 @@ You can use `mapModelActionsStateToProps` to set the property as the same way li ## createModel -`createStore(model)` +`createStore(model, options)` The function called to create a model. - ```js import { createModel } from '@ice/store'; @@ -372,7 +309,134 @@ const [ ] = createModel(model); ``` -### Provider +### Arguments + +#### model + +##### state + +`state: any`: Required + +The initial state of the model. + +```js +const model = { + state: { loading: false }, +}; +``` + +##### reducers + +`reducers: { [string]: (prevState, payload) => any }` + +An object of functions that change the model's state. These functions take the model's previous state and a payload, and return the model's next state. These should be pure functions relying only on the state and payload args to compute the next state. For code that relies on the "outside world" (impure functions like api calls, etc.), use effects. + +```js +const counter = { + state: 0, + reducers: { + add: (state, payload) => state + payload, + } +}; +``` + +Reducer could be use mutable method to achieve immutable state. Like the example: + +```js +const todo = { + state: [ + { + todo: 'Learn typescript', + done: true, + }, + { + todo: 'Try immer', + done: false, + }, + ], + reducers: { + done(state) { + state.push({ todo: 'Tweet about it' }); + state[1].done = true; + }, + }, +} +``` + +In Immer, reducers perform mutations to achieve the next immutable state. Keep in mind, Immer only supports change detection on plain objects and arrays, so primitive values like strings or numbers will always return a change. Like the example: + +```js +const count = { + state: 0, + reducers: { + add(state) { + state += 1; + return state; + }, + }, +} +``` + +See [docs/recipes](./recipes.md#immutable-description) for more details. + +##### effects + +`effects: { [string]: (prevState, payload, actions, globalActions) => void }` + +An object of functions that can handle the world outside of the model. Effects provide a simple way of handling async actions when used with async/await. + +```js +const counter = { + state: 0, + effects: { + async add(prevState, payload, actions) { + // wait for data to load + const response = await fetch('http://example.com/data'); + const data = await response.json(); + // pass the result to a local reducer + actions.update(data); + }, + }, + reducers: { + update(prevState, payload) { + return { ...prevState, ...payload }; + } + }, +}; +``` + +You can call another action by useing `actions` or `globalActions`: + +```js +const user = { + state: { + foo: [], + }, + effects: { + like(prevState, payload, actions, globalActions) => { + actions.foo(payload); // call user's actions + globalActions.user.foo(payload); // call actions of another model + }, + }, + reducres: { + foo(prevState, payload) { + return { + ...prevState, + }; + }, + } +}; +``` + +#### options + +- `disableImmer` (boolean, optional, default=false) + + If you set this to true, then [immer](https://github.com/immerjs/immer) will be disabled, meaning you can no longer mutate state directly within actions and will instead have to return immutable state as in a standard reducer. + +### Returns + +#### Provider `Provider(props: { children, initialState })` @@ -433,7 +497,7 @@ function FunctionComponent() { } ``` -### useActions +#### useActions `useActions(): actions` @@ -446,7 +510,7 @@ function FunctionComponent() { } ``` -### useEffectsState +#### useEffectsState `useEffectsState(): { [actionName: string]: { isLoading: boolean, error: Error } } ` diff --git a/docs/recipes.md b/docs/recipes.md index 8cd42502..9730691e 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -233,10 +233,7 @@ const todos = { }, reducers: { update(prevState, payload) { - return { - ...prevState, - ...payload, - }; + return { ...prevState, ...payload }; }, }, effects: { @@ -329,6 +326,59 @@ ReactDOM.render( , document.getElementById('root')); ``` +## Immutable Description + +### Don't destructure the state argument + +In order to support the mutation API we utilise [immer](https://github.com/immerjs/immer). Under the hood immer utilises Proxies in order to track our mutations, converting them into immutable updates. Therefore if you destructure the state that is provided to your action you break out of the Proxy, after which any update you perform to the state will not be applied. + +Below are a couple examples of this antipattern. + +```js +const model = { + reducers: { + addTodo({ items }, payload) { + items.push(payload); + }, + + // or + + addTodo(state, payload) => { + const { items } = state; + items.push(payload); + } + } +} +``` + +### Switching to an immutable API + +By default we use immer to provide a mutation based API. + +This is completely optional, you can instead return new state from your actions like below. + +```js +const model = { + state: [], + reducers: { + addTodo((prevState, payload) { + // 👇 new immutable state returned + return [...prevState, payload]; + }) + } +} +``` + +Should you prefer this approach you can explicitly disable immer via the disableImmer option value of createStore. + +```js +import { createStore } from '@ice/store'; + +const store = createStore(models, { + disableImmer: true; // 👈 set the flag +}); +``` + ## Comparison - O: Yes diff --git a/examples/todos/src/models/todos.ts b/examples/todos/src/models/todos.ts index 236f4d70..07b8671b 100644 --- a/examples/todos/src/models/todos.ts +++ b/examples/todos/src/models/todos.ts @@ -19,13 +19,8 @@ const todos = { ], }, reducers: { - toggle(prevState: TodosState, index: number) { - const dataSource = [].concat(prevState.dataSource); - dataSource[index].done = !prevState.dataSource[index].done; - return { - ...prevState, - dataSource, - }; + toggle(state: TodosState, index: number) { + state.dataSource[index].done = !state.dataSource[index].done; }, update(prevState: TodosState, payload) { return { @@ -36,7 +31,7 @@ const todos = { }, effects: { add(state: TodosState, todo: Todo, actions, globalActions) { - const dataSource = [].concat(state.dataSource); + const dataSource = ([] as any).concat(state.dataSource); dataSource.push(todo); globalActions.user.setTodos(dataSource.length); actions.update({ @@ -65,11 +60,11 @@ const todos = { }, async remove(state: TodosState, index: number, actions, globalActions) { await delay(1000); - const dataSource = [].concat(state.dataSource); + const dataSource = ([] as any).concat(state.dataSource); dataSource.splice(index, 1); globalActions.user.setTodos(dataSource.length); - actions.update(state); + actions.update({dataSource}); }, }, }; diff --git a/examples/todos/src/models/user.ts b/examples/todos/src/models/user.ts index 71bd1737..c6daa643 100644 --- a/examples/todos/src/models/user.ts +++ b/examples/todos/src/models/user.ts @@ -9,8 +9,8 @@ const user = { auth: false, }, reducers: { - setTodos(prevState, todos: number) { - return { ...prevState, todos }; + setTodos(state, todos: number) { + state.todos = todos; }, update(prevState, payload) { return { diff --git a/package.json b/package.json index eb046cca..3a11d6f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ice/store", - "version": "1.1.2", + "version": "1.2.0", "description": "Lightweight React state management library based on react hooks", "main": "lib/index.js", "types": "lib/index.d.ts", @@ -59,6 +59,7 @@ "preset": "ts-jest" }, "dependencies": { + "immer": "^6.0.1", "is-promise": "^2.1.0", "lodash.transform": "^4.6.0", "utility-types": "^3.10.0" diff --git a/src/createModel.tsx b/src/createModel.tsx index 9f710702..4a8b0fa5 100644 --- a/src/createModel.tsx +++ b/src/createModel.tsx @@ -2,6 +2,7 @@ import { useState, useMemo, useEffect, useRef, useCallback } from 'react'; import isPromise from 'is-promise'; import transform from 'lodash.transform'; +import produce, { enableES5 } from 'immer'; import { createContainer } from './createContainer'; import { ReactSetState, @@ -18,11 +19,14 @@ import { ModelEffectsState, SetFunctionsState, ModelValue, + Options, } from './types'; +enableES5(); + const isDev = process.env.NODE_ENV !== 'production'; -export function createModel(config: C, namespace?: K, modelsActions?): Model { +export function createModel(config: C, options?: Options, namespace?: K, modelsActions?): Model { type IModelState = ConfigPropTypeState; type IModelConfigMergedEffects = ConfigMergedEffects; type IModelConfigMergedEffectsKey = keyof IModelConfigMergedEffects; @@ -39,6 +43,7 @@ export function createModel(config: C, namespace?: reducers = {}, } = config; const mergedEffects = { ...defineActions, ...effects }; + const immerable = !(options && options.disableImmer); let actions; if (Object.keys(defineActions).length > 0) { @@ -153,7 +158,16 @@ export function createModel(config: C, namespace?: }); const setReducers = transform(reducers, (result, fn, name) => { - result[name] = (payload) => setState((prevState) => fn(prevState, payload)); + result[name] = (payload) => setState((prevState) => + immerable && typeof prevState === 'object' + ? produce(prevState, (draft) => { + const next = fn(draft, payload); + if (typeof next === 'object') { + return next; + } + }) + : fn(prevState, payload), + ); }); return { ...setReducers, ...setEffects }; diff --git a/src/createStore.tsx b/src/createStore.tsx index 3f5c5699..7e1efe1a 100644 --- a/src/createStore.tsx +++ b/src/createStore.tsx @@ -9,9 +9,10 @@ import { ModelActions, ModelEffectsState, UseModelValue, + Options, } from './types'; -export function createStore(configs: C) { +export function createStore(configs: C, options?: Options) { function getModel(namespace: K): Model { const model = models[namespace]; if (!model) { @@ -101,7 +102,7 @@ export function createStore(configs: C) { const modelsActions = {}; const models: { [K in keyof C]?: Model } = transform(configs, (result, config, namespace) => { - result[namespace] = createModel(config, namespace, modelsActions); + result[namespace] = createModel(config, options, namespace, modelsActions); }); return { diff --git a/src/types.ts b/src/types.ts index 10663d75..599fcdf1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -147,3 +147,7 @@ export type Model = ] >; export type UseModelValue = [ ConfigPropTypeState, ModelActions ]; + +export interface Options { + disableImmer?: boolean; +}