Skip to content

Instantly share code, notes, and snippets.

@bradennapier
Last active June 19, 2017 23:06
Show Gist options
  • Save bradennapier/f89772391d513d7d5db347742d4095eb to your computer and use it in GitHub Desktop.
Save bradennapier/f89772391d513d7d5db347742d4095eb to your computer and use it in GitHub Desktop.
A Complete Runthrough of ReduxSagaProcess's Properties and Features.
/*
A Process allows us to define a modular "Logic Component" which may
interact with our app in a variety of ways based on it's design.
Processes are given properties when configured which will define if/how
they reduce the redux state, what actions they make available to our
components, when our process should load and with what scope,
what types we want to listen for, and more.
This allows us to build truly encapsulated and re-useable application
logic.
All the properties defined below are COMPLETELY OPTIONAL. Processes are
designed with this in mind and will run as lightweight and efficiently as
possible based on their properties.
One of their major benefits is the ability to easily manage our raw state,
coordinate external actions, and dispatch a pure representation of the
values to our redux state, essentially decoupling logic from our UI completely
if we wish.
Processes can be hot reloaded and allow us to easily handle code splitting.
*/
import { Process } from 'redux-saga-process';
// To provide an example of using reselect -- completely optional
import { createSelector } from 'reselect';
/**
* Process Property: config
*
* The Process config property allows us to configure how the Process will
* be compiled, built, and ran by the library.
*
* @type {Object}
* @param {String} pid
* Optionally provide a Process ID which is used to import our
* Processes exported values within our Components.
*
* @param {String|Array} reduces
* If this Process will reduce our redux store, provide the
* key that we will reduce. Multiple Processes may reduce
* the same key - they will be merged in the order they are
* defined during the build step.
*
* If an array of strings is provided, the Process will reduce
* the given keys.
*
* @param {Boolean} wildcard
* Wildcard Matching can be enabled with this property. This
* will enable checking for * values in actionRoutes
* { 'MY*': 'handleMyAction' } will match any types that begin with
* MY
*
* @param {Boolean} transformTypes
* When defining our properties, values that are considered action types
* will automatically be converted into SCREAMING_SNAKE_CASE. This is
* done to help normalize the commands, actions, and reducers. This
* can be turned off with this value.
*
* Transformation Uses: https://www.npmjs.com/package/to-redux-type
*
* @param {Boolean} ssr
* When rendering on the server, it is likely that some Processes do
* not make sense to import and/or run. Setting ssr to false will
* disable the Process when on the server.
*
* @param {Boolean} web
* Opposite of ssr, this allows us to toggle if the Process will run
* when our client is a web client.
*/
const processConfig = {
pid: 'counter',
reduces: 'counter',
wildcard: true,
transformTypes: true,
ssr: false,
web: true,
};
/**
* Process Property: loadOnAction
*
* Extends Redux-Saga API:
* https://redux-saga.js.org/docs/api/index.html#takepattern
*
* Specifies a condition that should be awaited before loading the
* processes scope and starting the process. The processes reducers
* and initialState will already be active.
*
* You may provide:
* @type {String} Loads when the given action type is received.
* @type {Function} Receives dispatched actions and loads when the function
* returns truthy.
* @type {Object} Resolves when an object that matches the provided object
* is discovered.
* @type {Array} Resolves if any of the elements in the array pass the above
* tests. You may mix types ['AUTH_SUCCESS', { type: 'AUTH_FAILURE' }]
*/
const processLoadsOnAction = 'START_COUNTER';
/**
* Process Property: loadProcess
*
* When our process is loaded, providing this property allows us to dynamically
* import a "scope" for the process. We either return a Promise that will be
* resolved into the desired scope or we can use the provided PromiseMap.
*
* The resolved promise will be available within the instance under this.scope
* when the Process is first started.
*
* Showing an example of importing the Firebase library just as an example.
*
* @param {PromiseMap} promises https://www.npmjs.com/package/promise-map-es6
* @param {Function} defaulted Shortcut for module => module.default
*/
function processLoadsWithScope(promises, defaulted) {
// firebase library will be at this.scope.firebase, it will be
// chunked together into a chunk names "firebase-library.js" (webpack 2)
promises.merge({
firebase: import(
/* webpackChunkName: "firebase-library" */
'firebase/app',
),
});
// .push imports the values into scope but does not add them
// to the resolved object.
promises.push(
import(
/* webpackChunkName: "firebase-library" */
'firebase/auth',
),
import(
/* webpackChunkName: "firebase-library" */
'firebase/database',
),
import(
/* webpackChunkName: "firebase-library" */
'firebase/messaging',
),
);
}
/**
* Process Property: actionRoutes
*
* Action Routes allows us to define what actions our process is
* interested in receiving. It accepts an Object or a Map. If
* a Map, the keys can be any of the types defined for loadOnAction
* and the value is either a function to call or a string mapping to a
* method within our process.
*
* We use actionRoutes when we need to handle an action with more
* complex logic than the reducer can handle.
*
* Functions and Methods are called with binding to the Process Instance.
*
* Tip: if type transformations are enabled (default), myAction -> MY_ACTION
* when defining our action routes.
*
* @type {Object} An Object mapping types to methods within our process.
* {
* MY_ACTION: 'handleMyAction',
* ANOTHER_ACTION: 'handleAnotherAction'
* }
* @type {Map} A Map who's keys can be any of the available types (see loadsOnAction)
* and values map to a function or method to execute, bound to our Processes
* instance.
* new Map([
* [ action => action.type === 'MY_ACTION', 'handleMyAction' ],
* [ ['ANOTHER_ACTION', 'SOME_OTHER_ACTION'], 'handleAnotherAction'],
* [ { triggerMyAction: true }, 'handleMyAction' ]
* ])
*/
const processActionRoutes = {
UPLOAD_COUNTER: 'handleUploadCounter',
// by default, anotherAction will be converted to SYNC_COUNTER.
// this can be turned off within the configuration.
syncCounter: 'handleSyncCounter',
};
/**
* Process Property: actionCreators
*
* Action Creators allow us to define what actions our Process will
* provide to our components. By default types will be transformed to
* screaming snake case (increment -> INCREMENT), (myAction -> MY_ACTION)
*
* In the example below, our components could call:
*
* this.props.increment(2) ->
* { type: 'INCREMENT', by: 2 }
*
* this.props.decrement({ by: 2 }) ->
* { type: 'DECREMENT', by: 2 }
*
* @type {Object}
* Accepted Object Value Types:
* @type {Array} When an array is provided, each string maps to the dispatched
* types key in the action.
* @type {Null} When null is given, the action dispatches the type and merges
* any object provided when the action is called.
*
* Extra arguments are merged into the dispatched type. In the example below
* this.props.decrement(evt, { by: 2 }) -> { type: 'DECREMENT', evt: {...}, by: 2 }
*/
const processActionCreators = {
increment: ['evt', 'by'],
decrement: ['evt'],
uploadCounter: null,
syncCounter: null,
};
/**
* Process Property: initialState
*
* When our Process will be reducing the redux store, we may optionally
* provide the initial state which should be defined.
*
* @type {Object}
*/
const processInitialState = {
count: 0,
lastSynced: undefined,
lastUploaded: undefined,
inSyncWithServer: undefined,
};
/**
* Process Property: reducer
*
* When our Process will be reducing the redux store, we must define the
* reducer values we want to use. This value uses reducer generators
* (linked below) to build our reducers based on the type of value
* that we provide.
*
* https://github.com/search?q=topic%3Areducer-generator+org%3ADash-OS&type=Repositories
*
* Tip: Wildcard is accepted here if enabled in the config property
* (false by default).
*
* @type {Object}
*/
const processReducer = {
INCREMENT: (state, action) => ({
...state,
count: state.count + action.by || 1,
lastChanged: Date.now(),
inSyncWithServer: false,
inSyncWithStorage: false,
}),
DECREMENT: (state, action) => ({
...state,
count: state.count - action.by || 1,
lastChanged: Date.now(),
inSyncWithServer: false,
inSyncWithStorage: false,
}),
counterSynchronized: (state, action) => ({
...state,
lastSynced: Date.now(),
inSyncWithStorage: true,
}),
counterUploaded: (state, action) => ({
...state,
lastUploaded: Date.now(),
inSyncWithServer: true,
}),
};
/**
* Process Property: selectors
*
* Selectors allow us to not only define the redux state selectors that we
* would like to use within our process, but also allow us to easily export
* our selectors to the Component that wants to monitor our dispatched state.
*
* Selector libraries may be used if desired. Each selector will be given the
* redux state when requested and return the appropriate values.
*
* @type {Object}
*/
const processSelectors = {
count: state => state.counter.count,
lastChanged: state => state.counter.lastChanged,
// just an example of using reselect - doesn't really make any sense
// here, but oh well.
counters: createSelector(
state => state.counter,
counter => ({
count: counter.count,
lastUploaded: counter.lastUploaded,
lastSynced: counter.lastSynced,
}),
),
};
/**
* configureCounterProcess
*
* When we setup our Process, our configure factory will be called if
* we don't directly export a Process. This allows us to configure our
* processes and their behavior easily based on the environment.
*
* If we do not return a valid Process or ProcessConfiguration,
* it will be skipped.
*
* @return {Process|ProcessConfiguration}
* Expects either a Process with static properties defined for any of the
* above values or an Object which has the process and it's properties
* defined (as shown below).
*/
export default function configureCounterProcess(...args) {
let Firebase;
class CounterProcess extends Process {
*handleUploadCounter(action) {
const currentCount = yield select(processSelectors.count);
// run your upload logic here -- this is ran as a redux-saga
yield put({
type: 'COUNTER_UPLOADED',
});
}
*handleSyncCounter(action) {
// sync to local storage? whatever you want...
yield put({
type: 'COUNTER_SYNCHRONIZED',
});
}
*processStarts() {
/* This will not startup until loadsOnAction and loadProcess have
been resolved.
*/
Firebase = this.scope.firebase;
}
}
return {
process: ExampleProcess,
config: processConfig,
loadOnAction: processLoadsOnAction,
loadProcess: processLoadsWithScope,
actionRoutes: processActionRoutes,
actionCreators: processActionCreators,
initialState: processInitialState,
reducer: processReducer,
selectors: processSelectors,
};
}
//////////////////////////////////////////////////////////////////////
// In Your Components -- connectProcesses
//////////////////////////////////////////////////////////////////////
/**
* Process Import: connectProcesses
* connectProcesses allows us to connect our processes to our components.
* This function simply helps you configure the react-redux connect and
* does not add any overhead to the rendering (no HOC by default).
*
* It can also be called as a standard function to get the exported values.
*/
import React, { Component } from 'react';
import connectProcesses from 'redux-saga-process/connect';
import { connect } from 'react-redux';
/**
* Shows importing all of our actions and using our selector value to
* react to our counter value changing.
*
* @param {Number} currentCount state.counter.count
* @param {Date} lastChanged state.counter.lastChanged
* @param {Function} increment Triggers our INCREMENT Reducer
* @param {Function} decrement Triggers our DECREMENT Reducer
* @param {Function} uploadCounter Triggers our *handleUploadCounter saga
* @param {Function} syncCounter Triggers our *handleSyncCounter saga
*/
const MyComponent = ({
currentCount,
lastChanged,
increment,
decrement,
uploadCounter,
syncCounter,
}) =>
<div>
<div>
Count: {currentCount} | Last Changed: {lastChanged}
</div>
<div style={{ display: 'flex' }}>
<button onClick={increment}>Add One</button>
<button onClick={e => increment(e, 2)}>Add Two</button>
</div>
<div style={{ display: 'flex' }}>
<button onClick={decrement}>Subtract One</button>
<button onClick={e => decrement(e, 2)}>Subtract Two</button>
</div>
<button onClick={uploadCounter}>Save to Cloud</button>
<button onClick={syncCounter}>Save To Storage</button>
</div>;
const ConnectRedux = ({ actions, selectors }) =>
connect(
state => ({
currentCount: selectors.count(state),
lastChanged: selectors.lastChanged(state),
}),
actions,
)(MyComponent);
export default connectProcesses(
{
// import one or more processes by their pid (process id).
counter: [ 'selectors', 'actions' ],
},
ConnectRedux
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment