English | 简体中文
Lightweight React state management library based on react hooks
icestore requires React 16.8.0 or later.
npm install @ice/store --save
icestore
is a lightweight React state management library based on hooks. It has the following core features:
- Minimal & Familiar API: No additional learning costs, easy to get started with the knowledge of React Hooks.
- Enough & Extensible: Cover 80% most common usage scenes and extend the remaining 20% scenes with builtin middleware mechanism.
- Class Component Support: Make old projects enjoying the fun of lightweight state management with friendly compatibility strategy.
- Built in Async Status: Records loading and error status of async actions, simplifying the rendering logic in the view layer.
- Typescript Support: Provide complete type definitions to support intelliSense in VSCode.
- Optimal Performance: Decreases the number of view components that rerender when the state changes by creating multiple stores.
The data flow is as follows:
Let's build a simple todo app from scatch using icestore
which includes following steps:
-
Define a store config (a plain JavaScript object) which consists of function properties (correspond to the action) and other properties (correspond to state).
// src/stores/todos.js export default { dataSource: [], async refresh() { this.dataSource = await new Promise(resolve => setTimeout(() => { resolve([ { name: 'react' }, { name: 'vue', done: true }, { name: 'angular' } ]); }, 1000) ); }, add(todo) { this.dataSource.push(todo); }, };
-
Initialize the store instance and register the pre-defined store config using the namespace.
// src/stores/index.js import todos from './todos'; import Store from '@ice/store'; const storeManager = new Store(); const stores = storeManager.registerStores({ todos }); export default stores;
-
In the view component, you can get the store config (including state and actions) by using the useStore hook after importing the store instance. After that, you can trigger actions through event callbacks or by using the useEffect hook, which binds the state to the view template.
// src/index.js import React, { useEffect } from 'react'; import ReactDOM from 'react-dom'; import stores from './stores'; function Todo() { const todos = stores.useStore('todos'); const { dataSource, refresh, add, remove, toggle } = todos; useEffect(() => { refresh(); }, []); function onAdd(name) { add({ name }); } function onRemove(index) { remove(index); } function onCheck(index) { toggle(index); } const noTaskView = <span>no task</span>; const loadingView = <span>loading...</span>; const taskView = dataSource.length ? ( <ul> {dataSource.map(({ name, done }, index) => ( <li key={index}> <label> <input type="checkbox" checked={done} onClick={() => onCheck(index)} /> {done ? <s>{name}</s> : <span>{name}</span>} </label> <button onClick={() => onRemove(index)}>-</button> </li> ))} </ul> ) : ( noTaskView ); return ( <div> <h2>Todos</h2> {!refresh.loading ? taskView : loadingView} <div> <input onKeyDown={event => { if (event.keyCode === 13) { onAdd(event.target.value); event.target.value = ''; } }} placeholder="Press Enter" /> </div> </div> ); } const rootElement = document.getElementById('root'); ReactDOM.render(<Todo />, rootElement);
Complete example is presented in this CodeSandbox, feel free to play with it.
icestore provides complete type definitions to support IntelliSense in VSCode. TS example is presented in this CodeSandbox.
Register multiple store configs to the global icestore instance.
- Parameters
- namespace {string} unique name of the store
- stores {object} multiple store's config object including store's state and actions
- Return value
- {object} store manager object which including follow methods
-
useStore {function} Hook to use a single store.
- Parameters
- namespace {string} store namespace
- Return value
- {object} single store instance
- Parameters
-
useStores {function} Hook to use multiple stores.
- Parameters
- namespaces {array} array of store namespaces
- Return value
- {object} object of stores' instances divided by namespace
- Parameters
-
withStore {function}
- Parameters
- namespace {string} store namespace
- mapStoreToProps {function} optional, mapping store to props
- Return value
- HOC
- Parameters
-
withStores {function}
- Parameters
- namespaces {array} array of store namespaces
- mapStoresToProps {function} optional, mapping store to props
- Return value
- HOC
- Parameters
-
getState {function} Get the latest state of individual store by namespace.
- Parameters
- namespace {string} store namespace
- Return value
- {object} the latest state of the store
- Parameters
-
- {object} store manager object which including follow methods
Apply middleware to all the store if the second parameter is not specified, otherwise apply middleware the store by namespace.
- Parameters
- middlewares {array} middleware array to be applied
- namespace {string} store namespace
- Return value
- void
icestore
has built-in support to access the executing status of async actions. This enables users to have access to the loading and error executing status of async actions without defining extra state, making the code more consise and clean.
action.loading
- flag checking if the action is executing- Type: {boolean}
- Default: false
action.error
- error object if error was throw after action executed- Type: {object}
- Default: null
action.disableLoading
- flag to disable the loading effect of the action. If this is set to true, relevant view components would not rerender when their loading status changes- Type: {boolean}
- Default: false
store.disableLoading
- flag to disable the loading effect at global level. An action's disableLoading flag will always take priority when both values are set.- Type: {boolean}
- Default: false
const todos = store.useStore('todos');
const { refresh, dataSource } = todos;
useEffect(() => {
refresh();
}, []);
const loadingView = (
<div>
loading.......
</div>
);
const taskView = !refresh.error ? (
<ul>
{dataSource.map(({ name }) => (
<li>{name}</li>
))}
</ul>
) : (
<div>
{refresh.error.message}
</div>
);
return (
<div>
{!refresh.loading ? taskView : loadingView}
<Loading />
);
import React from 'react';
import ReactDOM from 'react-dom';
import Icestore from '@ice/store';
interface Todo {
id: number;
name: string;
}
interface TodoStore {
dataSource: Todo[];
refresh: () => void;
remove: (id: number) => void;
}
const todos: TodoStore = {
// Action && State
};
const icestore = new Icestore();
const stores = icestore.registerStores({
todos,
});
class TodoList extends Component<{store: TodoStore}> {
onRmove = (id) => {
const {store} = this.props;
store.remove(id);
}
componentDidMount() {
this.props.store.refresh();
}
render() {
const {store} = this.props;
return (
<div>
{
store.dataSource.map(({id, name}) => {
return (<p>
{name}
<button onClick={() => onRmove(id)} >Delete</button>
</p>);
})
}
</div>
);
}
}
const TodoListWithStore = stores.withStore('todos')(TodoList);
ReactDOM.render(<TodoListWithStore />, document.body);
If you have used server side frameworks such as Express or koa, you were probably familiar with the concept of middleware already. Among these frameworks, middleware is used to insert custom code between receiving request
and generating response
, the functionality of middlewares include data mutation、authority check before the request was handled and add HTTP header、log printing after the request was handled.
In state management area, Redux also implements middleware mechanism, it was used to put custom code between action dispatching
and reaching reducer
. Its functionalities include log printing, async mechanism such as thunk, promise.
Like Redux, the purpose of icestore
implementing middleware mechanism is to add an extensive mechanism between action was not dispatched and dispatched. The difference is that icestore
already supports async action, so there is no need to write middleware for async support.
icestore
takes insiprations from koa for its middleware API design as follows:
async (ctx, next) => {
// logic before action was dispatched
const result = await next();
// logic after action was dispatched
return result;
}
Note: If there is return value in action, all the middlewares in the chain must return the executing result of the next middleware to ensure the action's return value is correctly return from middleware chain.
- ctx.action - the object of the action dispatched
- Type:{object}
- Return Value:void
- ctx.action.name - the name of action dispatched
- Type:{string}
- Return Value:void
- ctx.action.arguments - the arguments of current action function
- Type:{array}
- Return Value:void
- ctx.store - the store object
- Type:{object}
- Return Value:void
- ctx.store.namespace - the store namespace
- Type:{string}
- Return Value:void
- ctx.store.getState - the method to get the latest state value of current store
- Type:{function}
- Return Value:void
The example is as follows:
const {
action, // the object of the action dispatched
store, // the store object
} = ctx;
const {
name, // the name of action dispatched
arguments, // the arguments of current action function
} = action;
const {
namespace, // the store namespace
getState, // the method to get the latest state value of current store
} = store;
Due the multiple store design of icestore
, it supports registering middlewares for indivisual store as follows:
-
Global registration: middlewares apply to all stores.
import Icestore from '@ice/store'; const stores = new Icestore(); stores.applyMiddleware([a, b]);
-
Registration by namespace: The ultimate middleware queue of single store will merge global middlewares with its own middlewares.
stores.applyMiddleware([a, b]); stores.applyMiddleware([c, d], 'foo'); // store foo middleware is [a, b, c, d] stores.applyMiddleware([d, c], 'bar'); // store bar middleware is [a, b, d, c]
icestore
provide an official logger middleware to facilitate user traceing state changes and improve debug productivity.
npm install @ice/store-logger --save
Use applyMiddleware
API to push logger middleware into middleware queue.
import todos from './todos';
import Icestore from '@ice/store';
import logger from '@ice/store-logger';
const icestore = new Icestore();
const middlewares = [];
// Turn off logger middleware in production enviroment
if (process.env.NODE_ENV !== 'production') {
middlewares.push(logger);
}
icestore.applyMiddleware(middlewares);
icestore.registerStore('todos', todos);
When action was dispatched, the log will be printed into browser's DevTools by realtime:
The logger includes the following infos:
- Store Name: namespace of current store
- Action Name: action being dispatched
- Added / Deleted / Updated: type of state changes
- Old state: state before change
- New state: state after change
Because all the states and actions are contained in a plain JavaScript object, it is easy to write tests without using mock objects.
Example:
describe('todos', () => {
test('refresh data success', async () => {
await todos.refresh();
expect(todos.dataSource).toEqual([
{
name: 'react'
},
{
name: 'vue',
done: true
},
{
name: 'angular'
}
]);
});
});
Please refer to the todos.spec.js
file in the sandbox above for complete reference.
icestore
enforces all the mutations to the state to occur only in action methods. Mutation occurred outside actions will not take effect (e.g. in the view component).
The reason is that the mutation logic would be hard to trace and impossible to test as there might be unpredictable changes made to the view components as a result of mutations outside actions.
// store.js
export default {
inited: false,
setInited() {
this.inited = true;
}
}
// view.js
const todos = useStore('todos');
useEffect(() => {
// bad
todos.inited = true;
// good
todos.setInited();
});
By design, icestore
will trigger the rerender of all the view components subscribed to the store (by using useStore) once the state of the store has changed.
This means that putting more state in one store may cause more view components to rerender, affecting the overall performance of the application. As such, it is advised to categorize your state and put them in individual stores to improve performance.
From the engineering perspective, the global store should only be used to store states that are shared across multiple pages or components.
Putting local state in the global store will break the component's encapsulation, affecting its reusability. Using the todo app as an example, if the app only has one page, the state of the todo app is preferred to be stored as a local state in the view component rather than in the global store.
✔ | ✔ | ✔ | 9+ ✔ | ✔ | ✔ | ✔ |
Feel free to report any questions as an issue, we'd love to have your helping hand on icestore
.
If you're interested in icestore
, see CONTRIBUTING.md for more information to learn how to get started.