{"id":22201649,"url":"https://github.com/neoverse/spunky","last_synced_at":"2025-07-27T04:30:45.276Z","repository":{"id":57368103,"uuid":"125638537","full_name":"neoverse/spunky","owner":"neoverse","description":"Lifecycle management for react-redux","archived":false,"fork":false,"pushed_at":"2018-09-13T05:10:44.000Z","size":99,"stargazers_count":8,"open_issues_count":0,"forks_count":1,"subscribers_count":2,"default_branch":"master","last_synced_at":"2024-11-20T22:05:53.391Z","etag":null,"topics":["actions","components","higher-order-component","react","react-redux","reducers","redux","redux-saga","sagas"],"latest_commit_sha":null,"homepage":"","language":"JavaScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/neoverse.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2018-03-17T14:42:01.000Z","updated_at":"2023-03-29T02:07:37.000Z","dependencies_parsed_at":"2022-09-05T20:51:11.246Z","dependency_job_id":null,"html_url":"https://github.com/neoverse/spunky","commit_stats":null,"previous_names":[],"tags_count":1,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/neoverse%2Fspunky","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/neoverse%2Fspunky/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/neoverse%2Fspunky/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/neoverse%2Fspunky/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/neoverse","download_url":"https://codeload.github.com/neoverse/spunky/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":227574213,"owners_count":17788147,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["actions","components","higher-order-component","react","react-redux","reducers","redux","redux-saga","sagas"],"created_at":"2024-12-02T16:09:44.604Z","updated_at":"2024-12-02T16:09:45.766Z","avatar_url":"https://github.com/neoverse.png","language":"JavaScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# spunky\n\n`spunky` leverages the power of [redux-saga](https://redux-saga.js.org/) to take away the pain of\nwriting [redux](https://redux.js.org/) reducers, allowing you to focus on writing simple JavaScript\nfunctions for reading and writing your application's data.  `spunky` will automatically take care of\nstate management, providing insight about the progress, success, or failure of calling your\nfunction.  This progress along with data, errors, and more can in turn be exposed to your\n[React](https://reactjs.org/) components using a predefined set of\n[higher-order components](https://reactjs.org/docs/higher-order-components.html) (HOCs).\n\nBenefits of using `spunky`:\n\n* Eliminates the need to write a reducer for every redux action.\n* Automatic progress tracking with loading, loaded, and error states.\n* Cancel asynchronous actions before they finish.\n* Track action progress.\n* Simple HOC's for exposing data, errors, and progress (and more!) to your components.\n\nOne important aspect worth noting about this package is that it stores functions inside of redux\nstores.  Because functions cannot be serialized, this package currently *does not* work with\nisomorphic applications that attempt to preload data on the server in order to pass it to the\nclient.\n\n## Installation\n\nIf using npm:\n\n```\nnpm install spunky react redux redux-saga --save\n```\n\nIf using yarn:\n\n```\nyarn add spunky react redux redux-saga\n```\n\n## Setup\n\nFirst, you will need to combine `spunky`'s reducer in your top-level reducer.  If your app does not\nalready have a reducer, you will need to create one.\n\nThe `spunky` package assumes that you will store this under the \"spunky\" key.  If you prefer a\ndifferent key, note that you will need to specify that key as part of `options.prefix` when using\nthe HOCs provided by this package.\n\n```js\n// app/reducers.js\nimport { combineReducers } from \"redux\";\nimport { reducer as spunkyReducer } from \"spunky\";\n\nexport default combineReducers({\n  spunky: spunkyReducer\n  // other app reducers here\n});\n```\n\nNext, you will need to expose your reducer from above along with `spunky`'s saga to your redux store.\n\n```js\n// app/store.js\nimport { createStore, applyMiddleware } from \"redux\";\nimport createSagaMiddleware from \"redux-saga\";\nimport { saga } from \"spunky\";\nimport reducers from \"./reducers\";\n\nexport default function configureStore(initialState = {}) {\n  const sagaMiddleware = createSagaMiddleware();\n  const store = createStore(reducers, initialState, applyMiddleware(sagaMiddleware));\n\n  sagaMiddleware.run(saga);\n\n  return store;\n}\n```\n\nIn order to use promises (or `async`/`await` syntax) in your actions, you can apply the `redux-saga`\nmiddleware in conjunction with [`redux-thunk`](https://github.com/gaearon/redux-thunk).  e.g.:\n\n```js\nimport thunk from \"redux-thunk\";\n\nconst store = createStore(reducers, initialState, applyMiddleware(sagaMiddleware, thunk));\n```\n\n## Usage\n\nUsage of `spunky` can be broken down into three parts:\n\n1. **Define actions:** Actions can be used for reading or writing data, whether through an API,\n   local storage, or otherwise.  They can be synchronous or asynchronous (via promises or the ES6\n   `async` keyword).\n2. **Call actions:** Use actions to retrieve and return data to your application.\n3. **Expose data:** Use HOCs to expose data (or errors) to your components.\n\nBelow is an example of how these three steps work together to fetch \u0026 expose data to a custom\ncomponent, displaying a loading component while data is loading.\n\n```js\nimport React from \"react\";\nimport ReactDOM from \"react\";\nimport { compose } from \"recompose\";\nimport {\n  createActions,\n  withCall,\n  withData,\n  withProgressComponents,\n  progressValues\n} from \"spunky\";\n\nconst { LOADING, FAILED } = progressValues;\n\nconst PROFILES = [\n  { id: 1, name: \"Homer\", email: \"homer@example.com\" },\n  { id: 2, name: \"Lenny\", email: \"lenny@example.com\" },\n  { id: 2, name: \"Carl\", email: \"carl@example.com\" }\n];\n\nconst profileActions = createActions(\"profile\", ({ id }) =\u003e async () =\u003e {\n  const profile = PROFILES.find(profile =\u003e profile.id === id);\n\n  // fake internet latency so that we can see loading component while data loads\n  await delay(1000);\n\n  if (!profile) {\n    throw new Error(\"Profile not found.\");\n  }\n\n  return profile;\n});\n\nconst MyComponent = ({ name, email }) =\u003e (\n  \u003cul\u003e\n    \u003cli\u003eName: {name}\u003c/li\u003e\n    \u003cli\u003eEmail: {email}\u003c/li\u003e\n  \u003c/ul\u003e\n);\n\nconst Loading = () =\u003e \u003cdiv\u003eLoading...\u003c/div\u003e;\nconst Failed = () =\u003e \u003cdiv\u003eFailed to load profile!\u003c/div\u003e;\n\nconst mapProfileDataToProps = (profile) =\u003e ({\n  name: profile.name,\n  email: profile.email\n})\n\nconst MyContainer = compose(\n  withCall(profileActions),\n  withProgressComponents(profileActions, {\n    [LOADING]: Loading,\n    [FAILED]: Failed\n  }),\n  withData(profileActions, mapProfileDataToProps)\n)(MyComponent);\n\nReactDOM.render(\n  \u003cMyContainer id={1} /\u003e,\n  document.getElementById(\"root\")\n);\n```\n\n### Actions\n\nActions can be called, reset, and cancelled.\n\n#### createActions\n\nTo create a set of actions, use the `createActions` function.  It accepts as arguments:\n\n1. `id`: Unique string representing the key in the redux store.  If multiple actions have the same\n   `id`, then performing one action will overwrite the results of the other.  Including periods\n   (`.`) will result in a nested object structure within the redux store (e.g.: `foo.bar` will\n   create an object with key `foo` nesting an object with key `bar`).\n1. `action`: Function to be called when dispatching the action.\n\nCalling `createActions` will generate a simple object with the following shape:\n\n* `id`: The same unique string that was passed in.\n* `call`: Wrapper function used for dispatching your function definition.\n* `cancel`: Wrapper function used for cancelling your action if it is processing.\n* `reset`: Wrapper function for resetting the portion of the redux store under `id` to its initial\n  state.\n* `actionTypes`: Used internally for mapping function calls and results within the redux store.\n\n```js\nimport { createActions } from \"spunky\";\n\nexport default createActions(\"geocode\", ({ latitude, longitude }) =\u003e async () =\u003e {\n  // Perform a reverse geocode request \u0026 return the full address of the first result.\n  const { data } = await axios.get(\"https://maps.googleapis.com/maps/api/geocode/json\", {\n    params: {\n      latlng: [latitude, longitude].join(\",\"),\n      key: GOOGLE_MAPS_API_KEY\n    }\n  });\n\n  if (data.status !== \"OK\") {\n    throw new Error(`Unexpected API response: ${data.status}`);\n  }\n\n  return data.results[0].address_components.map((component) =\u003e component.short_name).join(\" \");\n});\n```\n\n#### createBatchActions\n\nThe `createBatchActions` function is useful when you want to call multiple functions in parallel, or\nif you want to display a loading component until all actions are finished loading.  It accepts as\narguments:\n\n1. `id`: Unique string representing the key in the redux store.  If multiple actions have the same\n   `id`, then performing one action will overwrite the results of the other.  Including periods\n   (`.`) will result in a nested object structure within the redux store (e.g.: `foo.bar` will\n   create an object with key `foo` that has a nested object under key `bar`).\n1. `actionsMap`: an object where each key represents an identifier for data, errors, etc., and each\n   value is a set of actions created via `createActions` or `createBatchActions`.\n\n```js\nimport { createBatchActions } from \"spunky\";\n\nexport default createBatchActions(\"account\", {\n  profile: profileActions,\n  friends: friendsActions\n});\n```\n\n### Higher-Order Components\n\n#### withCall\n\nThe `withCall` HOC is used for kicking off your action's function for the first time.  It will run\nany time your component is added to the DOM.\n\n| Argument           | Type       | Required | Description\n| ------------------ | ---------- | -------- | -----------\n| `actions`          | `Actions`  | Yes      | The actions defined by `createAction` or `createBatchActions`.\n| `mapPropsToAction` | `Function` | No       | The function used to pass data to the function call. (default: `(props) =\u003e props`)\n| `options`          | `object`   | No       | An object containing additional options outlined below.\n| `options.propName` | `string`   | No       | The dispatch function prop's name. (default: `\"performAction\"`)\n\n```js\nimport { withCall } from \"spunky\";\n\nexport default withCall(profileActions)(MyComponent);\n```\n\n#### withData\n\nThe `withData` HOC is used for passing data (once loaded) to your component.\n\n| Argument           | Type       | Required | Description\n| ------------------ | ---------- | -------- | -----------\n| `actions`          | `Actions`  | Yes      | The actions defined by `createAction` or `createBatchActions`.\n| `mapper`           | `function` | No       | The function used to map the error to props. (default: maps to `error` prop)\n| `options`          | `object`   | No       | An object containing additional options outlined below.\n| `options.prefix`   | `string`   | No       | The reducer key used when integrating this package. (default: `\"spunky\"`)\n\n```js\nimport { withCall } from \"spunky\";\n\nconst mapLocationDataToProps = (location) =\u003e ({\n  address: location.address\n});\n\nconst dashboardActions = ({ profile, friends }) =\u003e ({\n  name: `${profile.firstName} ${profile.lastName}`,\n  email: profile.email,\n  activeFriends: friends.filter(friend =\u003e friend.active)\n});\n\nconst MyComponent = ({ address, name, email, activeFriends }) =\u003e (\n  \u003cdl\u003e\n    \u003cdt\u003eLocation\u003cdt\u003e\n    \u003cdd\u003e{address}\u003c/dd\u003e\n    \u003cdt\u003eName\u003c/dt\u003e\n    \u003cdd\u003e{name}\u003c/dd\u003e\n    \u003cdt\u003eEmail\u003c/dt\u003e\n    \u003cdd\u003e{email}\u003c/dd\u003e\n    \u003cdt\u003eNumber of Friends\u003c/dt\u003e\n    \u003cdd\u003e{activeFriends.length}\u003c/dd\u003e\n  \u003c/dl\u003e\n);\n\nexport default compose(\n  // for actions:\n  withData(locationActions, mapLocationDataToProps),\n\n  // for batch actions:\n  withData(dashboardActions, mapDashboardDataToProps)\n)(MyComponent);\n```\n\n#### withError\n\nThe `withError` HOC is used for passing error (once failed) to your component.\n\n| Argument           | Type       | Required | Description\n| ------------------ | ---------- | -------- | -----------\n| `actions`          | `Actions`  | Yes      | The actions defined by `createAction` or `createBatchActions`.\n| `mapper`           | `function` | No       | The function used to map the error to props. (default: maps to `error` prop)\n| `options`          | `object`   | No       | An object containing additional options outlined below.\n| `options.prefix`   | `string`   | No       | The reducer key used when integrating this package. (default: `\"spunky\"`)\n\n```js\nimport { withError } from \"spunky\";\n\nconst MyComponent = ({ error }) =\u003e (\n  \u003cdiv\u003eError loading data: {error}\u003c/div\u003e\n);\n\nexport default withError(profileActions)(MyComponent);\n```\n\n#### withProgress\n\nThe `withProgress` HOC is used for passing the progress value for an action to your component.\n\n| Argument           | Type       | Required | Description\n| ------------------ | ---------- | -------- | -----------\n| `actions`          | `Actions`  | Yes      | The actions defined by `createAction` or `createBatchActions`.\n| `options`          | `object`   | No       | An object containing additional options outlined below.\n| `options.propName` | `string`   | No       | The progress prop's name. (default: `\"progress\"`)\n| `options.prefix`   | `string`   | No       | The reducer key used when integrating this package. (default: `\"spunky\"`)\n| `options.strategy` | `function` | No       | The reducer key used when integrating this package. (default: `initiallyLoadedStrategy`)\n\n```js\nimport { withProgress } from \"spunky\";\n\nconst MyComponent = ({ progress }) =\u003e (\n  \u003cdiv\u003eProgress: {progress}\u003c/div\u003e\n);\n\nexport default withProgress(profileActions)(MyComponent);\n```\n\n##### Initially Loaded Strategy\n\nThe `initiallyLoadedStrategy` is the default strategy.  It:\n\n* returns `FAILED` if any actions have failed,\n* returns `LOADED` if all actions have loaded.\n* returns `LOADING` if any actions are loading or haven't started loading,\n\n```js\nimport { initiallyLoadedStrategy } from \"spunky\";\n```\n\n##### Already Loaded Strategy\n\nThe `alreadyLoadedStrategy`:\n\n* returns `FAILED` if any actions have failed,\n* returns `LOADED` if all actions have loaded *at least once*, even if it has been called (is\n  loading) again,\n* returns `LOADING` if any actions hasn't finished loading *for the first time*.\n\n```js\nimport { alreadyLoadedStrategy } from \"spunky\";\n```\n\n##### Recently Completed Strategy\n\nThe `recentlyCompletedStrategy`:\n\n* returns `FAILED` if any actions are failed or are loading but failed before the most recent load,\n* returns `LOADED` if all actions are loaded or are loading but loaded before the most recent load,\n* returns `LOADING` if any actions are loading and have not previously loaded or failed.\n\n#### Custom Strategy\n\nYou can define your own strategy as well.  It should accept an array of action states and return one\nof `LOADING`, `LOADED`, or `FAILED`.  An action state is defined as:\n\n```js\n{\n  batch: boolean,\n  progress: 'INITIAL' | 'LOADING' | 'LOADED' | 'FAILED',\n  rollbackProgress: 'INITIAL' | 'LOADING' | 'LOADED' | 'FAILED' | null,\n  loadedCount: number,\n  data: any,\n  error: string\n}\n```\n\nFor example:\n\n```js\nimport { progressValues } from \"spunky\";\n\nconst { LOADING, LOADED, FAILED } = progressValues;\n\nfunction progressCount(progressValue) {\n  return actionStates.filter(actionState =\u003e actionState.progress === progressValue).length;\n}\n\nfunction atLeastTwoLoadedStrategy(actionStates) {\n  if (progressCount(FAILED) \u003e 0) {\n    return FAILED;\n  } else if (progressCount(LOADED) \u003e= 2) {\n    return LOADED;\n  } else {\n    return LOADING;\n  }\n}\n```\n\n#### withProgressComponents\n\nThe `withProgress` HOC is used for passing the progress value for an action to your component.\n\n| Argument           | Type       | Required | Description\n| ------------------ | ---------- | -------- | -----------\n| `actions`          | `Actions`  | Yes      | The actions defined by `createAction` or `createBatchActions`.\n| `mapping`          | `object`   | Yes      | An object with keys representing the progress value and values representing the component to render.\n| `options`          | `object`   | No       | An object containing additional options outlined below.\n| `options.propName` | `string`   | No       | The progress prop's name. (default: `\"progress\"`)\n| `options.prefix`   | `string`   | No       | The reducer key used when integrating this package. (default: `\"spunky\"`)\n| `options.strategy` | `function` | No       | The reducer key used when integrating this package. (default: `initiallyLoadedStrategy`)\n\n```js\nimport { withProgressComponents, progressValues } from \"spunky\";\n\nconst { LOADING, LOADED, FAILED } = progressValues;\n\nconst MyComponent = () =\u003e (\n  \u003cdiv\u003eData loaded successfully!\u003c/div\u003e\n);\n\nconst Loading = () =\u003e \u003cdiv\u003eLoading...\u003c/div\u003e;\nconst Failed = () =\u003e \u003cdiv\u003eFailed to load profile!\u003c/div\u003e;\n\nexport default withProgressComponents(profileActions, {\n  [LOADING]: Loading,\n  [FAILED]: Failed\n})(MyComponent);\n```\n\n#### withCancel\n\nThe `withCancel` HOC is used for cancelling (interrupting) a call when one (or more) prop changes.\n\n| Argument           | Type                           | Required | Description\n| ------------------ | ------------------------------ | -------- | -----------\n| `actions`          | `Actions`                      | Yes      | The actions defined by `createAction` or `createBatchActions`.\n| `shouldReload`     | `string / string[] / function` | Yes      | The prop name(s) to watch for changes, or a function that takes `prevProps` and `nextProps` and returns a boolean.\n| `options`          | `object`                       | No       | An object containing additional options outlined below.\n| `options.propName` | `string`                       | No       | The dispatch function prop's name. (default: `\"performAction\"`)\n\n```js\nimport { withCancel } from \"spunky\";\n\nexport default withCancel(profileActions, \"profileId\")(MyComponent);\n\nexport default withCancel(profileActions, (prevProps, nextProps) =\u003e {\n  return prevProps.profileId === null \u0026\u0026 nextProps.profileId !== null;\n})(MyComponent);\n```\n\n#### withRecall\n\nThe `withRecall` HOC is used for retrying a call when one (or more) prop changes.\n\n| Argument           | Type                           | Required | Description\n| ------------------ | ------------------------------ | -------- | -----------\n| `actions`          | `Actions`                      | Yes      | The actions defined by `createAction` or `createBatchActions`.\n| `shouldReload`     | `string / string[] / function` | Yes      | The prop name(s) to watch for changes, or a function that takes `prevProps` and `nextProps` and returns a boolean.\n| `options`          | `object`                       | No       | An object containing additional options outlined below.\n| `options.propName` | `string`                       | No       | The dispatch function prop's name. (default: `\"performAction\"`)\n\n```js\nimport { withRecall } from \"spunky\";\n\nexport default withRecall(profileActions, \"error\")(MyComponent);\n\nexport default withRecall(profileActions, (prevProps, nextProps) =\u003e {\n  return prevProps.error === null \u0026\u0026 nextProps.error !== null;\n})(MyComponent);\n```\n\n#### withReset\n\nThe `withReset` HOC is used for reseting (clearing) the redux store for an action when one (or more) prop changes.\n\n| Argument           | Type                           | Required | Description\n| ------------------ | ------------------------------ | -------- | -----------\n| `actions`          | `Actions`                      | Yes      | The actions defined by `createAction` or `createBatchActions`.\n| `shouldReload`     | `string / string[] / function` | Yes      | The prop name(s) to watch for changes, or a function that takes `prevProps` and `nextProps` and returns a boolean.\n| `options`          | `object`                       | No       | An object containing additional options outlined below.\n| `options.propName` | `string`                       | No       | The dispatch function prop's name. (default: `\"performAction\"`)\n\n```js\nimport { withReset } from \"spunky\";\n\nexport default withReset(profileActions, \"profileId\")(MyComponent);\n\nexport default withReset(profileActions, (prevProps, nextProps) =\u003e {\n  return prevProps.profileId !== null \u0026\u0026 nextProps.profileId === null;\n})(MyComponent);\n```\n\n#### withActions\n\nThe `withActions` HOC is used for passing the `call`, `cancel`, `reset`, and/or `clean` function\ndefinitions for an action to your component.\n\n| Argument            | Type       | Required | Description\n| ------------------- | ---------- | -------- | -----------\n| `actions`           | `Actions`  | Yes      | The actions defined by `createAction` or `createBatchActions`.\n| `mapActionsToProps` | `function` | No       | The function used to map the actions to props. (default: maps to `call`, `cancel`, and/or `reset` props)\n\n```js\nimport { withActions } from \"spunky\";\n\nexport default withActions(profileActions)(MyComponent);\n\nexport default withActions(profileActions, (actions, ownProps) =\u003e ({\n  request: actions.call,\n  abort: actions.cancel,\n  reset: actions.reset,\n  clean: actions.clean\n}))(MyComponent);\n```\n\n## Development\n\nRun test suite:\n\n```\nyarn run test\n```\n\nRun lint:\n\n```\nyarn run lint\n```\n\nRun flow:\n\n```\nyarn run flow\n```\n\n### Building\n\nBuild for development:\n\n```\nyarn run build:dev\n```\n\nBuild for production:\n\n```\nyarn run build:prod\n```\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fneoverse%2Fspunky","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fneoverse%2Fspunky","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fneoverse%2Fspunky/lists"}