├── .eslintrc.js
├── .gitignore
├── .npmrc
├── .travis.yml
├── LICENSE
├── README.md
├── demo
├── App.js
├── actions.js
├── api.js
├── entries
│ └── index.js
├── index.css
├── reducers.js
├── selectors.js
└── store.js
├── package.json
├── settings.js
├── src
├── __tests__
│ ├── entities.test.js
│ ├── queries.test.js
│ ├── selectors.test.js
│ └── thunkCreatorFor.test.js
├── entities.ts
├── index.ts
├── interface.ts
├── queries.ts
└── selectors.ts
├── tsconfig.json
├── tslint.json
├── types
├── san-update.d.ts
└── vfile-message.d.ts
└── yarn.lock
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | extends: './node_modules/reskript/config/eslint.js',
3 | };
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 |
8 | # Runtime data
9 | pids
10 | *.pid
11 | *.seed
12 | *.pid.lock
13 |
14 | # Directory for instrumented libs generated by jscoverage/JSCover
15 | lib-cov
16 |
17 | # Coverage directory used by tools like istanbul
18 | coverage
19 |
20 | # nyc test coverage
21 | .nyc_output
22 |
23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
24 | .grunt
25 |
26 | # Bower dependency directory (https://bower.io/)
27 | bower_components
28 |
29 | # node-waf configuration
30 | .lock-wscript
31 |
32 | # Compiled binary addons (http://nodejs.org/api/addons.html)
33 | build/Release
34 |
35 | # Dependency directories
36 | node_modules/
37 | jspm_packages/
38 |
39 | # Typescript v1 declaration files
40 | typings/
41 |
42 | # Optional npm cache directory
43 | .npm
44 |
45 | # Optional eslint cache
46 | .eslintcache
47 |
48 | # Optional REPL history
49 | .node_repl_history
50 |
51 | # Output of 'npm pack'
52 | *.tgz
53 |
54 | # Yarn Integrity file
55 | .yarn-integrity
56 |
57 | # dotenv environment variables file
58 | .env
59 |
60 | /cjs
61 | /es
62 | .DS_Store
63 |
--------------------------------------------------------------------------------
/.npmrc:
--------------------------------------------------------------------------------
1 | registry = https://registry.npm.taobao.org
2 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js:
3 | - "node"
4 | script: yarn run ci
5 | after_script:
6 | - yarn run report-cov
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2017 Baidu EFE team
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # standard-redux-shape
2 |
3 | `standard-redux-shape` is a tiny utility library to help you manage an optimized redux store shape.
4 |
5 | ## Background
6 |
7 | In many applications, developers tends to consider redux store as a simple data structure represnting their views, for example, in a simple todo application, the store may look like:
8 |
9 | ```javascript
10 | store: {
11 | todoList: {
12 | pageNumber: 0,
13 | pageSize: 10,
14 | filters: {
15 | keyword: 'buy',
16 | startDate: '2017-01-01',
17 | endDate: '2017-06-30',
18 | status: 'pending'
19 | },
20 | todos: [/* todo objects */]
21 | }
22 | }
23 | ```
24 |
25 | This is OK, by listening events and dispatching actions to update `filters` or `todos`, with a `mapStateToProps` function you can receive an array of todo objects and display them on screen:
26 |
27 | ```javascript
28 | const TodoList = ({todos}) => (
29 |
30 | {todos.map(todo =>
...
)}
31 |
32 | );
33 |
34 | const mapStateToProps = state => state.todoList.todos;
35 |
36 | export default connect(mapStateToProps)(TodoList);
37 | ```
38 |
39 | This is how many application implements their react-redux application, however it can still introduce issues when `filters` are changed, since `TodoList` only knows `todoList.todos` property, it can receive a wrong list before actions updating `filters` are dispatched and new todo objects are loaded from remote.
40 |
41 | In order to prevent wrong data to be displayed, a simple solution is to also receive all current parameters and compare them with those corresponding to the list, so we have to change our store to contain the current and corresponding parameters:
42 |
43 | ```javascript
44 | store: {
45 | todoList: {
46 | currentParams: {
47 | pageNumber: 0,
48 | pageSize: 10,
49 | filters: {
50 | keyword: 'buy',
51 | startDate: '2017-01-01',
52 | endDate: '2017-06-30',
53 | status: 'pending'
54 | }
55 | },
56 | response: {
57 | params: {
58 | pageNumber: 0,
59 | pageSize: 10,
60 | filters: {
61 | keyword: 'buy',
62 | startDate: '2017-01-01',
63 | endDate: '2017-06-30',
64 | status: 'pending'
65 | }
66 | },
67 | todos: [/* todo obejcts */]
68 | }
69 | }
70 | }
71 | ```
72 |
73 | Then we receive all these properties and compare them to ensure the `todos` property is udpate to date:
74 |
75 | ```javascript
76 | const mapStateToProps = state => {
77 | const {currentParams, response: {params, todos}} = state;
78 |
79 | if (shallowEquals(currentParams, params)) {
80 | return todos;
81 | }
82 |
83 | return null; // To indicate current response is not returned from remote
84 | };
85 | ```
86 |
87 | This solves the wrong data issue "perfectly" with some shortcomings:
88 |
89 | - We have to manage a more complex store structure case by case.
90 | - The `todos` list is overridden each time `pageNumber`, `pageSize` or `filters` are changed, instant undo is missing on same view.
91 |
92 | Having our purpose to solve above issues along with receiving the correct and update to date data each time, the final approach comes with a standardized store shape with three layers.
93 |
94 | ## Standard shape of store
95 |
96 | The `standard-redux-shape` recommends a standard store shape which combines with three concepts:
97 |
98 | 1. The `entities` is for [store normalization](http://redux.js.org/docs/recipes/reducers/NormalizingStateShape.html), `standard-redux-shape` helps you to create and manage entity tables.
99 | 2. The `queries` is an area to store and retrieve query responses with certain params, consider a query response as a special kind of entity, the query params is the key of such entity, `standard-redux-shape` provides functions to serialize params and store responses.
100 | 3. The rest of the store should be a minimum state containing current params, `standard-redux-shape` also provides function to retrive responses with params.
101 |
102 | ### Store normalization
103 |
104 | Considering that in most applications entities come from the remote server, `standard-redux-shape` decided to simply binding remote API calls to entity tables, this is archived with 2 functions.
105 |
106 | First or all, the `createTableUpdater` creates a function usually called `withTableUpdate` (of type `TableUpdater`), this is a higher order function which has the signature of:
107 |
108 | ```
109 | type StoreResolver = () => Store;
110 | type TableUpdaterCreator = ({StoreResolver} resolveStore) => TableUpdater;
111 | type EntitySelector = ({Object} response) => Obejct;
112 | type TableUpdater = ({string} tableName, {EntitySelector} selectEntites) => APIWrapper;
113 | type APIWrapper = ({Function} api) => Function;
114 | ```
115 |
116 | Next, the `createTableUpdateReducer` is a function creating a reducer to update the table entities, simply use `combineReducers` to assign a property of store (`entities` recommended) to its return value so that `withTableUpdate` functions can work as expected.
117 |
118 | To have a real world example, suppose we have a API called `getTodos` returning a response as:
119 |
120 | ```javascript
121 | {
122 | pageNumber: 1,
123 | pageSize: 10,
124 | data: [/* todo objects */]
125 | }
126 | ```
127 |
128 | First we need to create our store, here we need `createTableUpdateReducer` as a sub reducer, and to create and export our `withTableUpdate` function:
129 |
130 | ```javascript
131 | // store.js
132 |
133 | import {createStore, combineReducers} from 'redux';
134 | import {createTableUpdateReducer} from 'standard-redux-shape';
135 | import reducers from 'reducers'
136 |
137 | export const store = createStore(
138 | combineReducers({...reducers, entities: createTableUpdateReducer()}),
139 | null
140 | );
141 |
142 | // fetch.js
143 | export const withTableUpdate = createTableUpdater(() => import('store'));
144 | ```
145 |
146 |
147 | Then we can wrap our `getTodos` API function:
148 |
149 | ```javascript
150 | // api.js
151 |
152 | import {withTableUpdate} from 'fetch';
153 |
154 | const getTodos = params => {
155 | // ...
156 | };
157 |
158 | const selectTodos = ({data}) => data.reduce((todos, todo) => ({...todos, [todo.id]: todo}), {});
159 |
160 | export const fetchTodos = withTableUpdate(selectTodos, 'todosByID')(getTodos);
161 | ```
162 |
163 | Every thing is done, every time when you call `fetchTodos`, a todo list is fetched from remote and the `entities.todosByID` table is automatically updated.
164 |
165 | In order to enjoy the benefits of store normalization, it is highly recommended to map todos array to an array of their id in action creator:
166 |
167 | ```javascript
168 | // actions.js
169 |
170 | import {fetchTodos} from 'api';
171 |
172 | export const requestTodos = params => async dispatch => {
173 | const response = fetchTodos(params);
174 | const todos = response.data.map(todo => todo.id);
175 |
176 | dispatch({type: 'LOAD_TODOS', todos: todos});
177 | };
178 | ```
179 |
180 | `withTableUpdate` can also be nested to update multiple entity tables on one API call:
181 |
182 | ```javascript
183 | const selectTodos = ({todos}) => todos.reduce((todos, todo) => ({...todos, [todo.id]: todo}), {});
184 | const withTodosUpdate = withTableUpdate(selectTodos, 'todosByID');
185 |
186 | const selectMemos = ({memos}) => memos.reduce((memos, memo) => ({...memos, [memo.id]: memo}), {});
187 | const withMemosUpdate = withTableUpdate(selectMemos, 'memosByID');
188 |
189 | export const initializeApplication = selectMemos(selectTodos(loadIntiialData));
190 | ```
191 |
192 | Also you can simple make `selectEntities` function return multiple table patch:
193 |
194 | ```javascript
195 | const selectEntities = ({todos, memos}) => {
196 | return {
197 | todosByID: todos.reduce((todos, todo) => ({...todos, [todo.id]: todo}), {}),
198 | memosByID: memos.reduce((memos, memo) => ({...memos, [memo.id]: memo}), {})
199 | };
200 | };
201 |
202 | export const initializeApplication = selectEntities(loadIntiialData);
203 | ```
204 |
205 | These code are equivelent.
206 |
207 | ## Query storage
208 |
209 | `standard-redux-shape` provides action creator and reducer helpers. In our standardized shape, any query consists of three stages:
210 |
211 | 1. The **fetch** stage is when a request is sent but the response is not returned, in this stage we usually recording all pending requests so that we can show a loading indicator or to decide which response is the most fresh one.
212 | 2. The **receive** stage is when response is returned, in this stage we can simply put response to store and display it on screen via `mapStateToProps` mappings.
213 | 3. The **accept** stage is a special stage when you refuses to immediately accept and display the response, it will be stored temporarily and can be accepted later.
214 |
215 | In order to have enough information of the stage of a query and its possible responses (either accepted and pending), we designed a standard shape of query:
216 |
217 | ```javascript
218 | query: {
219 | params: {any}, // The params corresponding to this query, can be any type
220 | pendingMutex: {number}, // A number indicating the count of pending requests
221 | response: {
222 | data: {any?}, // A possible object representing the latest success response
223 | error: {Object?}, // Possible error information for failed request
224 | },
225 | nextResponse: { // The latest unaccepted response
226 | data,
227 | error
228 | }
229 | }
230 | ```
231 |
232 | To update a query structure, we need 2 or 3 actions which matches the fetch, receive and accept stages, `standard-redux-shape` provides several strategies to deal with new responses:
233 |
234 | - `acceptLatest({string} fetchActionType, {string} receiveActionType)`: Always accept the latest arrived response, ealier responses will be overridden, this is the mose common case.
235 | - `keepEarliest({string} fetchActionType, {string} receiveActionType, {string} acceptActionType)`: Always use the first arrived response, later responses are discarded automatically, this can be used for some statistic jobs. The latest respones are keep in `nextResponse` so that you can accept it by dispatching `acceptActionType`.
236 | - `keepEarliestSuccess({string} fetchActionType, {string} receiveActionType)`: Quite the same as `keepEarliest` but override earilier error responses.
237 | - `acceptWhenNoPending({string} fetchActionType, {string} receiveActionType)`: Accept the latest arrived response if `pendingMutext` is `0`, this prevents frequent view update when multiple requests may on the fly.
238 |
239 | Aside the above, `standard-redux-shape` also provides functions for action creators to create action payloads which can be recognized by reducers, they are:
240 |
241 | - `{Object} createQueryPayload({any} params, {any} data)` to create an action payload on success.
242 | - `{Object} createQueryErrorPayload({any} params, {any} data)` to create an action payload on error.
243 |
244 | Moreover, `standard-redux-shape` provides selectors to retrieve wanted properties from a query:
245 |
246 | - `{Function} createQuerySelector({Function} selectQuery, {Function} selectParams)`: Create a selector to get a query from query set.
247 | - `{Function} createQueryResponseSelector({Function} selectQuery, {Function} selectParams)`: Create a selector to get the `response` property of a query.
248 | - `{Function} createQueryDataSelector({Function} selectQuery, {Function} selectParams)`: Create a selector to get the `response.data` property of q query.
249 | - `{Function} createQueryErrorSelector({Function} selectQuery, {Function} selectParams)`: Create a selector to get the `response.error` property of a query.
250 |
251 | By combining these utilities we can create a simple todo list application easily, first we define our action types, each query must have a fetch and a receive type:
252 |
253 | ```javascript
254 | // actions/type.js
255 |
256 | export const FETCH_TODOS = 'FETCH_TODOS';
257 | export const RECEIVE_TODOS = 'RECEIVE_TODOS';
258 | ```
259 |
260 | Then simply create an action creator which invokes the remote API and dispatches corresponding actions with correct payload:
261 |
262 | ```javascript
263 | // actions/index.js
264 |
265 | import {createQueryPayload, createQueryErrorPayload} from 'standard-redux-shape';
266 | import {fetchTodos} from 'api';
267 | import {FETCH_TODOS, RECEIVE_TODOS} from './type';
268 |
269 | export const requestTodos = params => async dispatch => {
270 | dispatch({type: FETCH_TODOS, payload: params}); // Payload of fetch action must be the params
271 |
272 | try {
273 | const response = await fetchTodos(params);
274 |
275 | dispatch({type: RECEIVE_TODOS, payload: createQueryPayload(params, response)});
276 | }
277 | catch (ex) {
278 | if (isRequestError(ex)) {
279 | dispatch({type: RECEIVE_TODOS, payload: createQueryErrorPayload(params, ex)});
280 | }
281 | else {
282 | throw ex;
283 | }
284 | }
285 | };
286 | ```
287 |
288 | The reducers can be super simple:
289 |
290 | ```javascript
291 | // reducers.js
292 |
293 | import {acceptWhenNoPending} from 'standard-redux-shape';
294 | import {combineReducers} from 'redux';
295 | import {FETCH_TODOS, RECEIVE_TODOS} from './type';
296 |
297 | const reducers = {
298 | todoList: acceptWhenNoPending(FETCH_TODOS, RECEIVE_TODOS)
299 | };
300 |
301 | export default combineReducers(reducers);
302 | ```
303 |
304 | In component we can get query state by selectors:
305 |
306 | ```javascript
307 | // TodoList.js
308 |
309 | import {createQuerySelector} from 'standard-redux-shape';
310 | import parseQuery from 'parse-query';
311 | import {withRouter} from 'react-router';
312 |
313 | const selectTodoList = createQuerySelector(
314 | state => state.todoListQuery,
315 | state => {
316 | const {pageNumber, startDate, endDate} = parseQuery(props.location.query);
317 | return {pageNumber, startDate, endDate};
318 | }
319 | );
320 |
321 | const TodoList = props => {
322 | const query = selectTodoList(props);
323 |
324 | if (query.pendingMutex) {
325 | return ;
326 | }
327 |
328 | if (query.response.error) {
329 | return
330 | };
331 |
332 | return (
333 |
334 | {query.response.data.map(todo =>
...
)}
335 |
336 | );
337 | };
338 |
339 |
340 | const mapStateToProps = state => {
341 | return {
342 | todoListQuery: state.todoList.queries // Suppose we combine reducers here
343 | }
344 | };
345 |
346 | export default withRouter(connect(mapStateToProps)(TodoList));
347 | ```
348 |
349 | ### Quick create thunk
350 |
351 | The `thunkCreatorFor` function helps you to quick create a simple [thunk function](https://github.com/gaearon/redux-thunk) which just dispatching 2 actions around an API call function:
352 |
353 | ```javascript
354 | {Function} thunkCreatorFor(
355 | {Function} api,
356 | {string} fetchActionType,
357 | {string} receiveActionType,
358 | {Object} options
359 | );
360 | ```
361 |
362 | More options can be passed via `options` parameter:
363 |
364 | - `{Function} computeParams({any} ...args)`: To compute the params for `api`.
365 | - `{boolean} once`: If set to `true`, `api` will not be invoked when there is already a response in store.
366 | - `{boolean} trustPending`: It set to `true`, thunk will immediately return if previous `api` call is still pending, in this case, the `pendingMutex` will always be `1` or `0`, it can simplify idempotent cases.
367 | - `{Function} selectQuerySet({any} state)`: When `once` is set to `true`, `selectQuerySet` must be provided to select the corresponding query set to determine whether response is already in store.
368 |
369 | ## Example
370 |
371 | You can run `npm start` to start a demo, the chart uses `keepEarliest` strategy and a friendly message is displayed to user waiting their action to accept the latest data.
372 |
373 | ## Change Log
374 |
375 | ### 1.0.0
376 |
377 | - `createTableUpdateReducer` now accepts a `customMerger` to customize entity table merging strategy.
378 | - **BREAKING** `withTableUpdate`'s parameters are reversed to `(selectEntities, tableName)`, now `tableName` is optional when `selectEntities` returns multiple entity table patch.
379 |
--------------------------------------------------------------------------------
/demo/App.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @file App
3 | * @author zhanglili
4 | */
5 |
6 | import {PureComponent} from 'react';
7 | import {connect} from 'react-redux';
8 | import {bindActionCreators} from 'redux';
9 | import {sortBy, get} from 'lodash';
10 | import {bind} from 'lodash-decorators';
11 | import withTimeout from 'react-timeout';
12 | import ECharts from 'echarts-for-react';
13 | import {Button, notification} from 'antd';
14 | import {fetchSummary, acceptSummary} from './actions';
15 | import {selectSummaryQuery} from './selectors';
16 | import 'antd/dist/antd.min.css';
17 |
18 | class App extends PureComponent {
19 |
20 | componentDidMount() {
21 | const {setInterval} = this.props;
22 | this.fetchSummary();
23 | setInterval(this.fetchSummary, 10 * 1000);
24 | }
25 |
26 | componentDidUpdate(prevProps) {
27 | const nextResponse = get(this.props, 'summaryQuery.nextResponse');
28 | const prevNextResponse = get(prevProps, 'summaryQuery.nextResponse');
29 |
30 | if (nextResponse && nextResponse !== prevNextResponse) {
31 | const confirmButton = ;
32 | const config = {
33 | message: 'Received new data',
34 | description: 'New data is received, click "Confirm" to refresh chart',
35 | btn: confirmButton,
36 | key: 'newSummary',
37 | };
38 | notification.open(config);
39 | }
40 | }
41 |
42 | @bind()
43 | acceptSummary() {
44 | this.props.acceptSummary();
45 | notification.close('newSummary');
46 | }
47 |
48 | @bind()
49 | fetchSummary() {
50 | this.props.fetchSummary();
51 | }
52 |
53 | render() {
54 | const {summaryQuery: {response}} = this.props;
55 |
56 | if (!response) {
57 | return null;
58 | }
59 |
60 | const seriesData = [
61 | {value: response.data.direct, name: '直接访问'},
62 | {value: response.data.email, name: '邮件营销'},
63 | {value: response.data.union, name: '联盟广告'},
64 | {value: response.data.video, name: '视频广告'},
65 | {value: response.data.search, name: '搜索引擎'},
66 | ];
67 |
68 | const options = {
69 | backgroundColor: '#2c343c',
70 | tooltip: {
71 | trigger: 'item',
72 | formatter: '{a} {b} : {c} ({d}%)',
73 | },
74 | visualMap: {
75 | show: false,
76 | min: 500,
77 | max: 5000,
78 | inRange: {
79 | colorLightness: [0, 1],
80 | },
81 | },
82 | series: [
83 | {
84 | name: 'PV Source',
85 | type: 'pie',
86 | radius: '55%',
87 | center: ['50%', '50%'],
88 | data: sortBy(seriesData, 'value'),
89 | roseType: 'radius',
90 | label: {
91 | normal: {
92 | textStyle: {
93 | color: 'rgba(255, 255, 255, 0.3)',
94 | },
95 | },
96 | },
97 | labelLine: {
98 | normal: {
99 | lineStyle: {
100 | color: 'rgba(255, 255, 255, 0.3)',
101 | },
102 | smooth: 0.2,
103 | length: 10,
104 | length2: 20,
105 | },
106 | },
107 | itemStyle: {
108 | normal: {
109 | color: '#c23531',
110 | shadowBlur: 200,
111 | shadowColor: 'rgba(0, 0, 0, 0.5)',
112 | },
113 | },
114 | animationType: 'scale',
115 | animationEasing: 'elasticOut',
116 | animationDelay() {
117 | return Math.random() * 200;
118 | },
119 | },
120 | ],
121 | };
122 |
123 | return (
124 |
125 |
126 |
PV Source
127 | Data refreshes every 10 seconds.
128 | [Fetch Now]
129 |
130 |
131 |