├── .babelrc
├── .gitignore
├── .npmignore
├── CHANGELOG.md
├── README.md
├── package.json
└── src
└── autoaction.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "stage": 0
3 | }
4 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | transpiled
3 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # v0.0.9
2 | - Fix issue with batching without expliticly stating `key` parameter of actions
3 |
4 | # v0.0.8
5 | - Fix issue with outdated props from parent @connect
6 |
7 | # v0.0.7
8 | - Fix issue calling action with array of args
9 |
10 | # v0.0.6
11 | - New API for calling actions with changed state/params props that aren't
12 | arguments
13 | - Allow single arbitrary types as arguments to actions
14 |
15 | # v0.0.5
16 |
17 | - Fix bug when deleting items from a queue rendering calls as undefined
18 | - Add better documentation to readme
19 |
20 | # v0.0.4
21 |
22 | - Prevent stack overflows when listening to props with undefined arguments
23 |
24 | # v0.0.3
25 |
26 | - Call actions using `.apply` if we have an array of arguments
27 |
28 | # v0.0.2
29 |
30 | - Allow all props to call actions automatically. This lets us call actions
31 | from state (eg. router params), then automatically request data based on the
32 | initial action call.
33 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## AutoAction automatically calls redux actions on mount and prop changes
2 |
3 | ----
4 |
5 | **For data loading this is deprecated in favour of: https://github.com/tonyhb/tectonic**
6 |
7 | ----
8 |
9 | Automatically call redux actions from state.
10 |
11 | ### Setup:
12 |
13 | 1. Put `@autoaction` **beneath** `@connect` so it receives new props from Redux
14 | 2. Pass an object to the `@autoaction` decorator where:
15 | i. array keys are action names
16 | ii. array values are functions that accept params and state, and return array
17 | arguments
18 |
19 | ### How it works
20 |
21 | 1. autoaction accepts a map of action-names to a function which returns action
22 | arguments.
23 | 2. if any arguments resolve to undefined we **don't call that action**. This
24 | allows actions to update redux state, which then triggers other actions
25 | 3. if actions are called multiple times with the same arguments **dedupe and
26 | only call these once**. This allows child components in a tree to request
27 | data that any parents request with ony one request to thee API.
28 |
29 | Examples:
30 |
31 | ```js
32 | // Single argument:
33 | //
34 | // Automatically call getPost with state.router.params.slug, ie:
35 | // getPost(state.router.params.slug)
36 | @autoaction({
37 | getPost: (params, state) => state.router.params.slug
38 | }, postActions)
39 |
40 | // Multiple arguments:
41 | // getPost(params.id, state.router.params.slug)
42 | @autoaction({
43 | getPost: (params, state) => [params.id, state.router.params.slug]
44 | }, postActions)
45 |
46 | // Multiple arguments as object:
47 | // getPost({ id: params.id, slug: state.router.params.slug })
48 | @autoaction({
49 | getPost: (params, state) => {
50 | return: {
51 | id: params.id,
52 | slug: state.router.params.slug
53 | };
54 | }
55 | }, postActions)
56 |
57 | // Call an action each time a state/prop value changes but **isn't an action
58 | // argument**
59 | @autoaction({
60 | // postActions.resetUI will be called with 'post' as the argument each time
61 | // the 'key' updates (ie. state.router.params.slug changes)
62 | resetUI: {
63 | args: 'post',
64 | key: (params, state) => state.router.params.slug
65 | },
66 | }, postActions)
67 | ```
68 |
69 | **And exactly how?**
70 |
71 | We connect to redux state directly and listen to store changes. We enqueue
72 | action calls in `componentWillMount` for all components and dispatch them in
73 | `componentDidMount`. This allows us to dedupe any action calls from children,
74 | allowing all components to request the same actions if need be.
75 |
76 | When we receive new props we enqueue actions and dispatch immediately. To
77 | prevent stack overflows we delete actions from the queue before dispatching.
78 |
79 | ### API
80 |
81 |
82 |
83 | ### Basic example
84 |
85 | Action:
86 |
87 | ```js
88 | // Note that this function accepts an object and immediately destructures into
89 | // arguments. It is called via getPostBySlug({ slug: 'some-post' });
90 | export function getPostBySlug({ slug }) {
91 | return {
92 | type: "GET_POST",
93 | meta: {
94 | promise: Axios.get(`/api/posts/${slug}`)
95 | }
96 | };
97 | }
98 | ```
99 |
100 | Component:
101 |
102 | ```js
103 | import autoaction from 'autoaction';
104 | import * as postActions from 'actions/posts';
105 | import { createStructuredSelector } from 'reselect';
106 |
107 | const mapState = createStructuredSelector({
108 | post: (state) => state.post,
109 | comments: (state) => state.comments[state.post]
110 | });
111 |
112 | // In this example, getPostBySlug will be called from redux-router state
113 | // immediately. `params.post.id` returns undefined and so
114 | // `getCommentsbyPostId` won't be called immediately.
115 | // When getPostBySlug resolves the component will receive post props and will
116 | // call getCommentsByPostID automatically.
117 | @connect(mapState)
118 | @autoaction({
119 | getPostBySlug: (params, state) => { return { slug: state.router.params.slug }; }
120 | getCommentsByPostID: (params, state) => params.post.id
121 | }, postActions)
122 | class BlogPost extends Component {
123 | static propTypes = {
124 | post: PropTypes.object
125 | }
126 |
127 | render() {
128 | return (
129 |
postActions.getPostBySlug was automatically called with the slug
130 | router parameter!
131 | );
132 | }
133 | }
134 | ```
135 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "autoaction",
3 | "version": "0.0.9",
4 | "description": "Declarative data loading for redux + react",
5 | "main": "transpiled/autoaction.js",
6 | "scripts": {
7 | "prepublish": "$(npm bin)/babel -d transpiled src",
8 | "test": "echo \"Error: no test specified\" && exit 1"
9 | },
10 | "repository": {
11 | "type": "git",
12 | "url": "git+ssh://git@github.com/tonyhb/autoaction.git"
13 | },
14 | "keywords": [
15 | "autoaction",
16 | "react",
17 | "redux",
18 | "data",
19 | "loading"
20 | ],
21 | "author": "Tony Holdstock-Brown",
22 | "license": "MIT",
23 | "bugs": {
24 | "url": "https://github.com/tonyhb/autoaction/issues"
25 | },
26 | "homepage": "https://github.com/tonyhb/autoaction#readme",
27 | "peerDependencies": {
28 | "react": "^0.14.3",
29 | "react-redux": "^4.0.0"
30 | },
31 | "devDependencies": {
32 | "babel": "^5.8.34"
33 | },
34 | "dependencies": {
35 | "prop-types": "^15.7.2"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/autoaction.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | // import storeShape from 'react-redux/lib/utils/storeShape';
4 | import shallowEqual from 'react-redux/lib/utils/shallowEqual';
5 | import React from 'react';
6 | import deepEqual from 'deep-equal';
7 | import { bindActionCreators } from 'redux';
8 | import PropTypes from 'prop-types';
9 |
10 | const BatchActions = {
11 | isDispatching: false,
12 |
13 | // Needs:
14 | // - action name
15 | // - arguments
16 | // - function wrapped with dispatcher
17 |
18 | // object in the format of:
19 | // {
20 | // actionName: [
21 | // {func, args, key},
22 | // {func, args, key},
23 | // ],
24 | // }
25 | queue: {
26 | },
27 |
28 | called: {},
29 |
30 | // tryDispatch iterates through all queued actions and dispatches actions
31 | // with unique arguments.
32 | //
33 | // Each dispatch changes store state; our wrapper component listens to store
34 | // changes and queues/dispatches more actions. This means that we need to
35 | // remove actions from our queue just before they're dispatched to prevent
36 | // stack overflows.
37 | tryDispatch() {
38 | Object.keys(this.queue).forEach( actionName => {
39 | let calls = this.queue[actionName] || [];
40 |
41 | // Iterate through all of this action's batched calls and dedupe
42 | // if arguments are the same
43 | calls = calls.reduce((uniq, call, idx) => {
44 | // if the args and key arent the same this is a new unique call
45 | const isUnique = uniq.every(prev => {
46 | // Only test keys if the current call has a key and it doesn't match
47 | // the previous key.
48 | const isKeyMatch = (call.key !== null && prev.key === call.key);
49 | const isArgMatch = deepEqual(prev.args, call.args);
50 |
51 | // If both the action args and keys match this is non-unique, so
52 | // return false.
53 | return !(isKeyMatch === true && isArgMatch === true);
54 | });
55 |
56 | if (isUnique) {
57 | uniq.push(call);
58 | }
59 |
60 | return uniq;
61 | }, []);
62 |
63 | delete this.queue[actionName];
64 |
65 | // call each deduped action and pass in the required args
66 | calls.forEach((call, idx) => {
67 | if (Array.isArray(call.args)) {
68 | return call.func.apply(null, call.args);
69 | }
70 | // this is either an object or single argument; call as expected
71 | return call.func(call.args);
72 | });
73 | });
74 |
75 | this.queue = {};
76 | },
77 |
78 | enqueue(actionName, func, args, key) {
79 | let actions = this.queue[actionName] || [];
80 | actions.push({
81 | args,
82 | func,
83 | key
84 | });
85 | this.queue[actionName] = actions;
86 | }
87 | };
88 |
89 | /**
90 | * autoaction is an ES7 decorator which wraps a component with declarative
91 | * action calls.
92 | *
93 | * The actions map must be in the format of { actionName: stateFunc }
94 | *
95 | * Example usage:
96 | *
97 | * @autoaction({
98 | * getBlogPost: (state) => { return { org: state.params.router.slug }; }
99 | * })
100 | * @connect(mapState)
101 | * class Post extends React.Component {
102 | *
103 | * static propTypes = {
104 | * post: React.PropTypes.object.isRequired
105 | * }
106 | *
107 | * render() {
108 | * ...
109 | * }
110 | *
111 | * }
112 | *
113 | */
114 | export default function autoaction(autoActions = {}, actionCreators = {}) {
115 | // Overall goal:
116 | // 1. connect to the redux store
117 | // 2. subscribe to data changes
118 | // 3. compute arguments for each action call
119 | // 4. if any arguments are different call that action bound to the dispatcher
120 |
121 | // We refer to this many times throughout this function
122 | const actionNames = Object.keys(autoActions);
123 |
124 | // If we're calling actions which have no functions to prepare arguments we
125 | // don't need to subscribe to store changes, as there is nothing from the
126 | // store that we need to process.
127 | const shouldSubscribe = actionNames.length > 0 &&
128 | actionNames.some(k => typeof autoActions[k] === 'function');
129 |
130 | // Given a redux store and a list of actions to state maps, compute all
131 | // arguments for each action using autoActions passed into the decorator and
132 | // return a map contianing the action args and any keys for invalidation.
133 | function computeAllActions(props, state) {
134 | return actionNames.reduce((computed, action) => {
135 | const data = autoActions[action];
136 |
137 | if (Array.isArray(data)) {
138 | computed[action] = {
139 | args: data,
140 | key: null
141 | };
142 | return computed;
143 | }
144 |
145 | // we may have an arg function or an object containing arg and key
146 | // functions.
147 | switch (typeof data) {
148 | case 'function':
149 | computed[action] = {
150 | args: data(props, state),
151 | key: null
152 | };
153 | break;
154 | case 'object':
155 | let args = data.args
156 | // If we're passed a function which calcs args based on props/state,
157 | // call it. Otherwise assume that data.args is a single type to be
158 | // used as the argument itsekf
159 | if (typeof data === 'function') {
160 | args = args(props, state);
161 | }
162 | computed[action] = {
163 | args,
164 | key: data.key(props, state)
165 | };
166 | break;
167 | default:
168 | // By default use this as the argument
169 | computed[action] = {
170 | args: data,
171 | key: null
172 | };
173 | }
174 | return computed;
175 | }, {});
176 | }
177 |
178 | // If any argument within any action call is undefined our arguments should be
179 | // considered invalid and this should return true.
180 | function areActionArgsInvalid(args) {
181 | // single argument actions
182 | if (args === undefined) {
183 | return true;
184 | }
185 | if (Array.isArray(args)) {
186 | return args.some(arg => arg === undefined);
187 | }
188 | if (typeof args === 'object') {
189 | return Object.keys(args).some(arg => args[arg] === undefined);
190 | }
191 | // TODO: throw an invariant here
192 | return false;
193 | }
194 |
195 | return (WrappedComponent) => {
196 |
197 | /**
198 | * Autoconect is a wrapper component that:
199 | * 1. Resolves action arguments via selectors from global state
200 | * 2. Automatically calls redux actions with the determined args
201 | *
202 | * This lets us declaratively call actions from any component, which in short:
203 | * 1. Allows us to declaratively load data
204 | * 2. Reduces boilerplate for loading data across componentWillMount and
205 | * componentWillReceiveProps
206 | */
207 | return class autoaction extends React.Component {
208 |
209 | static contextTypes = {
210 | store: PropTypes.any
211 | }
212 |
213 | constructor(props, context) {
214 | super(props, context);
215 |
216 | this.store = context.store;
217 | this.mappedActions = computeAllActions(props, this.store.getState());
218 | this.actionCreators = bindActionCreators(actionCreators, this.store.dispatch)
219 | }
220 |
221 | componentWillMount() {
222 | this.tryCreators();
223 | }
224 |
225 | componentDidMount() {
226 | this.trySubscribe();
227 | BatchActions.tryDispatch();
228 | }
229 |
230 | componentWillUnmount() {
231 | this.tryUnsubscribe();
232 | }
233 |
234 | trySubscribe() {
235 | if (shouldSubscribe && !this.unsubscribe) {
236 | this.unsubscribe = this.store.subscribe(::this.handleStoreChange);
237 | }
238 | }
239 |
240 | tryUnsubscribe() {
241 | if (typeof this.unsubscribe === 'function') {
242 | this.unsubscribe();
243 | this.unsubscribe = null;
244 | }
245 | }
246 |
247 | // When the Redux store recevies new state this is called immediately (via
248 | // trySubscribe).
249 | //
250 | // Each action called via autoaction can use props to determine arguments
251 | // for the action.
252 | //
253 | // Unfortunately, we're listening to the store directly. This means that
254 | // `handleStoreChange` may be called before any parent components receive
255 | // new props and pass new props to our autoaction component. That is
256 | // - the parent component hasn't yet received the store update event and
257 | // the passed props are out-of-sync with actual store state (they're
258 | // stale).
259 | //
260 | // By computing actions within a requestAnimationFrame window we can
261 | // guarantee that components have been repainted and any parent @connect
262 | // calls have received new props. This means that our props used as
263 | // arguments within autoconnect will always be in sync with store state.
264 | //
265 | // This is kinda complex. Trust us, this works.
266 | //
267 | // TODO: Write test case.
268 | handleStoreChange() {
269 | const handleChange = () => {
270 | const actions = computeAllActions(this.props, this.store.getState());
271 | if (deepEqual(actions, this.mappedActions)) {
272 | return;
273 | }
274 |
275 | this.tryCreators(actions);
276 | BatchActions.tryDispatch();
277 | };
278 |
279 | // See above comments for explanation on using RAF.
280 | if (window && window.requestAnimationFrame) {
281 | window.requestAnimationFrame(handleChange);
282 | } else {
283 | handleChange();
284 | }
285 | }
286 |
287 | // Iterate through all actions with their computed arguments and call them
288 | // if necessary.
289 | // We only call the action if all arguments !== undefined and:
290 | // - this is the first time calling tryCreators, or
291 | // - the arguments to the action have changed
292 | tryCreators(actions = this.mappedActions) {
293 | // If we're calling tryCreators with this.mappedActions we've never
294 | // called the actions before.
295 | const initialActions = (actions === this.mappedActions);
296 |
297 | Object.keys(actions).forEach(a => {
298 | let action = actions[a];
299 |
300 | if (areActionArgsInvalid(action.args)) {
301 | // TODO: LOG
302 | return;
303 | }
304 |
305 | if (initialActions || !deepEqual(action, this.mappedActions[a])) {
306 | this.mappedActions[a] = action;
307 | BatchActions.enqueue(a, this.actionCreators[a], action.args, action.key);
308 | }
309 | });
310 | }
311 |
312 | shouldComponentUpdate(nextProps, nextState) {
313 | return !shallowEqual(nextProps, this.props);
314 | }
315 |
316 | render() {
317 | return (
318 |
319 | );
320 | }
321 | }
322 | };
323 |
324 | }
325 |
--------------------------------------------------------------------------------