├── .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 | --------------------------------------------------------------------------------