spunky leverages the power of redux-saga to take away the pain of
writing redux reducers, allowing you to focus on writing simple JavaScript
functions for reading and writing your application's data. spunky will automatically take care of
state management, providing insight about the progress, success, or failure of calling your
function. This progress along with data, errors, and more can in turn be exposed to your
React components using a predefined set of
higher-order components (HOCs).
Benefits of using spunky:
- Eliminates the need to write a reducer for every redux action.
- Automatic progress tracking with loading, loaded, and error states.
- Cancel asynchronous actions before they finish.
- Track action progress.
- Simple HOC's for exposing data, errors, and progress (and more!) to your components.
One important aspect worth noting about this package is that it stores functions inside of redux stores. Because functions cannot be serialized, this package currently does not work with isomorphic applications that attempt to preload data on the server in order to pass it to the client.
If using npm:
npm install spunky react redux redux-saga --save
If using yarn:
yarn add spunky react redux redux-saga
First, you will need to combine spunky's reducer in your top-level reducer. If your app does not
already have a reducer, you will need to create one.
The spunky package assumes that you will store this under the "spunky" key. If you prefer a
different key, note that you will need to specify that key as part of options.prefix when using
the HOCs provided by this package.
// app/reducers.js
import { combineReducers } from "redux";
import { reducer as spunkyReducer } from "spunky";
export default combineReducers({
spunky: spunkyReducer
// other app reducers here
});Next, you will need to expose your reducer from above along with spunky's saga to your redux store.
// app/store.js
import { createStore, applyMiddleware } from "redux";
import createSagaMiddleware from "redux-saga";
import { saga } from "spunky";
import reducers from "./reducers";
export default function configureStore(initialState = {}) {
const sagaMiddleware = createSagaMiddleware();
const store = createStore(reducers, initialState, applyMiddleware(sagaMiddleware));
sagaMiddleware.run(saga);
return store;
}In order to use promises (or async/await syntax) in your actions, you can apply the redux-saga
middleware in conjunction with redux-thunk. e.g.:
import thunk from "redux-thunk";
const store = createStore(reducers, initialState, applyMiddleware(sagaMiddleware, thunk));Usage of spunky can be broken down into three parts:
- Define actions: Actions can be used for reading or writing data, whether through an API,
local storage, or otherwise. They can be synchronous or asynchronous (via promises or the ES6
asynckeyword). - Call actions: Use actions to retrieve and return data to your application.
- Expose data: Use HOCs to expose data (or errors) to your components.
Below is an example of how these three steps work together to fetch & expose data to a custom component, displaying a loading component while data is loading.
import React from "react";
import ReactDOM from "react";
import { compose } from "recompose";
import {
createActions,
withCall,
withData,
withProgressComponents,
progressValues
} from "spunky";
const { LOADING, FAILED } = progressValues;
const PROFILES = [
{ id: 1, name: "Homer", email: "homer@example.com" },
{ id: 2, name: "Lenny", email: "lenny@example.com" },
{ id: 2, name: "Carl", email: "carl@example.com" }
];
const profileActions = createActions("profile", ({ id }) => async () => {
const profile = PROFILES.find(profile => profile.id === id);
// fake internet latency so that we can see loading component while data loads
await delay(1000);
if (!profile) {
throw new Error("Profile not found.");
}
return profile;
});
const MyComponent = ({ name, email }) => (
<ul>
<li>Name: {name}</li>
<li>Email: {email}</li>
</ul>
);
const Loading = () => <div>Loading...</div>;
const Failed = () => <div>Failed to load profile!</div>;
const mapProfileDataToProps = (profile) => ({
name: profile.name,
email: profile.email
})
const MyContainer = compose(
withCall(profileActions),
withProgressComponents(profileActions, {
[LOADING]: Loading,
[FAILED]: Failed
}),
withData(profileActions, mapProfileDataToProps)
)(MyComponent);
ReactDOM.render(
<MyContainer id={1} />,
document.getElementById("root")
);Actions can be called, reset, and cancelled.
To create a set of actions, use the createActions function. It accepts as arguments:
id: Unique string representing the key in the redux store. If multiple actions have the sameid, then performing one action will overwrite the results of the other. Including periods (.) will result in a nested object structure within the redux store (e.g.:foo.barwill create an object with keyfoonesting an object with keybar).action: Function to be called when dispatching the action.
Calling createActions will generate a simple object with the following shape:
id: The same unique string that was passed in.call: Wrapper function used for dispatching your function definition.cancel: Wrapper function used for cancelling your action if it is processing.reset: Wrapper function for resetting the portion of the redux store underidto its initial state.actionTypes: Used internally for mapping function calls and results within the redux store.
import { createActions } from "spunky";
export default createActions("geocode", ({ latitude, longitude }) => async () => {
// Perform a reverse geocode request & return the full address of the first result.
const { data } = await axios.get("https://maps.googleapis.com/maps/api/geocode/json", {
params: {
latlng: [latitude, longitude].join(","),
key: GOOGLE_MAPS_API_KEY
}
});
if (data.status !== "OK") {
throw new Error(`Unexpected API response: ${data.status}`);
}
return data.results[0].address_components.map((component) => component.short_name).join(" ");
});The createBatchActions function is useful when you want to call multiple functions in parallel, or
if you want to display a loading component until all actions are finished loading. It accepts as
arguments:
id: Unique string representing the key in the redux store. If multiple actions have the sameid, then performing one action will overwrite the results of the other. Including periods (.) will result in a nested object structure within the redux store (e.g.:foo.barwill create an object with keyfoothat has a nested object under keybar).actionsMap: an object where each key represents an identifier for data, errors, etc., and each value is a set of actions created viacreateActionsorcreateBatchActions.
import { createBatchActions } from "spunky";
export default createBatchActions("account", {
profile: profileActions,
friends: friendsActions
});The withCall HOC is used for kicking off your action's function for the first time. It will run
any time your component is added to the DOM.
| Argument | Type | Required | Description |
|---|---|---|---|
actions |
Actions |
Yes | The actions defined by createAction or createBatchActions. |
mapPropsToAction |
Function |
No | The function used to pass data to the function call. (default: (props) => props) |
options |
object |
No | An object containing additional options outlined below. |
options.propName |
string |
No | The dispatch function prop's name. (default: "performAction") |
import { withCall } from "spunky";
export default withCall(profileActions)(MyComponent);The withData HOC is used for passing data (once loaded) to your component.
| Argument | Type | Required | Description |
|---|---|---|---|
actions |
Actions |
Yes | The actions defined by createAction or createBatchActions. |
mapper |
function |
No | The function used to map the error to props. (default: maps to error prop) |
options |
object |
No | An object containing additional options outlined below. |
options.prefix |
string |
No | The reducer key used when integrating this package. (default: "spunky") |
import { withCall } from "spunky";
const mapLocationDataToProps = (location) => ({
address: location.address
});
const dashboardActions = ({ profile, friends }) => ({
name: `${profile.firstName} ${profile.lastName}`,
email: profile.email,
activeFriends: friends.filter(friend => friend.active)
});
const MyComponent = ({ address, name, email, activeFriends }) => (
<dl>
<dt>Location<dt>
<dd>{address}</dd>
<dt>Name</dt>
<dd>{name}</dd>
<dt>Email</dt>
<dd>{email}</dd>
<dt>Number of Friends</dt>
<dd>{activeFriends.length}</dd>
</dl>
);
export default compose(
// for actions:
withData(locationActions, mapLocationDataToProps),
// for batch actions:
withData(dashboardActions, mapDashboardDataToProps)
)(MyComponent);The withError HOC is used for passing error (once failed) to your component.
| Argument | Type | Required | Description |
|---|---|---|---|
actions |
Actions |
Yes | The actions defined by createAction or createBatchActions. |
mapper |
function |
No | The function used to map the error to props. (default: maps to error prop) |
options |
object |
No | An object containing additional options outlined below. |
options.prefix |
string |
No | The reducer key used when integrating this package. (default: "spunky") |
import { withError } from "spunky";
const MyComponent = ({ error }) => (
<div>Error loading data: {error}</div>
);
export default withError(profileActions)(MyComponent);The withProgress HOC is used for passing the progress value for an action to your component.
| Argument | Type | Required | Description |
|---|---|---|---|
actions |
Actions |
Yes | The actions defined by createAction or createBatchActions. |
options |
object |
No | An object containing additional options outlined below. |
options.propName |
string |
No | The progress prop's name. (default: "progress") |
options.prefix |
string |
No | The reducer key used when integrating this package. (default: "spunky") |
options.strategy |
function |
No | The reducer key used when integrating this package. (default: initiallyLoadedStrategy) |
import { withProgress } from "spunky";
const MyComponent = ({ progress }) => (
<div>Progress: {progress}</div>
);
export default withProgress(profileActions)(MyComponent);The initiallyLoadedStrategy is the default strategy. It:
- returns
FAILEDif any actions have failed, - returns
LOADEDif all actions have loaded. - returns
LOADINGif any actions are loading or haven't started loading,
import { initiallyLoadedStrategy } from "spunky";The alreadyLoadedStrategy:
- returns
FAILEDif any actions have failed, - returns
LOADEDif all actions have loaded at least once, even if it has been called (is loading) again, - returns
LOADINGif any actions hasn't finished loading for the first time.
import { alreadyLoadedStrategy } from "spunky";The recentlyCompletedStrategy:
- returns
FAILEDif any actions are failed or are loading but failed before the most recent load, - returns
LOADEDif all actions are loaded or are loading but loaded before the most recent load, - returns
LOADINGif any actions are loading and have not previously loaded or failed.
You can define your own strategy as well. It should accept an array of action states and return one
of LOADING, LOADED, or FAILED. An action state is defined as:
{
batch: boolean,
progress: 'INITIAL' | 'LOADING' | 'LOADED' | 'FAILED',
rollbackProgress: 'INITIAL' | 'LOADING' | 'LOADED' | 'FAILED' | null,
loadedCount: number,
data: any,
error: string
}For example:
import { progressValues } from "spunky";
const { LOADING, LOADED, FAILED } = progressValues;
function progressCount(progressValue) {
return actionStates.filter(actionState => actionState.progress === progressValue).length;
}
function atLeastTwoLoadedStrategy(actionStates) {
if (progressCount(FAILED) > 0) {
return FAILED;
} else if (progressCount(LOADED) >= 2) {
return LOADED;
} else {
return LOADING;
}
}The withProgress HOC is used for passing the progress value for an action to your component.
| Argument | Type | Required | Description |
|---|---|---|---|
actions |
Actions |
Yes | The actions defined by createAction or createBatchActions. |
mapping |
object |
Yes | An object with keys representing the progress value and values representing the component to render. |
options |
object |
No | An object containing additional options outlined below. |
options.propName |
string |
No | The progress prop's name. (default: "progress") |
options.prefix |
string |
No | The reducer key used when integrating this package. (default: "spunky") |
options.strategy |
function |
No | The reducer key used when integrating this package. (default: initiallyLoadedStrategy) |
import { withProgressComponents, progressValues } from "spunky";
const { LOADING, LOADED, FAILED } = progressValues;
const MyComponent = () => (
<div>Data loaded successfully!</div>
);
const Loading = () => <div>Loading...</div>;
const Failed = () => <div>Failed to load profile!</div>;
export default withProgressComponents(profileActions, {
[LOADING]: Loading,
[FAILED]: Failed
})(MyComponent);The withCancel HOC is used for cancelling (interrupting) a call when one (or more) prop changes.
| Argument | Type | Required | Description |
|---|---|---|---|
actions |
Actions |
Yes | The actions defined by createAction or createBatchActions. |
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. |
options |
object |
No | An object containing additional options outlined below. |
options.propName |
string |
No | The dispatch function prop's name. (default: "performAction") |
import { withCancel } from "spunky";
export default withCancel(profileActions, "profileId")(MyComponent);
export default withCancel(profileActions, (prevProps, nextProps) => {
return prevProps.profileId === null && nextProps.profileId !== null;
})(MyComponent);The withRecall HOC is used for retrying a call when one (or more) prop changes.
| Argument | Type | Required | Description |
|---|---|---|---|
actions |
Actions |
Yes | The actions defined by createAction or createBatchActions. |
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. |
options |
object |
No | An object containing additional options outlined below. |
options.propName |
string |
No | The dispatch function prop's name. (default: "performAction") |
import { withRecall } from "spunky";
export default withRecall(profileActions, "error")(MyComponent);
export default withRecall(profileActions, (prevProps, nextProps) => {
return prevProps.error === null && nextProps.error !== null;
})(MyComponent);The withReset HOC is used for reseting (clearing) the redux store for an action when one (or more) prop changes.
| Argument | Type | Required | Description |
|---|---|---|---|
actions |
Actions |
Yes | The actions defined by createAction or createBatchActions. |
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. |
options |
object |
No | An object containing additional options outlined below. |
options.propName |
string |
No | The dispatch function prop's name. (default: "performAction") |
import { withReset } from "spunky";
export default withReset(profileActions, "profileId")(MyComponent);
export default withReset(profileActions, (prevProps, nextProps) => {
return prevProps.profileId !== null && nextProps.profileId === null;
})(MyComponent);The withActions HOC is used for passing the call, cancel, reset, and/or clean function
definitions for an action to your component.
| Argument | Type | Required | Description |
|---|---|---|---|
actions |
Actions |
Yes | The actions defined by createAction or createBatchActions. |
mapActionsToProps |
function |
No | The function used to map the actions to props. (default: maps to call, cancel, and/or reset props) |
import { withActions } from "spunky";
export default withActions(profileActions)(MyComponent);
export default withActions(profileActions, (actions, ownProps) => ({
request: actions.call,
abort: actions.cancel,
reset: actions.reset,
clean: actions.clean
}))(MyComponent);Run test suite:
yarn run test
Run lint:
yarn run lint
Run flow:
yarn run flow
Build for development:
yarn run build:dev
Build for production:
yarn run build:prod