Skip to content

Commit 8a2520e

Browse files
committed
Docs and auxiliary tooling for item actions and reducer
1 parent 631ead2 commit 8a2520e

File tree

8 files changed

+368
-2
lines changed

8 files changed

+368
-2
lines changed

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ external ReactJS projects developed by the Topcoder community.
1414
- [Configurations](#configurations)
1515
- [Components](#components)
1616
- [NodeJS Scripts](#nodejs-scripts)
17+
- [Redux Templates](#redux-templates)
1718
- [Utilities](#utilities)
1819
- [Development](#development)
1920
- [License](#license)
@@ -63,6 +64,9 @@ $ ./node_modules/.bin/topcoder-lib-setup
6364
- [**topcoder-lib-setup**](docs/topcoder-lib-setup-script.md) — Helps to
6465
install and upgrade **topcoder-react-utils** and other similar libraries.
6566

67+
### Redux Templates
68+
- [**item**](docs/redux-item.md) — An async piece of data in Redux store.
69+
6670
### Utilities
6771
- [**Client**](docs/client.md) — Client-side initialization code.
6872
- [**Config**](docs/config.md) — Isomorphic app config;

__tests__/__snapshots__/index.js.snap

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,11 @@ Object {
4343
},
4444
"redux": Object {
4545
"combineReducers": [Function],
46+
"connect": [Function],
4647
"createActions": [Function],
4748
"handleActions": [Function],
49+
"proxyAction": [Function],
50+
"proxyReducer": [Function],
4851
"resolveAction": [Function],
4952
"resolveReducers": [Function],
5053
"storeFactory": [Function],
@@ -198,8 +201,11 @@ Object {
198201
},
199202
"redux": Object {
200203
"combineReducers": [Function],
204+
"connect": [Function],
201205
"createActions": [Function],
202206
"handleActions": [Function],
207+
"proxyAction": [Function],
208+
"proxyReducer": [Function],
203209
"resolveAction": [Function],
204210
"resolveReducers": [Function],
205211
"storeFactory": [Function],
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Jest Snapshot v1, https://goo.gl/fbAQLP
2+
3+
exports[`Module exports 1`] = `
4+
Object {
5+
"combineReducers": [Function],
6+
"connect": [Function],
7+
"createActions": [Function],
8+
"handleActions": [Function],
9+
"proxyAction": [Function],
10+
"proxyReducer": [Function],
11+
"resolveAction": [Function],
12+
"resolveReducers": [Function],
13+
"storeFactory": [Function],
14+
}
15+
`;

__tests__/shared/utils/redux.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import _ from 'lodash';
2+
import * as redux from 'utils/redux';
3+
import { createAction } from 'redux-actions';
4+
5+
const DUMMY_ACTION = {
6+
type: 'DUMMY/ACTION',
7+
error: true,
8+
payload: 'DUMMY_PAYLOAD',
9+
};
10+
11+
const sampleActionCreator = createAction('SAMPLE/ACTION', (...args) => args);
12+
13+
test('Module exports', () => expect(redux).toMatchSnapshot());
14+
15+
test('proxyAction: payload creator reconstruction', () => {
16+
const proxy = redux.proxyAction(sampleActionCreator);
17+
expect(proxy('ARG1', 'ARG2')).toEqual(['ARG1', 'ARG2']);
18+
});
19+
20+
test('proxyAction: action mapping', () => {
21+
expect(redux.proxyAction(sampleActionCreator, _.clone(DUMMY_ACTION)))
22+
.toEqual({ ...DUMMY_ACTION, type: 'SAMPLE/ACTION' });
23+
});
24+
25+
test('proxyReducer', () => {
26+
const mockReducer = jest.fn();
27+
const proxyReducer = redux.proxyReducer(mockReducer, sampleActionCreator);
28+
proxyReducer('DUMMY_STATE', _.clone(DUMMY_ACTION));
29+
expect(mockReducer).toHaveBeenCalledWith('DUMMY_STATE', {
30+
...DUMMY_ACTION,
31+
type: 'SAMPLE/ACTION',
32+
});
33+
});

docs/redux-item.md

Lines changed: 274 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,274 @@
1+
# Item (Redux Template)
2+
3+
The standard way to handle an async piece of data in Redux store.
4+
5+
**Why?** — It is a common need to keep in Redux store a piece of data that
6+
is asyncroneously fetched from some API, or generated some other way, and should
7+
be updated periodically. **item** actions and reducer provided by our library
8+
implement a good way to handle this.
9+
10+
### Content
11+
- [Overview](#overview)
12+
- [Tutorial](#tutorial)
13+
14+
### Overview
15+
16+
Say you need to load (generate) and keep in Redux store some piece of `DATA`,
17+
which can be of any JS type (Boolean, Number, String, Object, etc.). To handle
18+
it efficiently you want:
19+
20+
- Record whether `DATA` are being loaded currently, and being able to cancel
21+
pending requests to load `DATA`, if necessary;
22+
23+
- Keep track of the last `DATA` update time, to refresh them when necessary,
24+
while avoiding to reload them more than needed;
25+
26+
- Keep track whether these data are used, to be able to drop them out of the
27+
store, when they are of no use anymore.
28+
29+
Our solution for this problem comes in two pieces:
30+
31+
- [**item reducer**](auto/reducers.item.md) manages a segment of Redux store
32+
that keeps your `DATA` in the following way:
33+
```js
34+
{
35+
data: DATA,
36+
loadingOperationId,
37+
numRefs,
38+
timestamp,
39+
}
40+
```
41+
In addition to the actual `DATA`, kept under the **data** field, the segment
42+
stores:
43+
- **loadingOperationId** — `null` when data are not being loaded,
44+
otherwises a unique ID of the loading operation. During an ongoing data
45+
loading operation you can change this ID, to silently ignore the result
46+
of the pending operation; thus cancelling it, or overriding with a new
47+
data request.
48+
- **numRefs** — Allows you to count, if you wish, refences to `DATA`
49+
from different objects in your code; to support a simple garbage collection
50+
mechanics.
51+
- **timestamp** — Timestamp [ms] of the latest update of `DATA`. Helps
52+
to reload stale data only when they are really outdated.
53+
54+
- [**item actions**](auto/actions.item.md) provide the control interface for
55+
the **item** reducer:
56+
- Allow to (re-)load data into the store segment;
57+
- Update reference counter;
58+
- Remove stale data with no references to them.
59+
60+
### Example
61+
62+
Say, you want to add `myData` segment to your Redux store, using **item**
63+
actions / reducers. You want multiple ReactJS containers rely on this segment,
64+
and you want to properly clean-up stale data when no container is using them.
65+
66+
For `myData` actions module you should do:
67+
```js
68+
// src/shared/actions/myData.js
69+
70+
import { actions, redux } from 'topcoder-react-utils';
71+
72+
const itemActions = actions.item;
73+
74+
// This is the only payload creator you have to customize, to implement
75+
// the actual loading of your data. Here `...args` stay for any arguments
76+
// you need to get your data.
77+
async function getDone(operationId, ...args) {
78+
// Here is your async operation(s) that produces the data you need.
79+
const data = await ...;
80+
// Here you proxy the result to the standard item's action:
81+
return redux.proxyAction(itemActions.loadItemDone)(operationId, data);
82+
}
83+
84+
export default redux.createActions({
85+
MY_DATA: {
86+
CLEAN_UP: redux.proxyAction(itemActions.dropData),
87+
GET_INIT: redux.proxyAction(itemActions.loadItemInit),
88+
GET_DONE: getDone,
89+
UPDATE_REFERENCE_COUNTER:
90+
redux.proxyAction(itemActions.updateReferenceCounter),
91+
},
92+
});
93+
```
94+
95+
Now, you create `myData` reducer the following way (the layout of module follows
96+
our standard practice):
97+
```js
98+
// src/shared/reducers/myData.js
99+
100+
import actions from 'actions/myData';
101+
import { actions as truActions, reducers, redux } from 'topcoder-react-utils';
102+
103+
const itemActions = truActions.item;
104+
const itemReducer = reducers.item;
105+
106+
/* Custom reducer function to handle possible errors in a custom way. */
107+
function onGetDone(state, action) {
108+
if (action.error) {
109+
// SOME ERROR HANDLING HERE.
110+
return state;
111+
}
112+
const a = redux.proxyAction(itemActions.loadDataDone, action);
113+
return itemReducer(state, a);
114+
// Note, the code above is equivalent to the following one, but the former
115+
// one is a bit more efficient under the hood:
116+
// ```
117+
// const r = redux.proxyReducer(itemReducer, itemActions.loadDataDone);
118+
// return r(state, action);
119+
// ```
120+
}
121+
122+
/**
123+
* Creates a new reducer.
124+
* @param {Object} intialState Optional. Initial state.
125+
* @return {Reducer}
126+
*/
127+
function create(initialState = itemReducer(undefined, '@@INIT')) {
128+
const a = actions.myData;
129+
return redux.handleActions({
130+
[a.cleanUp]: redux.proxyReducer(itemReducer, itemActions.dropData),
131+
[a.getInit]: redux.proxyReducer(itemReducer, itemActions.loadDataInit),
132+
[a.getDone]: onGetDone,
133+
[a.updateReferenceCounter]:
134+
redux.proxyReducer(itemReducer, itemActions.updateReferenceCounter),
135+
});
136+
}
137+
138+
/**
139+
* Reducer factory, that may use some `options` to generate initial state
140+
* more appropriate for the user request, thus taking care about a better
141+
* server-side rendering.
142+
* @param {Object} options Optional. SSR options.
143+
* @return {Promise} Resolves to the reducer.
144+
*/
145+
export async function factory(options) {
146+
if (options) {
147+
let initialState
148+
// PREPARES A CUSTOMIZED initialState HERE.
149+
return create(initialState)
150+
}
151+
return create();
152+
}
153+
154+
/* Reducer with the default initial state. */
155+
export default create();
156+
```
157+
158+
Now, provided that you have embed this reducer into your root reducer in
159+
the regular way, you can follow the following pattern for a container that
160+
relies on these data:
161+
```js
162+
// src/shared/containers/example.js
163+
164+
import actions from 'actions/myData';
165+
import Example from 'components/Example';
166+
import PT from 'prop-types';
167+
import React from 'react';
168+
import shortId from 'shortid';
169+
import { redux } from 'topcoder-react-utils';
170+
171+
const MAX_AGE = 5 * 60 * 1000; // 5 min.
172+
const RELOAD_AGE = 1 * 60 * 1000; // 1 min.
173+
174+
class ExampleContainer extends React.Component {
175+
componentDidMount() {
176+
const {
177+
dataTimestamp,
178+
load,
179+
loadingData,
180+
updateReferenceCounter,
181+
} = this.props;
182+
const now = Date.now();
183+
184+
/* If data are old enough and are not being loaded currently,
185+
* we trigger data reload. */
186+
if (!loadingData && dataTimestamp < now - RELOAD_AGE) {
187+
load(/* In most cases you'll pass in you own arguments, specifying
188+
* what data should be loaded, etc. */);
189+
}
190+
191+
/* In all cases we update data reference counter, to keep track that
192+
* a newly updated component relies on the data now. */
193+
updateReferenceCounter(1);
194+
}
195+
196+
componentDidUpdate() {
197+
/* In case you need update data in response to a prop change,
198+
* you can do it here. You should compare current props to the
199+
* new props to decide, whether data refresh is necessary, and
200+
* then use load(..) prop to trigger the reload. No need to update
201+
* the data reference counter. */
202+
}
203+
204+
componentWillUnmount() {
205+
const = { cleanUp, updateReferenceCounter } = this.props;
206+
/* Mark that this container will not use the data anymore. */
207+
updateReferenceCounter(-1);
208+
/* This call will drop data out of the state if it was the last
209+
* container to rely on them. */
210+
cleanUp();
211+
/* In case you want keep data in Redux for some more time (to be able
212+
* to use them in a newly created container), you can do: */
213+
const olderThan = Date.now() - MAXAGE;
214+
cleanUp(olderThan);
215+
/* Notice that in this case, if data are not removed by the last
216+
* container that uses them, they'll stay in the state until another
217+
* container is created and tries to clean them up on its destruction.
218+
* In case it is not good for performance of you app, you can implement
219+
* and fire here an async cleanUp operation, that will delay cleanUp to
220+
* some point in future. */
221+
}
222+
223+
render() {
224+
/* In most cases, these are the only data you want to forward into
225+
* the actual rendering function. */
226+
const { data, loadingData } = this.props;
227+
228+
/* It is up to you, what you want to do when `data` are old,
229+
* and the new data loading operation is already going on. However,
230+
* for a better user experience, in most of cases, it makes sense to
231+
* show old data anyway, and probably even do not show the reloading
232+
* indicator, thus making a silent data update. Depending on the nature
233+
* of you data and UI you might prefer different strategies. */
234+
return <Example data={data} loading={loadingData} />;
235+
}
236+
}
237+
238+
ExampleContainer.defaultProps = {
239+
data: null,
240+
};
241+
242+
ExampleContainer.propTypes = {
243+
cleanUp: PT.func.isRequired,
244+
data: PT.any, /* Should be the actual type of data you expect. */
245+
dataTimestamp: PT.number.isRequired,
246+
load: PT.func.isRequired,
247+
loadingData: PT.bool.isRequired,
248+
updateReferenceCounter: PT.func.isRequired,
249+
};
250+
251+
function mapStateToProps(state) {
252+
const s = state.myData;
253+
return {
254+
data: s.data,
255+
dataTimestamp: s.timestamp,
256+
loadingData: Boolean(s.loadingOperationId),
257+
};
258+
}
259+
260+
function mapDispatchToProps(dispatch) {
261+
const a = actions.myData;
262+
return {
263+
cleanUp: () => dispatch(a.cleanUp()),
264+
load: (...args) => {
265+
const operationId = shortId();
266+
dispatch(a.loadItemInit(operationId));
267+
dispatch(a.loadItemDone(operationId, ...args));
268+
},
269+
updateReferenceCounter: shift => dispatch(a.updateReferenceCounter(shift)),
270+
};
271+
}
272+
273+
export default redux.connect(mapStateToProps, mapDispatchToProps)(Example);
274+
```

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,5 +116,5 @@
116116
"lint:scss": "./node_modules/.bin/stylelint **/*.scss --syntax scss",
117117
"test": "npm run lint && npm run jest"
118118
},
119-
"version": "0.4.4"
119+
"version": "0.4.5"
120120
}

0 commit comments

Comments
 (0)