with-resources v2.3.0
with-resources
A higher order component to help fetch resources
This React HOC facilitates fetching and grouping data into categories called resources which is freely defined by user. User just needs to supply the data manager which is in charge of fetching the data for each resource defined.
Requirements
This tool has the following requirements:
- peerDependencies:
"react": "^16.0.0",
"react-redux": "^5.0.0",
"redux": ">=4 <5",
"redux-observable": "^1.0.0",
"rxjs": ">=6.0.0-beta.0 <7"
Setup of redux store and provide it to the app using react-redux Provider
Setup of redux-observable as redux middleware to handle side-effects
Data manager to fetch data for each resource
Installation
Make sure that you have installed all the peer dependencies
yarn add with-resources
or
npm install with-resources --save
Development Setup
Resource Types
with-resources requires an object resourceTypes to prepare the resources. It must be in the following format:
{
USERS: 'users',
DIGITAL_ASSETS: 'digitalAssets',
}
keys are in CAPS_SNAKE_CASE and referred in code; values are in camelCase and serve as key to select the corresponding data manager or getter for a particular resource. Both should end with 's' to indicate plurality.
Data manager
Each resource needs their own data manager to fetch data. Each data manager must adhere to the following format to interface with with-resources:
const DM = async ({ method, input }) => ({ [method]: await DM[method](input) });
and have individual functions to fetch data corresponding to each method (CRUD following RESTful convention):
/*
C - Create
adapter: {
fe2be: to massage body from FE to BE format
be2fe: to massage response from BE to FE format
}
*/
DM.create = async ({ content }) =>
R.pipe(
adapter.fe2be,
payload =>
fetch(`${endpoint}/${resource}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
})
.then(res => res.json())
.then(adapter.be2fe)
.catch(err => ({ err }))
)(content);
/*
R - retrieve
*/
DM.retrieveOne = async () =>
fetch(`${endpoint}/${resource}`, { method: "GET" })
.then(res => res.json())
.then(adapter.be2fe)
.catch(err => ({ err }));
/*
R - retrieve
queries: [
{ name: "page", value: 0 },
{ name: "pageSize", value: 10 },
]
getQueriesString: convert to query string: "?page=1&pageSize=10"
*/
DM.retrieveMany = async ({ params: { queries } }) =>
R.pipe(
getQueriesString,
queriesString =>
fetch(`${endpoint}/${resource}${queriesString}`, { method: "GET" })
.then(res => res.json())
.then(adapter.be2fe)
.catch(err => ({ err }))
)(queries);
/*
U - Update
ids: [
{ name: "resourceId", value: 1234 },
]
getIdsObject: convert to { resourceId: 1234 }
*/
DM.update = async ({ params: { ids }, content }) =>
R.pipe(
R.juxt([
R.pipe(
R.prop("ids"),
getIdsObject
),
R.prop("content")
]),
([{ resourceId }, content]) =>
R.pipe(
adapter.fe2be,
payload =>
fetch(`${endpoint}/${resource}/${resourceId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
})
.then(res => res.json())
.then(adapter.be2fe)
.catch(err => ({ err }))
)(content)
)({ ids, content });
/*
D - Delete
*/
DM.delete = async ({ params: { ids } }) =>
R.pipe(
getIdsObject,
({ resourceId }) =>
fetch(`${endpoint}/${resource}/${resourceId}`, { method: "DELETE" })
.then(res => res.json())
.catch(err => ({ err }))
)(ids);
data/managers/index.js
import resourceType1 from "./resourceType1";
import resourceType2 from "./resourceType2";
export default {
resourceType1,
resourceType2
};
Redux Store
data/store.js
import { createStore, applyMiddleware, compose } from "redux";
import { createEpicMiddleware, combineEpics } from "redux-observable";
import setupResources from "with-resources";
import DM from "./managers";
const epicMiddleware = createEpicMiddleware();
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
const { reducer, epics } = setupResources({
resourceTypes: {
RESOURCE_TYPE_1: "resourceType1",
RESOURCE_TYPE_2: "resourceType2"
},
DM
});
const store = createStore(
reducer,
composeEnhancers(applyMiddleware(epicMiddleware))
);
epicMiddleware.run(combineEpics(...epics));
export default store;
API & Usage
Default export of with-resources
The default export of with-resources (setupResources) receives a config object:
{ resourceTypes = {}, reduxPath = [], DM }
- resourceTypes is the object containing the resources you want to set up (see above)
- reduxPath is the location of your root resources in redux store (depending on where you put the reducer returned from setupResources)
- DM is the object containing the data managers for each resources
HOC: withResources(operations)
Each operation represents one request to a resource. It has the following format:
{
resourceType: resourceTypes.USERS,
method: 'retrieveFriends',
// input: mapper or object below
input: {
params: {
ids: [
{ name: 'userId', value: 777 },
],
queries: [
{ name: 'page', value: 1 },
{ name: 'pageSize', value: 10 },
],
},
content: {},
},
options: {
autorun: false,
runOnInputChange: true,
reset: false,
useLast: false,
},
}
Each operation has the analogy to a RESTful resource request:
resourceType: the resource to request, it can be retrieved via resourceTypes which can be imported from with-resources after calling setupResources
method: follow CRUD convention, the DM for the requested resourceType must have the implementation for the specified method
input: allow customizing of url parameters such as id and query string, content is useful for C & U method. input can be a mapper function, receiving parent props and yielding the described object
options:
- autorun: auto execute the request when component mounted, and whenever the input object changes (runOnInputChange must be true). Default is false
- runOnInputChange: effective when autorun is true. Default is true. If you want to execute the input mapper function once, set runOnInputChange to false
- reset: useful when you need to reset the redux state for a method of a resourceType. Default is false
- useLast: use the last retrieved result if any (see Caching mechanism)
The operation above can be understood as making the following RESTful api request:
GET /endpoint/users/777/friends?page=1&pageSize=10
Injected props
withResources will inject a pair of data and actionCreators to the wrapped component
- data: an object with the following format:
{
status: { loading, success, error }, // combined status of all operations
users: {
retrieveFriends: {
status: { loading, success, error }, // individual status of method
// data fetched
},
// other methods
}
}
- actionCreators: a list of bound & readily dispatched action creators grouped by resource types, including ajax, clearCache, reset
{
users: { ajax, clearCache, reset },
// other resource type
}
Instead of setting autorun to true, you can use the injected actionCreatorsresourceType.ajax to fetch data at your own will. It receives a cargo (and optional callbacks) in the following format:
cargo: {
method: 'retrieveFriends',
input: {
params: {
ids: [
{ name: 'userId', value: 777 },
],
queries: [
{ name: 'page', value: 1 },
{ name: 'pageSize', value: 10 },
],
},
},
options: {
useLast: true,
}
},
onSuccess: ({ data: { retrieveFriends: { list = [] } = {} } }) => {
// ...
},
onFailure: ({ error }) => {
// ...
},
Resource Getters (Optional)
Instead of traversing the redux store path to get the data you want, i.e.
R.pathOr([], [resourceTypes.USERS, 'retrieveFriends', 'list'], data)
First, you can supply a list of getters to with-resources and use them to get the data, i.e.
gettersOf(resourceTypes.USERS).getFriends()(data)
, gettersOf can be imported from with-resources
data/getters/users.js
import * as R from 'ramda';
import { resourceTypes } from 'with-resources';
/*
**************************************************
State Getters
**************************************************
*/
const RESOURCE_TYPE = resourceTypes.USERS;
// supply true to root if you use getFriends with redux state
const getFriends = ({ root, defaultValue = [] } = {}) => R.ifElse(
R.anyPass([
R.isNil,
R.isEmpty,
R.pipe(
R.path(
R.concat(root ? ['resources'] : [], [
RESOURCE_TYPE,
'retrieveFriends',
'status',
'success',
]),
),
R.not,
),
]),
R.always(defaultValue),
R.path(R.concat(root ? ['resources'] : [], [RESOURCE_TYPE, 'retrieveFriends', 'list'])),
);
export default {
getFriends,
};
Next, you need to tell webpack where to look for the resources' getters by replacing the context (since with-resources use dynamic import of resources' getters files)
webpack.config.js
plugins: [
new webpack.ContextReplacementPlugin(/getters/, path.resolve(__dirname, 'src/data/getters')),
],
]
The idea behind it is not just for quick access of data, but for maintenance purpose. Let's say, you access the same piece of data in many places in your app, changing the location of your root resources in redux requires rectifying the access path for all the places.
By default, each resource has 3 predefined getters, namely getState
, getMethod
, getStatus
getState(state)
gives you access to the resource in redux by supplying the redux state
gettersOf(resourcesTypes.USERS).getState(state)
getMethod({ root })(method)
receives a config object (root = true means accessing from redux state), and a method (i.e retrieveFriends), and gives you access to the data of method in redux
gettersOf(resourcesTypes.USERS).getMethod()('retrieveFriends')(data).list
getStatus({ root })(method)
receives a config object (root = true means accessing from redux state), and a method (i.e retrieveFriends), and gives you access to the status of method
gettersOf(resourcesTypes.USERS).getStatus()('retrieveFriends')(data).success
Caching mechanism
Caching mechanism has the following characteristics:
- It is an opt-in mechanism, i.e. with-resources always return newly fetched data unless user specifies useLast: true.
- It is done per entire resource, i.e. if you clear cache (using action CLEAR_CACHE), the cache for the entire resource will be cleared (not just any particular method of the resource).
- Timeout: default to 15mins (Note: CLEAR_CACHE should be called on new session to limit the caching to session-based)
Examples
The animal example uses withResources to fetch image of fox, cat or dog.
Start the server by running
npm run example:server
or
yarn example:server
Then, start the web app at localhost:7000
by running
npm run example:animal
or
yarn example:animal
React Hook
with-resources also provides experimental hook, i.e. useResources
Usage
Before using useResources
, you need to meet the following requirements:
- Install version
^16.7.0-alpha.2
ofreact
- supply the
store
as the context to with-resources
index.js
import { StoreContext } from 'with-resources';
import store from './data/store';
import AppHook from './app.hook';
<StoreContext.Provider value={store}>
<App />
</StoreContext.Provider>
Inside app.hook.js
...
const { data, actionCreators } = useResources([
{
resourceType: resourceTypes.ANIMALS,
method: 'retrieveOne',
input: useMemo(
() => ({
params: {
queries: [{ name: 'kind', value: 'fox' }],
},
}),
[],
),
options: { autorun: true },
},
]);
Make sure that you use useMemo
to pass the same object input to useResources
on every render, otherwise useResources
will trigger a new request with the new object input.
Server Side Rendering
Coming soon 🚧🚧🚧
Author
Charlie Chau – chaunhihien@gmail.com
Distributed under the MIT license. See LICENSE
for more information.