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
async
keyword). - 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.bar
will create an object with keyfoo
nesting 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 underid
to 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.bar
will create an object with keyfoo
that 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 viacreateActions
orcreateBatchActions
.
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
FAILED
if any actions have failed, - returns
LOADED
if all actions have loaded. - returns
LOADING
if any actions are loading or haven't started loading,
import { initiallyLoadedStrategy } from "spunky";
The alreadyLoadedStrategy
:
- returns
FAILED
if any actions have failed, - returns
LOADED
if all actions have loaded at least once, even if it has been called (is loading) again, - returns
LOADING
if any actions hasn't finished loading for the first time.
import { alreadyLoadedStrategy } from "spunky";
The recentlyCompletedStrategy
:
- returns
FAILED
if any actions are failed or are loading but failed before the most recent load, - returns
LOADED
if all actions are loaded or are loading but loaded before the most recent load, - returns
LOADING
if 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