react-remote-resource
Advanced tools
Comparing version 0.2.3 to 1.0.0
{ | ||
"dist/index.js": { | ||
"bundled": 9038, | ||
"minified": 4274, | ||
"gzipped": 1614, | ||
"bundled": 8743, | ||
"minified": 4591, | ||
"gzipped": 1574, | ||
"treeshaked": { | ||
"rollup": { | ||
"code": 632, | ||
"import_statements": 125 | ||
"code": 1094, | ||
"import_statements": 233 | ||
}, | ||
"webpack": { | ||
"code": 1743 | ||
"code": 2341 | ||
} | ||
@@ -17,6 +17,34 @@ } | ||
"dist/index.cjs.js": { | ||
"bundled": 10829, | ||
"minified": 5319, | ||
"gzipped": 1735 | ||
"bundled": 10197, | ||
"minified": 5428, | ||
"gzipped": 1688 | ||
}, | ||
"dist/esm.js": { | ||
"bundled": 9741, | ||
"minified": 4963, | ||
"gzipped": 1721, | ||
"treeshaked": { | ||
"rollup": { | ||
"code": 1110, | ||
"import_statements": 217 | ||
}, | ||
"webpack": { | ||
"code": 2355 | ||
} | ||
} | ||
}, | ||
"dist/esm.min.js": { | ||
"bundled": 9741, | ||
"minified": 4963, | ||
"gzipped": 1721, | ||
"treeshaked": { | ||
"rollup": { | ||
"code": 1110, | ||
"import_statements": 217 | ||
}, | ||
"webpack": { | ||
"code": 2355 | ||
} | ||
} | ||
} | ||
} |
@@ -7,62 +7,86 @@ 'use strict'; | ||
var React = require('react'); | ||
var React__default = _interopDefault(React); | ||
var _extends = _interopDefault(require('@babel/runtime/helpers/extends')); | ||
var uuid = _interopDefault(require('uuid/v1')); | ||
var redux = require('redux'); | ||
var ramda = require('ramda'); | ||
var immutable = require('immutable'); | ||
var Maybe = _interopDefault(require('data.maybe')); | ||
var React = require('react'); | ||
var React__default = _interopDefault(React); | ||
var Context = React.createContext({ | ||
registerError: function registerError() {} | ||
}); | ||
var LOADING_ENTRY = "LOADING_ENTRY"; | ||
var LOADING_ENTRY_FAILED = "LOADING_ENTRY_FAILED"; | ||
var RECEIVE_ENTRY_DATA = "RECEIVE_ENTRY_DATA"; | ||
var Entry = immutable.Record({ | ||
id: "", | ||
data: Maybe.Nothing(), | ||
updatedAt: Maybe.Nothing(), | ||
loadPromise: Maybe.Nothing() | ||
}, "RemoteResourceEntry"); | ||
var createTaskManager = function createTaskManager() { | ||
var tasks = new Map(); | ||
return { | ||
has: function has(key) { | ||
return tasks.has(key); | ||
}, | ||
get: function get(key) { | ||
return tasks.get(key); | ||
}, | ||
run: function run(key, task) { | ||
return tasks.get(key) || tasks.set(key, task().then(function (x) { | ||
tasks.delete(key); | ||
return x; | ||
}).catch(function (error) { | ||
tasks.delete(key); | ||
throw error; | ||
})).get(key); | ||
} | ||
}; | ||
var entryReducer = function entryReducer(state, action) { | ||
if (state === void 0) { | ||
state = Entry(); | ||
} | ||
switch (action.type) { | ||
case LOADING_ENTRY: | ||
return state.merge({ | ||
id: action.entryId, | ||
loadPromise: Maybe.of(action.promise) | ||
}); | ||
case LOADING_ENTRY_FAILED: | ||
return state.merge({ | ||
id: action.entryId, | ||
loadPromise: Maybe.Nothing() | ||
}); | ||
case RECEIVE_ENTRY_DATA: | ||
return state.merge({ | ||
id: action.entryId, | ||
data: Maybe.fromNullable(action.data), | ||
updatedAt: ramda.isNil(action.data) ? Maybe.Nothing() : Maybe.of(action.now), | ||
loadPromise: Maybe.Nothing() | ||
}); | ||
default: | ||
return state; | ||
} | ||
}; | ||
var REGISTER_RESOURCE = "REGISTER_RESOURCE"; | ||
var RECEIVE_DATA = "RECEIVE_DATA"; | ||
var DELETE_ENTRY = "DELETE_ENTRY"; | ||
var SUBSCRIPTION_STARTED = "SUBSCRIPTION_STARTED"; | ||
var SUBSCRIPTION_ENDED = "SUBSCRIPTION_ENDED"; | ||
var store = redux.createStore(function (state, action) { | ||
var initialResourceState = immutable.Map({ | ||
entriesById: immutable.Map() | ||
}); | ||
var resourceReducer = function resourceReducer(state, action) { | ||
if (state === void 0) { | ||
state = immutable.Map(); | ||
state = initialResourceState; | ||
} | ||
switch (action.type) { | ||
case REGISTER_RESOURCE: | ||
return state.set(action.resourceId, immutable.Map()); | ||
case RECEIVE_ENTRY_DATA: | ||
return state.updateIn(["entriesById", action.entryId], function (entryState) { | ||
return entryReducer(entryState, action); | ||
}); | ||
case RECEIVE_DATA: | ||
return state.setIn([action.resourceId, action.entryKey], immutable.Map({ | ||
updatedAt: action.now, | ||
data: action.data, | ||
hasSubscription: false | ||
})); | ||
default: | ||
return state; | ||
} | ||
}; | ||
case SUBSCRIPTION_STARTED: | ||
return state.setIn([action.resourceId, action.entryKey, "hasSubscription"], true); | ||
var initialRootState = immutable.Map({ | ||
resourcesById: immutable.Map() | ||
}); | ||
case SUBSCRIPTION_ENDED: | ||
return state.setIn([action.resourceId, action.entryKey, "hasSubscription"], false); | ||
var rootReducer = function rootReducer(state, action) { | ||
if (state === void 0) { | ||
state = initialRootState; | ||
} | ||
case DELETE_ENTRY: | ||
return state.deleteIn([action.resourceId, action.entryKey]); | ||
switch (action.type) { | ||
case RECEIVE_ENTRY_DATA: | ||
return state.updateIn(["resourcesById", action.resourceId], function (resourceState) { | ||
return resourceReducer(resourceState, action); | ||
}); | ||
@@ -72,12 +96,30 @@ default: | ||
} | ||
}); | ||
}; | ||
var defaultCreateCacheKey = function defaultCreateCacheKey(args) { | ||
return args.join("-") || "INDEX"; | ||
var store = redux.createStore(rootReducer); | ||
var selectResource = function selectResource(state, _ref) { | ||
if (state === void 0) { | ||
state = initialRootState; | ||
} | ||
var resourceId = _ref.resourceId; | ||
return Maybe.fromNullable(state.getIn(["resourcesById", resourceId])); | ||
}; | ||
var selectEntry = function selectEntry(state, _ref2) { | ||
if (state === void 0) { | ||
state = initialRootState; | ||
} | ||
var resourceId = _ref2.resourceId, | ||
entryId = _ref2.entryId; | ||
return selectResource(state, { | ||
resourceId: resourceId | ||
}).chain(function (resource) { | ||
return Maybe.fromNullable(resource.getIn(["entriesById", entryId])); | ||
}); | ||
}; | ||
var createRemoteResource = function createRemoteResource(_ref) { | ||
var resourceId = _ref.id, | ||
_ref$load = _ref.load, | ||
loader = _ref$load === void 0 ? function () { | ||
var _ref$load = _ref.load, | ||
load = _ref$load === void 0 ? function () { | ||
return Promise.resolve(); | ||
@@ -93,6 +135,2 @@ } : _ref$load, | ||
} : _ref$delete, | ||
_ref$subscribe = _ref.subscribe, | ||
subscribe = _ref$subscribe === void 0 ? function () { | ||
return function () {}; | ||
} : _ref$subscribe, | ||
_ref$initialValue = _ref.initialValue, | ||
@@ -102,35 +140,4 @@ initialValue = _ref$initialValue === void 0 ? null : _ref$initialValue, | ||
invalidateAfter = _ref$invalidateAfter === void 0 ? 300000 : _ref$invalidateAfter, | ||
_ref$createEntryKey = _ref.createEntryKey, | ||
createEntryKey = _ref$createEntryKey === void 0 ? defaultCreateCacheKey : _ref$createEntryKey; | ||
store.dispatch({ | ||
type: REGISTER_RESOURCE, | ||
resourceId: resourceId | ||
}); | ||
var loadTasks = createTaskManager(); | ||
var saveTasks = createTaskManager(); | ||
var deleteTasks = createTaskManager(); | ||
var selectEntry = function selectEntry(entryKey) { | ||
return Maybe.fromNullable(store.getState().getIn([resourceId, entryKey])); | ||
}; | ||
var selectData = function selectData(maybeEntry) { | ||
return maybeEntry.map(function (state) { | ||
return state.get("data"); | ||
}).getOrElse(initialValue); | ||
}; | ||
var selectUpdatedAt = function selectUpdatedAt(maybeEntry) { | ||
return maybeEntry.map(function (state) { | ||
return state.get("updatedAt"); | ||
}); | ||
}; | ||
var selectHasSubscription = function selectHasSubscription(maybeEntry) { | ||
return maybeEntry.map(function (state) { | ||
return state.get("hasSubscription"); | ||
}).getOrElse(false); | ||
}; | ||
var load = function load() { | ||
_ref$createEntryId = _ref.createEntryId, | ||
createEntryId = _ref$createEntryId === void 0 ? function () { | ||
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { | ||
@@ -140,118 +147,47 @@ args[_key] = arguments[_key]; | ||
var entryKey = createEntryKey(args); | ||
return loadTasks.run(entryKey, function () { | ||
return loader.apply(void 0, args).then(function (data) { | ||
store.dispatch({ | ||
type: RECEIVE_DATA, | ||
now: Date.now(), | ||
entryKey: createEntryKey(args), | ||
data: data, | ||
return args.join("-") || "INDEX"; | ||
} : _ref$createEntryId; | ||
var resourceId = uuid(); | ||
return { | ||
id: resourceId, | ||
createEntryId: createEntryId, | ||
initialValue: initialValue, | ||
invalidateAfter: invalidateAfter, | ||
load: load, | ||
save: save, | ||
delete: destroy, | ||
getEntry: function getEntry(entryId) { | ||
return selectEntry(store.getState(), { | ||
resourceId: resourceId, | ||
entryId: entryId | ||
}); | ||
}, | ||
onChange: function onChange(_onChange) { | ||
var currentState = selectResource(store.getState(), { | ||
resourceId: resourceId | ||
}); | ||
return store.subscribe(function () { | ||
var nextResourceState = selectResource(store.getState(), { | ||
resourceId: resourceId | ||
}); | ||
return data; | ||
}); | ||
}); | ||
}; | ||
return function () { | ||
for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { | ||
args[_key2] = arguments[_key2]; | ||
} | ||
if (nextResourceState !== currentState) { | ||
currentState = nextResourceState; | ||
var entryKey = createEntryKey(args); | ||
var _useContext = React.useContext(Context), | ||
registerError = _useContext.registerError; | ||
var _useState = React.useState(selectEntry(entryKey)), | ||
entry = _useState[0], | ||
setEntry = _useState[1]; | ||
var data = selectData(entry); | ||
var hasSubscription = selectHasSubscription(entry); | ||
var cacheInvalid = selectUpdatedAt(entry).map(function (updatedAt) { | ||
return updatedAt + invalidateAfter < Date.now(); | ||
}).getOrElse(true); | ||
React.useEffect(function () { | ||
return store.subscribe(function () { | ||
var nextEntry = selectEntry(entryKey); | ||
if (nextEntry !== entry) { | ||
setEntry(nextEntry); | ||
_onChange(); | ||
} | ||
}); | ||
}, [entryKey]); // We only load on the first render if the cache is invalid | ||
var renderCount = React.useRef(0); | ||
renderCount.current = renderCount.current + 1; | ||
if (renderCount.current === 1 && cacheInvalid && !loadTasks.has(entryKey)) { | ||
load.apply(void 0, args).catch(registerError); | ||
} // We only suspend while the initial load is outstanding | ||
if (cacheInvalid && loadTasks.has(entryKey)) { | ||
throw loadTasks.get(entryKey); | ||
}, | ||
dispatch: function dispatch(action) { | ||
return store.dispatch(_extends({}, action, { | ||
resourceId: resourceId | ||
})); | ||
} | ||
var setCache = React.useCallback(function (valueOrUpdate) { | ||
var newData = typeof valueOrUpdate == "function" ? valueOrUpdate(data) : valueOrUpdate; | ||
store.dispatch({ | ||
type: RECEIVE_DATA, | ||
now: Date.now(), | ||
data: newData, | ||
entryKey: entryKey, | ||
resourceId: resourceId | ||
}); | ||
return newData; | ||
}, [data]); | ||
var actions = { | ||
refresh: React.useCallback(function () { | ||
return load.apply(void 0, args).catch(registerError); | ||
}, []), | ||
setCache: setCache, | ||
deleteCache: React.useCallback(function () { | ||
store.dispatch({ | ||
type: DELETE_ENTRY, | ||
entryKey: entryKey, | ||
resourceId: resourceId | ||
}); | ||
return data; | ||
}, [data]), | ||
remoteSave: React.useCallback(function (newData) { | ||
return saveTasks.run(entryKey, function () { | ||
return save(newData); | ||
}); | ||
}, []), | ||
remoteDelete: React.useCallback(function () { | ||
return deleteTasks.run(entryKey, function () { | ||
return destroy(data); | ||
}); | ||
}, [data]), | ||
subscribe: React.useCallback(function () { | ||
// we only want one subscription running for each entry at a time | ||
if (hasSubscription) { | ||
return; | ||
} | ||
store.dispatch({ | ||
type: SUBSCRIPTION_STARTED, | ||
entryKey: entryKey, | ||
resourceId: resourceId | ||
}); | ||
var cleanup = subscribe(setCache).apply(void 0, args); | ||
return function () { | ||
cleanup(); | ||
store.dispatch({ | ||
type: SUBSCRIPTION_ENDED, | ||
entryKey: entryKey, | ||
resourceId: resourceId | ||
}); | ||
}; | ||
}, [hasSubscription]) | ||
}; | ||
return [selectData(entry), actions]; | ||
}; | ||
}; | ||
var Context = React.createContext({ | ||
registerError: function registerError() {} | ||
}); | ||
var RemoteResourceBoundary = function RemoteResourceBoundary(_ref) { | ||
@@ -291,43 +227,113 @@ var children = _ref.children, | ||
/** | ||
* Makes a resource "optimistic". | ||
*/ | ||
var useOptimism = function useOptimism(_ref) { | ||
var data = _ref[0], | ||
actions = _ref[1]; | ||
return [data, // Save. Updates cache first then saves to the remote | ||
function () { | ||
actions.setCache.apply(actions, arguments); | ||
return actions.remoteSave.apply(actions, arguments); | ||
}, // Delete. Deletes the cache first then deletes from the remote | ||
function () { | ||
actions.deleteCache.apply(actions, arguments); | ||
return actions.remoteDelete.apply(actions, arguments); | ||
}]; | ||
}; | ||
var useResourceActions = function useResourceActions(resource, args) { | ||
if (args === void 0) { | ||
args = []; | ||
} | ||
/** | ||
* Makes a resource "pessimistic". | ||
*/ | ||
var usePessimism = function usePessimism(_ref) { | ||
var data = _ref[0], | ||
actions = _ref[1]; | ||
return [data, { | ||
refresh: actions.refresh, | ||
// Saves to the remote first, then if it succeeds it updates the cache | ||
save: function save() { | ||
return actions.remoteSave.apply(actions, arguments).then(actions.setCache); | ||
}, | ||
// Deletes from the remote first, then if it succeeds it deletes the cache | ||
delete: function _delete() { | ||
return actions.remoteDelete.apply(actions, arguments).then(actions.deleteCache); | ||
var entryId = resource.createEntryId.apply(resource, args); | ||
var _useContext = React.useContext(Context), | ||
registerError = _useContext.registerError; | ||
var data = resource.getEntry(entryId).chain(function (entry) { | ||
return entry.data; | ||
}).getOrElse(resource.initialValue); | ||
var actions = { | ||
set: React.useCallback(function (nextData) { | ||
resource.dispatch({ | ||
type: RECEIVE_ENTRY_DATA, | ||
entryId: entryId, | ||
data: typeof nextData === "function" ? nextData(data) : nextData, | ||
now: Date.now() | ||
}); | ||
}, [data]), | ||
refresh: function refresh() { | ||
return resource.load.apply(resource, args).then(function (data) { | ||
resource.dispatch({ | ||
type: RECEIVE_ENTRY_DATA, | ||
entryId: entryId, | ||
data: data, | ||
now: Date.now() | ||
}); | ||
}).catch(function (error) { | ||
registerError(error); | ||
resource.dispatch({ | ||
type: LOADING_ENTRY_FAILED, | ||
entryId: entryId | ||
}); | ||
}); | ||
} | ||
}]; | ||
}; | ||
if (resource.save) { | ||
actions.save = resource.save; | ||
} | ||
if (resource.delete) { | ||
actions.delete = resource.delete; | ||
} | ||
return actions; | ||
}; | ||
var useSubscribe = function useSubscribe(resource) { | ||
React.useEffect(resource.actions.subscribe, [resource.actions]); | ||
return resource; | ||
var useFirstRender = function useFirstRender() { | ||
var renderCount = React.useRef(0); | ||
renderCount.current = renderCount.current + 1; | ||
return renderCount.current === 1; | ||
}; | ||
var useResourceState = function useResourceState(resource, args) { | ||
if (args === void 0) { | ||
args = []; | ||
} | ||
var entryId = resource.createEntryId.apply(resource, args); | ||
var _useState = React.useState(resource.getEntry(entryId)), | ||
maybeEntry = _useState[0], | ||
setEntry = _useState[1]; | ||
var data = maybeEntry.chain(function (entry) { | ||
return entry.data; | ||
}).getOrElse(resource.initialValue); | ||
var cacheInvalid = maybeEntry.map(function (entry) { | ||
return entry.updatedAt + resource.invalidateAfter < Date.now(); | ||
}).getOrElse(data === resource.initialValue); | ||
var loadPromise = maybeEntry.chain(function (entry) { | ||
return entry.loadPromise; | ||
}).getOrElse(null); | ||
var actions = useResourceActions(resource, args); | ||
React.useEffect(function () { | ||
return (// Important! The return value is used to unsubsribe from the store when necessary. | ||
resource.onChange(function () { | ||
setEntry(resource.getEntry(entryId)); | ||
}) | ||
); | ||
}, [entryId]); | ||
var isFirstRender = useFirstRender(); | ||
if (isFirstRender && cacheInvalid && !loadPromise) { | ||
var promise = actions.refresh(); // We need to store the promise so that if the component gets re-mounted | ||
// while the promise is pending we have the ability to throw it. | ||
resource.dispatch({ | ||
type: LOADING_ENTRY, | ||
entryId: entryId, | ||
promise: promise | ||
}); | ||
throw promise; | ||
} else if (loadPromise) { | ||
throw loadPromise; | ||
} | ||
return [data, React.useCallback(function (nextData) { | ||
resource.dispatch({ | ||
type: RECEIVE_ENTRY_DATA, | ||
entryId: entryId, | ||
data: typeof nextData === "function" ? nextData(data) : nextData, | ||
now: Date.now() | ||
}); | ||
}, [data])]; | ||
}; | ||
var useSuspense = function useSuspense(fn) { | ||
@@ -364,5 +370,4 @@ var _useState = React.useState(null), | ||
exports.RemoteResourceBoundary = RemoteResourceBoundary; | ||
exports.useOptimism = useOptimism; | ||
exports.usePessimism = usePessimism; | ||
exports.useSubscribe = useSubscribe; | ||
exports.useResourceState = useResourceState; | ||
exports.useResourceActions = useResourceActions; | ||
exports.useSuspense = useSuspense; |
@@ -1,54 +0,80 @@ | ||
import React, { createContext, useContext, useState, useEffect, useRef, useCallback, useMemo, Suspense } from 'react'; | ||
import _extends from '@babel/runtime/helpers/esm/extends'; | ||
import uuid from 'uuid/v1'; | ||
import { createStore } from 'redux'; | ||
import { Map as Map$1 } from 'immutable'; | ||
import { isNil } from 'ramda'; | ||
import { Record, Map } from 'immutable'; | ||
import Maybe from 'data.maybe'; | ||
import React, { createContext, useState, useMemo, useCallback, Suspense, useContext, useEffect, useRef } from 'react'; | ||
const Context = createContext({ | ||
registerError: () => {} | ||
}); | ||
const LOADING_ENTRY = "LOADING_ENTRY"; | ||
const LOADING_ENTRY_FAILED = "LOADING_ENTRY_FAILED"; | ||
const RECEIVE_ENTRY_DATA = "RECEIVE_ENTRY_DATA"; | ||
const Entry = Record({ | ||
id: "", | ||
data: Maybe.Nothing(), | ||
updatedAt: Maybe.Nothing(), | ||
loadPromise: Maybe.Nothing() | ||
}, "RemoteResourceEntry"); | ||
const createTaskManager = () => { | ||
const tasks = new Map(); | ||
return { | ||
has: key => tasks.has(key), | ||
get: key => tasks.get(key), | ||
run: (key, task) => tasks.get(key) || tasks.set(key, task().then(x => { | ||
tasks.delete(key); | ||
return x; | ||
}).catch(error => { | ||
tasks.delete(key); | ||
throw error; | ||
})).get(key) | ||
}; | ||
const entryReducer = function entryReducer(state, action) { | ||
if (state === void 0) { | ||
state = Entry(); | ||
} | ||
switch (action.type) { | ||
case LOADING_ENTRY: | ||
return state.merge({ | ||
id: action.entryId, | ||
loadPromise: Maybe.of(action.promise) | ||
}); | ||
case LOADING_ENTRY_FAILED: | ||
return state.merge({ | ||
id: action.entryId, | ||
loadPromise: Maybe.Nothing() | ||
}); | ||
case RECEIVE_ENTRY_DATA: | ||
return state.merge({ | ||
id: action.entryId, | ||
data: Maybe.fromNullable(action.data), | ||
updatedAt: isNil(action.data) ? Maybe.Nothing() : Maybe.of(action.now), | ||
loadPromise: Maybe.Nothing() | ||
}); | ||
default: | ||
return state; | ||
} | ||
}; | ||
const REGISTER_RESOURCE = "REGISTER_RESOURCE"; | ||
const RECEIVE_DATA = "RECEIVE_DATA"; | ||
const DELETE_ENTRY = "DELETE_ENTRY"; | ||
const SUBSCRIPTION_STARTED = "SUBSCRIPTION_STARTED"; | ||
const SUBSCRIPTION_ENDED = "SUBSCRIPTION_ENDED"; | ||
const store = createStore(function (state, action) { | ||
const initialResourceState = Map({ | ||
entriesById: Map() | ||
}); | ||
const resourceReducer = function resourceReducer(state, action) { | ||
if (state === void 0) { | ||
state = Map$1(); | ||
state = initialResourceState; | ||
} | ||
switch (action.type) { | ||
case REGISTER_RESOURCE: | ||
return state.set(action.resourceId, Map$1()); | ||
case RECEIVE_ENTRY_DATA: | ||
return state.updateIn(["entriesById", action.entryId], entryState => entryReducer(entryState, action)); | ||
case RECEIVE_DATA: | ||
return state.setIn([action.resourceId, action.entryKey], Map$1({ | ||
updatedAt: action.now, | ||
data: action.data, | ||
hasSubscription: false | ||
})); | ||
default: | ||
return state; | ||
} | ||
}; | ||
case SUBSCRIPTION_STARTED: | ||
return state.setIn([action.resourceId, action.entryKey, "hasSubscription"], true); | ||
const initialRootState = Map({ | ||
resourcesById: Map() | ||
}); | ||
case SUBSCRIPTION_ENDED: | ||
return state.setIn([action.resourceId, action.entryKey, "hasSubscription"], false); | ||
const rootReducer = function rootReducer(state, action) { | ||
if (state === void 0) { | ||
state = initialRootState; | ||
} | ||
case DELETE_ENTRY: | ||
return state.deleteIn([action.resourceId, action.entryKey]); | ||
switch (action.type) { | ||
case RECEIVE_ENTRY_DATA: | ||
return state.updateIn(["resourcesById", action.resourceId], resourceState => resourceReducer(resourceState, action)); | ||
@@ -58,10 +84,28 @@ default: | ||
} | ||
}); | ||
}; | ||
const defaultCreateCacheKey = args => args.join("-") || "INDEX"; | ||
const store = createStore(rootReducer); | ||
const selectResource = function selectResource(state, _ref) { | ||
if (state === void 0) { | ||
state = initialRootState; | ||
} | ||
let resourceId = _ref.resourceId; | ||
return Maybe.fromNullable(state.getIn(["resourcesById", resourceId])); | ||
}; | ||
const selectEntry = function selectEntry(state, _ref2) { | ||
if (state === void 0) { | ||
state = initialRootState; | ||
} | ||
let resourceId = _ref2.resourceId, | ||
entryId = _ref2.entryId; | ||
return selectResource(state, { | ||
resourceId | ||
}).chain(resource => Maybe.fromNullable(resource.getIn(["entriesById", entryId]))); | ||
}; | ||
const createRemoteResource = (_ref) => { | ||
let resourceId = _ref.id, | ||
_ref$load = _ref.load, | ||
loader = _ref$load === void 0 ? () => Promise.resolve() : _ref$load, | ||
let _ref$load = _ref.load, | ||
load = _ref$load === void 0 ? () => Promise.resolve() : _ref$load, | ||
_ref$save = _ref.save, | ||
@@ -71,4 +115,2 @@ save = _ref$save === void 0 ? () => Promise.resolve() : _ref$save, | ||
destroy = _ref$delete === void 0 ? () => Promise.resolve() : _ref$delete, | ||
_ref$subscribe = _ref.subscribe, | ||
subscribe = _ref$subscribe === void 0 ? () => () => {} : _ref$subscribe, | ||
_ref$initialValue = _ref.initialValue, | ||
@@ -78,21 +120,4 @@ initialValue = _ref$initialValue === void 0 ? null : _ref$initialValue, | ||
invalidateAfter = _ref$invalidateAfter === void 0 ? 300000 : _ref$invalidateAfter, | ||
_ref$createEntryKey = _ref.createEntryKey, | ||
createEntryKey = _ref$createEntryKey === void 0 ? defaultCreateCacheKey : _ref$createEntryKey; | ||
store.dispatch({ | ||
type: REGISTER_RESOURCE, | ||
resourceId | ||
}); | ||
const loadTasks = createTaskManager(); | ||
const saveTasks = createTaskManager(); | ||
const deleteTasks = createTaskManager(); | ||
const selectEntry = entryKey => Maybe.fromNullable(store.getState().getIn([resourceId, entryKey])); | ||
const selectData = maybeEntry => maybeEntry.map(state => state.get("data")).getOrElse(initialValue); | ||
const selectUpdatedAt = maybeEntry => maybeEntry.map(state => state.get("updatedAt")); | ||
const selectHasSubscription = maybeEntry => maybeEntry.map(state => state.get("hasSubscription")).getOrElse(false); | ||
const load = function load() { | ||
_ref$createEntryId = _ref.createEntryId, | ||
createEntryId = _ref$createEntryId === void 0 ? function () { | ||
for (var _len = arguments.length, args = new Array(_len), _key = 0; _key < _len; _key++) { | ||
@@ -102,104 +127,43 @@ args[_key] = arguments[_key]; | ||
const entryKey = createEntryKey(args); | ||
return loadTasks.run(entryKey, () => loader(...args).then(data => { | ||
store.dispatch({ | ||
type: RECEIVE_DATA, | ||
now: Date.now(), | ||
entryKey: createEntryKey(args), | ||
data, | ||
return args.join("-") || "INDEX"; | ||
} : _ref$createEntryId; | ||
const resourceId = uuid(); | ||
return { | ||
id: resourceId, | ||
createEntryId, | ||
initialValue, | ||
invalidateAfter, | ||
load, | ||
save, | ||
delete: destroy, | ||
getEntry: entryId => selectEntry(store.getState(), { | ||
resourceId, | ||
entryId | ||
}), | ||
onChange: _onChange => { | ||
let currentState = selectResource(store.getState(), { | ||
resourceId | ||
}); | ||
return data; | ||
})); | ||
}; | ||
return store.subscribe(() => { | ||
const nextResourceState = selectResource(store.getState(), { | ||
resourceId | ||
}); | ||
return function () { | ||
for (var _len2 = arguments.length, args = new Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { | ||
args[_key2] = arguments[_key2]; | ||
} | ||
if (nextResourceState !== currentState) { | ||
currentState = nextResourceState; | ||
const entryKey = createEntryKey(args); | ||
const _useContext = useContext(Context), | ||
registerError = _useContext.registerError; | ||
const _useState = useState(selectEntry(entryKey)), | ||
entry = _useState[0], | ||
setEntry = _useState[1]; | ||
const data = selectData(entry); | ||
const hasSubscription = selectHasSubscription(entry); | ||
const cacheInvalid = selectUpdatedAt(entry).map(updatedAt => updatedAt + invalidateAfter < Date.now()).getOrElse(true); | ||
useEffect(() => { | ||
return store.subscribe(() => { | ||
const nextEntry = selectEntry(entryKey); | ||
if (nextEntry !== entry) { | ||
setEntry(nextEntry); | ||
_onChange(); | ||
} | ||
}); | ||
}, [entryKey]); // We only load on the first render if the cache is invalid | ||
const renderCount = useRef(0); | ||
renderCount.current = renderCount.current + 1; | ||
if (renderCount.current === 1 && cacheInvalid && !loadTasks.has(entryKey)) { | ||
load(...args).catch(registerError); | ||
} // We only suspend while the initial load is outstanding | ||
if (cacheInvalid && loadTasks.has(entryKey)) { | ||
throw loadTasks.get(entryKey); | ||
} | ||
const setCache = useCallback(valueOrUpdate => { | ||
const newData = typeof valueOrUpdate == "function" ? valueOrUpdate(data) : valueOrUpdate; | ||
store.dispatch({ | ||
type: RECEIVE_DATA, | ||
now: Date.now(), | ||
data: newData, | ||
entryKey, | ||
resourceId | ||
}); | ||
return newData; | ||
}, [data]); | ||
const actions = { | ||
refresh: useCallback(() => load(...args).catch(registerError), []), | ||
setCache, | ||
deleteCache: useCallback(() => { | ||
store.dispatch({ | ||
type: DELETE_ENTRY, | ||
entryKey, | ||
resourceId | ||
}); | ||
return data; | ||
}, [data]), | ||
remoteSave: useCallback(newData => saveTasks.run(entryKey, () => save(newData)), []), | ||
remoteDelete: useCallback(() => deleteTasks.run(entryKey, () => destroy(data)), [data]), | ||
subscribe: useCallback(() => { | ||
// we only want one subscription running for each entry at a time | ||
if (hasSubscription) { | ||
return; | ||
} | ||
store.dispatch({ | ||
type: SUBSCRIPTION_STARTED, | ||
entryKey, | ||
resourceId | ||
}); | ||
const cleanup = subscribe(setCache)(...args); | ||
return () => { | ||
cleanup(); | ||
store.dispatch({ | ||
type: SUBSCRIPTION_ENDED, | ||
entryKey, | ||
resourceId | ||
}); | ||
}; | ||
}, [hasSubscription]) | ||
}; | ||
return [selectData(entry), actions]; | ||
}, | ||
dispatch: action => store.dispatch(_extends({}, action, { | ||
resourceId | ||
})) | ||
}; | ||
}; | ||
const Context = createContext({ | ||
registerError: () => {} | ||
}); | ||
const RemoteResourceBoundary = (_ref) => { | ||
@@ -233,41 +197,98 @@ let children = _ref.children, | ||
/** | ||
* Makes a resource "optimistic". | ||
*/ | ||
const useOptimism = (_ref) => { | ||
let data = _ref[0], | ||
actions = _ref[1]; | ||
return [data, // Save. Updates cache first then saves to the remote | ||
function () { | ||
actions.setCache(...arguments); | ||
return actions.remoteSave(...arguments); | ||
}, // Delete. Deletes the cache first then deletes from the remote | ||
function () { | ||
actions.deleteCache(...arguments); | ||
return actions.remoteDelete(...arguments); | ||
}]; | ||
const useResourceActions = function useResourceActions(resource, args) { | ||
if (args === void 0) { | ||
args = []; | ||
} | ||
const entryId = resource.createEntryId(...args); | ||
const _useContext = useContext(Context), | ||
registerError = _useContext.registerError; | ||
const data = resource.getEntry(entryId).chain(entry => entry.data).getOrElse(resource.initialValue); | ||
const actions = { | ||
set: useCallback(nextData => { | ||
resource.dispatch({ | ||
type: RECEIVE_ENTRY_DATA, | ||
entryId, | ||
data: typeof nextData === "function" ? nextData(data) : nextData, | ||
now: Date.now() | ||
}); | ||
}, [data]), | ||
refresh: () => resource.load(...args).then(data => { | ||
resource.dispatch({ | ||
type: RECEIVE_ENTRY_DATA, | ||
entryId, | ||
data, | ||
now: Date.now() | ||
}); | ||
}).catch(error => { | ||
registerError(error); | ||
resource.dispatch({ | ||
type: LOADING_ENTRY_FAILED, | ||
entryId | ||
}); | ||
}) | ||
}; | ||
if (resource.save) { | ||
actions.save = resource.save; | ||
} | ||
if (resource.delete) { | ||
actions.delete = resource.delete; | ||
} | ||
return actions; | ||
}; | ||
/** | ||
* Makes a resource "pessimistic". | ||
*/ | ||
const usePessimism = (_ref) => { | ||
let data = _ref[0], | ||
actions = _ref[1]; | ||
return [data, { | ||
refresh: actions.refresh, | ||
// Saves to the remote first, then if it succeeds it updates the cache | ||
save: function save() { | ||
return actions.remoteSave(...arguments).then(actions.setCache); | ||
}, | ||
// Deletes from the remote first, then if it succeeds it deletes the cache | ||
delete: function _delete() { | ||
return actions.remoteDelete(...arguments).then(actions.deleteCache); | ||
} | ||
}]; | ||
const useFirstRender = () => { | ||
const renderCount = useRef(0); | ||
renderCount.current = renderCount.current + 1; | ||
return renderCount.current === 1; | ||
}; | ||
const useSubscribe = resource => { | ||
useEffect(resource.actions.subscribe, [resource.actions]); | ||
return resource; | ||
const useResourceState = function useResourceState(resource, args) { | ||
if (args === void 0) { | ||
args = []; | ||
} | ||
const entryId = resource.createEntryId(...args); | ||
const _useState = useState(resource.getEntry(entryId)), | ||
maybeEntry = _useState[0], | ||
setEntry = _useState[1]; | ||
const data = maybeEntry.chain(entry => entry.data).getOrElse(resource.initialValue); | ||
const cacheInvalid = maybeEntry.map(entry => entry.updatedAt + resource.invalidateAfter < Date.now()).getOrElse(data === resource.initialValue); | ||
const loadPromise = maybeEntry.chain(entry => entry.loadPromise).getOrElse(null); | ||
const actions = useResourceActions(resource, args); | ||
useEffect(() => // Important! The return value is used to unsubsribe from the store when necessary. | ||
resource.onChange(() => { | ||
setEntry(resource.getEntry(entryId)); | ||
}), [entryId]); | ||
const isFirstRender = useFirstRender(); | ||
if (isFirstRender && cacheInvalid && !loadPromise) { | ||
const promise = actions.refresh(); // We need to store the promise so that if the component gets re-mounted | ||
// while the promise is pending we have the ability to throw it. | ||
resource.dispatch({ | ||
type: LOADING_ENTRY, | ||
entryId, | ||
promise | ||
}); | ||
throw promise; | ||
} else if (loadPromise) { | ||
throw loadPromise; | ||
} | ||
return [data, useCallback(nextData => { | ||
resource.dispatch({ | ||
type: RECEIVE_ENTRY_DATA, | ||
entryId, | ||
data: typeof nextData === "function" ? nextData(data) : nextData, | ||
now: Date.now() | ||
}); | ||
}, [data])]; | ||
}; | ||
@@ -300,2 +321,2 @@ | ||
export { createRemoteResource, RemoteResourceBoundary, useOptimism, usePessimism, useSubscribe, useSuspense }; | ||
export { createRemoteResource, RemoteResourceBoundary, useResourceState, useResourceActions, useSuspense }; |
@@ -1,1 +0,1 @@ | ||
export * from "./src"; | ||
module.exports = require("./dist/index.cjs.js"); |
{ | ||
"name": "react-remote-resource", | ||
"version": "0.2.3", | ||
"version": "1.0.0", | ||
"description": "Intuitive remote data management in React", | ||
"main": "dist/index.cjs.js", | ||
"main": "index.js", | ||
"module": "dist/index.js", | ||
@@ -18,3 +18,4 @@ "repository": "https://github.com/chadwatson/react-remote-resource", | ||
"immutable": "^4.0.0-rc.12", | ||
"redux": "^4.0.1" | ||
"redux": "^4.0.1", | ||
"uuid": "^3.3.2" | ||
}, | ||
@@ -24,2 +25,3 @@ "devDependencies": { | ||
"@babel/plugin-proposal-class-properties": "^7.4.0", | ||
"@babel/plugin-transform-runtime": "^7.4.0", | ||
"@babel/preset-env": "^7.4.1", | ||
@@ -42,4 +44,2 @@ "@babel/preset-react": "^7.0.0", | ||
"prettier": "^1.16.4", | ||
"react": "^16.8.4", | ||
"react-dom": "^16.8.4", | ||
"react-testing-library": "^6.0.1", | ||
@@ -49,3 +49,5 @@ "rollup": "^1.4.0", | ||
"rollup-plugin-node-resolve": "^4.0.1", | ||
"rollup-plugin-size-snapshot": "^0.8.0" | ||
"rollup-plugin-replace": "^2.1.1", | ||
"rollup-plugin-size-snapshot": "^0.8.0", | ||
"rollup-plugin-uglify": "^6.0.2" | ||
}, | ||
@@ -52,0 +54,0 @@ "peerDependencies": { |
274
README.md
@@ -7,4 +7,8 @@ # react-remote-resource | ||
Creating a remote resource gives you a React hook that can be used throughout your application. Whenever a component using the hook mounts the resource will pull the data from the internal cache and check if it is still valid by checking the `invalidateAfter` option (5 minutes by default) against the last time the cache was updated for that entry. If there is no data for that entry or the cache is invalid then the `load` function will be invoked and then thrown. If you have a `RemoteResourceBoundary` somewhere higher up in the component tree then it will catch the thrown promise (using `Suspsense` under the hood) and render the `fallback` until all promises resolve. If a promise rejects then the `RemoteResourceBoundary` will catch it and call `renderError` and `onLoadError` (if provided). This gives you an intuitive way to get and use data throughout your app without over-fetching. You also save yourself a lot of time and headache implementing these kinds of things in Redux or some other data management library. | ||
react-remote-resource simplifies the integration of remote resources, usually api endpoints, into React applications, reducing boilerplate and data over-fetching. | ||
## How does it work? | ||
Whenever a resource is used it will check the internal cache for a valid data entry. If a valid entry is found, the resource will return the data from the cache and the data is ready to use. If no valid entry is found, the `load` function, which returns a Promise, will be invoked and thrown. The nearest `RemoteResourceBoundary`, using `Suspsense` under the hood, will catch the Promise and render the `fallback` until all outstanding Promises resolve. If any of the Promises reject, the `RemoteResourceBoundary` calls `renderError` and `onLoadError` (if provided) otherwise it returns the `children`. This provides an intuitive way to use data from remote resources throughout your app without over-fetching, or the headache and boilerplate of Redux or some other data management library. | ||
## Getting Started | ||
@@ -23,7 +27,7 @@ | ||
createRemoteResource, | ||
useResourceState, | ||
RemoteResourceBoundary | ||
} from "react-remote-resource"; | ||
const useUserInfo = createRemoteResource({ | ||
id: "user-info", | ||
const userResource = createRemoteResource({ | ||
load: userId => fetchJson(`/api/users/${userId}`), | ||
@@ -33,4 +37,3 @@ invalidateAfter: 60 * 60 * 1000 // 1 hour | ||
const useTweets = createRemoteResource({ | ||
id: "tweets", | ||
const tweetsResource = createRemoteResource({ | ||
load: userId => fetchJson(`/api/users/${userId}/tweets`), | ||
@@ -41,4 +44,4 @@ invalidateAfter: 10000 // 10 seconds | ||
const UserInfo = ({ userId }) => { | ||
const [user] = useUserInfo(userId); | ||
const [tweets] = useTweets(userId); | ||
const [user] = useResourceState(userResource, [userId]); | ||
const [tweets] = useResourceState(tweetsResource, [userId]); | ||
@@ -57,3 +60,4 @@ return ( | ||
const Tweets = ({ userId }) => { | ||
const [tweets] = useTweets(userId); | ||
const [tweets] = useResourceState(tweetsResource, [userId]); | ||
return ( | ||
@@ -90,12 +94,9 @@ <ul> | ||
A function that takes a config object and returns a React hook. | ||
A function that takes a config object and returns a resource. | ||
```jsx | ||
const loadProduct = id => fetch(`/api/products/${id}`).then(response => response.json()); | ||
const useProduct = createRemoteResource({ | ||
// Required: Unique identifier for the cache | ||
id: "product", | ||
// Required: A Promise-returing function that resolves with the data or rejects if fails | ||
load: loadProduct, | ||
load: id => fetch(`/api/products/${id}`).then(response => response.json()), | ||
// Optional: A Promise-returing function that resolves with the data or rejects if fails | ||
@@ -106,45 +107,116 @@ // Default: () => Promise.resolve() | ||
: fetch("/api/products", { method: "POST", body: JSON.stringify(product) }).then(response => response.json()), | ||
// Optional: A Promise-returing function | ||
// Optional: A Promise-returning function | ||
// Default: () => Promise.resolve() | ||
delete: product => fetch(`/api/products/${product.id}`, { method: "DELETE" }), | ||
// Optional: A function that receives an `onUpdate` function and returns a function that takes in the same arguments as `useProduct` when it is used in a component. This function can optionally return another function to clean up when a component either re-renders or unmounts. Use `onUpdate` to update the cache with the new data. | ||
// Default: () => () => {} | ||
subscribe: onUpdate => (productId) => { | ||
const interval = setInterval(() => { | ||
loadProduct(productId).then(onUpdate); | ||
}, 3000); | ||
return () => { | ||
clearInterval(interval); | ||
}; | ||
}, | ||
// Optional: The amount of time in milliseconds since the last update in which the cache is considered stale. | ||
// Default: 300000 (5 minutes) | ||
invalidateAfter: 10000, | ||
// Optional: A function that creates an entry key from the arguments given to the hook | ||
// Optional: A function that creates an entry id from the arguments given to the hook | ||
// Default: args => args.join("-") || "INDEX" | ||
createEntryKey: id => id.toString().toUpperCase() | ||
createEntryId: id => id.toString().toUpperCase(), | ||
// Optional: The value to fall back to if no data has been fetched | ||
initialValue: [] | ||
}); | ||
``` | ||
The returned hook will return a tuple, similar to React's `useState`, where the first item is the value of the resource, and the second item is an object of actions: `refresh`, `setCache`, `deleteCache`, `remoteSave`, and `remoteDelete`. | ||
#### Resource | ||
The return value from `createRemoteResource` has the following shape: | ||
```ts | ||
{ | ||
id: string, | ||
createEntryId: (...args: Array<any>) => string, | ||
initialValue: any, | ||
invalidateAfter: number, | ||
load: (...args: Array<any>) => Promise<any>, | ||
save: (...args: Array<any>) => Promise<any>, | ||
delete: (...args: Array<any>) => Promise<any>, | ||
getEntry: string => Immutable.RecordOf<{ | ||
id: string, | ||
data: Maybe<any>, | ||
updatedAt: Maybe<number>, // Unix timestamp | ||
loadPromise: Maybe<Promise<any>> | ||
}>, | ||
onChange: (() => void) => void, // Allows for subscribing to state changes. Basically a wrapper around store.subscribe. | ||
dispatch: ({ type: string }) => void // store.dispatch that adds `resourceId` to the action payload | ||
}; | ||
``` | ||
### `useResourceState` | ||
A React hook that takes a resource and an optional array of arguments and returns a tuple, very much like React's `useState`. The second item in the tuple works like `useState` in that it sets the in-memory state of the resource. Unlike `useState`, however, the state is not local to the component. Any other components that are using the state of that same resource get updated immediately with the new state! Under the hood `react-remote-resource` implements a redux store. Every resource get its own state and there can be multiple entries for each resource, depending on the arguments you pass into `useResourceState` and the optional `createEntryId` option that you can pass into `createRemoteResource`. By default every new combination of arguments will create a new entry in the store. | ||
```jsx | ||
const [product, actions] = useProduct(productId); | ||
import { useResourceState } from "react-remote-resource"; | ||
// Bypasses the cache and calls `load` again but without throwing the promise. Returns the promise from `load`. | ||
actions.refresh(); | ||
const ProductCategory = ({ categoryId }) => { | ||
const [categoriesById] = useResourceState(categoriesResource); | ||
const [products] = useResourceState(productsResource, [categoryId]); | ||
// Calls the `save` function if provided. Only calls `save` once as long as the promise is not resolved. Returns the promise from `save`. | ||
actions.remoteSave(updatedProduce); | ||
return ( | ||
<section> | ||
<header> | ||
<h1>{categoriesById[categoryId].name}</h1> | ||
</header> | ||
<ul> | ||
{products.map(product => ( | ||
<li key={product.id}> | ||
<Link to={`/products/${product.id}`}>{product.name}</Link> | ||
</li> | ||
))} | ||
</ul> | ||
</section> | ||
); | ||
}; | ||
``` | ||
// Calls the `delete` function if provided. Only calls `delete` once as long as the promise is not resolved. Returns the promise from `delete`. | ||
actions.remoteDelete(); | ||
This hook is very powerful. Let's walk through what happens when it is used: | ||
// Sets the value for this entry in the cache and stores the time it was updated. All components using the hook will be re-rendered with the new value. Can optionally take an updater function that receives the current value. | ||
actions.setCache({ ...product, tags: product.tags.concat("shoes") }); | ||
actions.setCache(product => ({ ...product, tags: product.tags.concat("shoes") }); | ||
1. If there is no data for this resource OR no entry in the cache for the arguments (See [invalidateAfter](https://github.com/chadwatson/react-remote-resource/blob/master/README.md#createremoteresource)) OR the cache is stale (See [invalidateAfter](https://github.com/chadwatson/react-remote-resource/blob/master/README.md#createremoteresource)), then the `load` function of the `categoriesResource` and `productsResource` will be invoked and promises thrown. | ||
2. If either of the promises reject, the closest `RemoteResourceBoundary` will handle the error. If both promises resolve, the `categoriesById` and `products` data will be available to use (as the first item in the tuple). | ||
3. You can set the state using the second item in the tuple. Resource state changes, unlike component based `useState`, will persist in memory. If a component unmounts and remounts the state will be the same as when you left it. | ||
// Removes the entry from the cache. | ||
actions.deleteCache(); | ||
### `useResourceActions` | ||
A React hook that takes a resource and an optional array of arguments and returns an object literal with the following methods: | ||
- `set`: A function that takes either the new state to set for this entry or a function that takes the current state of the entry and returns what should be set as the new state for the entry. | ||
- `refresh`: A function that allows you to bypass the check against the `updatedAt` timestamp and immediately refetch the data. _Note: the promise will **not** be thrown for this action._ | ||
- `save`: The `save` function that was defined with `createRemoteResource`. _Note: this will be `undefined` if you did not define a `save` function with `createRemoteResource`._ | ||
- `delete`: The `delete` function that was defined with `createRemoteResource`. _Note: this will be `undefined` if you did not define a `delete` function with `createRemoteResource`._ | ||
Note: the array of arguments that are provided as the second argument will be spread as the initial arguments to the `save` and `delete` actions. For example, for the actions below, both `save` and `delete` inside of actions would receive `categoryId` as the first parameter when invoked. | ||
```jsx | ||
import { useResourceState, useResourceActions } from "react-remote-resource"; | ||
const ProductCategory = ({ categoryId }) => { | ||
const [categoriesById] = useResourceState(categoriesState); | ||
const [products] = useResourceState(productsResource, [categoryId]); | ||
const actions = useResourceActions(productsResource, [categoryId]); | ||
return ( | ||
<section> | ||
<header> | ||
<h1>{categoriesById[categoryId].name}</h1> | ||
<button onClick={actions.refresh}>Refresh Products</button> | ||
</header> | ||
<ul> | ||
{products.map(product => ( | ||
<li key={product.id}> | ||
<Link to={`/products/${product.id}`}>{product.name}</Link> | ||
</li> | ||
))} | ||
</ul> | ||
</section> | ||
); | ||
}; | ||
``` | ||
@@ -184,4 +256,4 @@ | ||
```jsx | ||
import { useSuspense } from "react-remote-resource"; | ||
import useUser from "../resources/user"; | ||
import { useSuspense, useResourceActions } from "react-remote-resource"; | ||
import userResource from "../resources/user"; | ||
@@ -193,3 +265,3 @@ const SaveButton = ({ onClick }) => ( | ||
const UserForm = () => { | ||
const [user, actions] = useUser(); | ||
const actions = useResourceActions(userResource); | ||
return ( | ||
@@ -199,3 +271,3 @@ <div> | ||
<Suspense fallback={<p>Saving...</p>}> | ||
<SaveButton onClick={actions.remoteSave} /> | ||
<SaveButton onClick={actions.save} /> | ||
</Suspense> | ||
@@ -206,115 +278,1 @@ </div> | ||
``` | ||
### `useOptimism` | ||
A hook that takes a resource and returns a tuple with the current value as the first item, a save action as the second item, and a delete action as the third item. `save` and `delete` take an optimistic approach, meaning they update the cache first then call `remoteSave` and `remoteDelete`, respectively, in the background. Both of these return the promise from `save` and `delete` so that you can handle failures as needed. | ||
This approach allows you to treat the cache as the source of the truth and provide a snappy UI. | ||
```jsx | ||
import { useOptimism } from "react-remote-resource"; | ||
import useCart from "../resources/cart"; | ||
const CartContainer = ({ id }) => { | ||
const [cart, saveCart, deleteCart] = useOptimism(useCart()); | ||
return <Cart cart={cart} onChange={saveCart} onCancel={deleteCart} />; | ||
}; | ||
``` | ||
### `usePessimism` | ||
A hook that takes a resource and returns a tuple with the current value as the first item and an object of actions as the second item: `refresh`, `save`, and `delete`. `save` and `delete` call `remoteSave` and `remoteDelete` first, respectively. The cache will be updated if they succeed. | ||
This approach allows you to treat your remote data source as the source of truth. Use this when you expect a `remoteSave` or `remoteDelete` to either completely fail or respond with validation errors of some sort. | ||
```jsx | ||
import { useState, Suspense } from "react"; | ||
import { usePessimism, useSuspense } from "react-remote-resource"; | ||
import useUser from "../resources/user"; | ||
const SaveButton = ({ onClick }) => ( | ||
<button onClick={useSuspense(onClick)}>Save</button> | ||
); | ||
const AccountInfoForm = () => { | ||
const [user, actions] = usePessimism(useUser()); | ||
const [errors, setErrors] = useState([]); | ||
return ( | ||
<div> | ||
{!!errors.length && ( | ||
<section> | ||
<header> | ||
<h1>Some errors occurred</h1> | ||
</header> | ||
<ul> | ||
{errors.map((error, index) => ( | ||
<li key={index}>{error}</li> | ||
))} | ||
</ul> | ||
</section> | ||
)} | ||
<label> | ||
First Name* | ||
<input type="text" value={user.firstName} /> | ||
</label> | ||
<label> | ||
Last Name* | ||
<input type="text" value={user.lastName} /> | ||
</label> | ||
<label> | ||
Email* | ||
<input type="email" value={user.email} /> | ||
</label> | ||
<Suspense fallback={<p>Saving...</p>}> | ||
<SaveButton onClick={data => actions.save(data).catch(setErrors)} /> | ||
</Suspense> | ||
</div> | ||
); | ||
}; | ||
``` | ||
### `useSubscribe` | ||
A hook that takes a resource and passes its `subscribe` action to `useEffect` and returns the resource. This allows for real-time updates to the entry data. | ||
```jsx | ||
import { createRemoteResource, useSubscribe } from "react-remote-resource"; | ||
const loadComments = postId => | ||
fetch(`/api/posts/${postId}/comments`).then(response => response.json()); | ||
export const useComments = createRemoteResource({ | ||
id: "comments", | ||
load: loadComments, | ||
subscribe: onUpdate => postId => { | ||
// poll for new comments every three seconds | ||
const interval = setInterval(() => { | ||
loadComments(postId).then(onUpdate); | ||
}, 3000); | ||
return () => { | ||
clearInterval(interval); | ||
}; | ||
} | ||
}); | ||
const PostComments = ({ postId }) => { | ||
const [comments] = useSubscribe(useComments(postId)); | ||
return ( | ||
<aside> | ||
<header> | ||
<h1>Comments</h1> | ||
</header> | ||
<ul> | ||
{comments.map(comment => ( | ||
<li key={comment.id}> | ||
<h2>{comment.author}</h2> | ||
<p>{comment.content}</p> | ||
</li> | ||
))} | ||
</ul> | ||
</aside> | ||
); | ||
}; | ||
``` |
@@ -8,3 +8,3 @@ import path from "path"; | ||
const external = id => !id.startsWith(".") && !id.startsWith(root); | ||
const extensions = [".js"]; | ||
const extensions = [".js", ".jsx", ".json"]; | ||
const getBabelOptions = ({ useESModules }, targets) => ({ | ||
@@ -22,3 +22,4 @@ babelrc: false, | ||
["@babel/plugin-proposal-object-rest-spread", { loose: true }], | ||
["transform-react-remove-prop-types", { removeImport: true }] | ||
["transform-react-remove-prop-types", { removeImport: true }], | ||
["@babel/plugin-transform-runtime", { regenerator: false, useESModules }] | ||
] | ||
@@ -25,0 +26,0 @@ }); |
@@ -1,182 +0,35 @@ | ||
import { useState, useEffect, useCallback, useRef, useContext } from "react"; | ||
import { createStore } from "redux"; | ||
import { Map as ImmutableMap } from "immutable"; | ||
import Maybe from "data.maybe"; | ||
import Context from "./Context"; | ||
import createTaskManager from "./create-task-manager"; | ||
import uuid from "uuid/v1"; | ||
import store, { selectEntry, selectResource } from "./store"; | ||
const REGISTER_RESOURCE = "REGISTER_RESOURCE"; | ||
const RECEIVE_DATA = "RECEIVE_DATA"; | ||
const DELETE_ENTRY = "DELETE_ENTRY"; | ||
const SUBSCRIPTION_STARTED = "SUBSCRIPTION_STARTED"; | ||
const SUBSCRIPTION_ENDED = "SUBSCRIPTION_ENDED"; | ||
const store = createStore((state = ImmutableMap(), action) => { | ||
switch (action.type) { | ||
case REGISTER_RESOURCE: | ||
return state.set(action.resourceId, ImmutableMap()); | ||
case RECEIVE_DATA: | ||
return state.setIn( | ||
[action.resourceId, action.entryKey], | ||
ImmutableMap({ | ||
updatedAt: action.now, | ||
data: action.data, | ||
hasSubscription: false | ||
}) | ||
); | ||
case SUBSCRIPTION_STARTED: | ||
return state.setIn( | ||
[action.resourceId, action.entryKey, "hasSubscription"], | ||
true | ||
); | ||
case SUBSCRIPTION_ENDED: | ||
return state.setIn( | ||
[action.resourceId, action.entryKey, "hasSubscription"], | ||
false | ||
); | ||
case DELETE_ENTRY: | ||
return state.deleteIn([action.resourceId, action.entryKey]); | ||
default: | ||
return state; | ||
} | ||
}); | ||
const defaultCreateCacheKey = args => args.join("-") || "INDEX"; | ||
const createRemoteResource = ({ | ||
id: resourceId, | ||
load: loader = () => Promise.resolve(), | ||
save = () => Promise.resolve(), | ||
delete: destroy = () => Promise.resolve(), | ||
subscribe = () => () => {}, | ||
load, | ||
save, | ||
delete: destroy, | ||
initialValue = null, | ||
invalidateAfter = 300000, | ||
createEntryKey = defaultCreateCacheKey | ||
createEntryId = (...args) => args.join("-") || "INDEX" | ||
}) => { | ||
store.dispatch({ type: REGISTER_RESOURCE, resourceId }); | ||
const loadTasks = createTaskManager(); | ||
const saveTasks = createTaskManager(); | ||
const deleteTasks = createTaskManager(); | ||
const selectEntry = entryKey => | ||
Maybe.fromNullable(store.getState().getIn([resourceId, entryKey])); | ||
const selectData = maybeEntry => | ||
maybeEntry.map(state => state.get("data")).getOrElse(initialValue); | ||
const selectUpdatedAt = maybeEntry => | ||
maybeEntry.map(state => state.get("updatedAt")); | ||
const selectHasSubscription = maybeEntry => | ||
maybeEntry.map(state => state.get("hasSubscription")).getOrElse(false); | ||
const load = (...args) => { | ||
const entryKey = createEntryKey(args); | ||
return loadTasks.run(entryKey, () => | ||
loader(...args).then(data => { | ||
store.dispatch({ | ||
type: RECEIVE_DATA, | ||
now: Date.now(), | ||
entryKey: createEntryKey(args), | ||
data, | ||
const resourceId = uuid(); | ||
return { | ||
id: resourceId, | ||
createEntryId, | ||
initialValue, | ||
invalidateAfter, | ||
load, | ||
save, | ||
delete: destroy, | ||
getEntry: entryId => selectEntry(store.getState(), { resourceId, entryId }), | ||
onChange: onChange => { | ||
let currentState = selectResource(store.getState(), { resourceId }); | ||
return store.subscribe(() => { | ||
const nextResourceState = selectResource(store.getState(), { | ||
resourceId | ||
}); | ||
return data; | ||
}) | ||
); | ||
}; | ||
return (...args) => { | ||
const entryKey = createEntryKey(args); | ||
const { registerError } = useContext(Context); | ||
const [entry, setEntry] = useState(selectEntry(entryKey)); | ||
const data = selectData(entry); | ||
const hasSubscription = selectHasSubscription(entry); | ||
const cacheInvalid = selectUpdatedAt(entry) | ||
.map(updatedAt => updatedAt + invalidateAfter < Date.now()) | ||
.getOrElse(true); | ||
useEffect(() => { | ||
return store.subscribe(() => { | ||
const nextEntry = selectEntry(entryKey); | ||
if (nextEntry !== entry) { | ||
setEntry(nextEntry); | ||
if (nextResourceState !== currentState) { | ||
currentState = nextResourceState; | ||
onChange(); | ||
} | ||
}); | ||
}, [entryKey]); | ||
// We only load on the first render if the cache is invalid | ||
const renderCount = useRef(0); | ||
renderCount.current = renderCount.current + 1; | ||
if (renderCount.current === 1 && cacheInvalid && !loadTasks.has(entryKey)) { | ||
load(...args).catch(registerError); | ||
} | ||
// We only suspend while the initial load is outstanding | ||
if (cacheInvalid && loadTasks.has(entryKey)) { | ||
throw loadTasks.get(entryKey); | ||
} | ||
const setCache = useCallback( | ||
valueOrUpdate => { | ||
const newData = | ||
typeof valueOrUpdate == "function" | ||
? valueOrUpdate(data) | ||
: valueOrUpdate; | ||
store.dispatch({ | ||
type: RECEIVE_DATA, | ||
now: Date.now(), | ||
data: newData, | ||
entryKey, | ||
resourceId | ||
}); | ||
return newData; | ||
}, | ||
[data] | ||
); | ||
const actions = { | ||
refresh: useCallback(() => load(...args).catch(registerError), []), | ||
setCache, | ||
deleteCache: useCallback(() => { | ||
store.dispatch({ | ||
type: DELETE_ENTRY, | ||
entryKey, | ||
resourceId | ||
}); | ||
return data; | ||
}, [data]), | ||
remoteSave: useCallback( | ||
newData => saveTasks.run(entryKey, () => save(newData)), | ||
[] | ||
), | ||
remoteDelete: useCallback( | ||
() => deleteTasks.run(entryKey, () => destroy(data)), | ||
[data] | ||
), | ||
subscribe: useCallback(() => { | ||
// we only want one subscription running for each entry at a time | ||
if (hasSubscription) { | ||
return; | ||
} | ||
store.dispatch({ | ||
type: SUBSCRIPTION_STARTED, | ||
entryKey, | ||
resourceId | ||
}); | ||
const cleanup = subscribe(setCache)(...args); | ||
return () => { | ||
cleanup(); | ||
store.dispatch({ | ||
type: SUBSCRIPTION_ENDED, | ||
entryKey, | ||
resourceId | ||
}); | ||
}; | ||
}, [hasSubscription]) | ||
}; | ||
return [selectData(entry), actions]; | ||
}, | ||
dispatch: action => store.dispatch({ ...action, resourceId }) | ||
}; | ||
@@ -183,0 +36,0 @@ }; |
export { default as createRemoteResource } from "./create-remote-resource"; | ||
export { default as RemoteResourceBoundary } from "./RemoteResourceBoundary"; | ||
export { default as useOptimism } from "./use-optimism"; | ||
export { default as usePessimism } from "./use-pessimism"; | ||
export { default as useSubscribe } from "./use-subscribe"; | ||
export { default as useResourceState } from "./use-resource-state"; | ||
export { default as useResourceActions } from "./use-resource-actions"; | ||
export { default as useSuspense } from "./use-suspense"; |
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
No v1
QualityPackage is not semver >=1. This means it is not stable and does not support ^ ranges.
Found 1 instance in 1 package
1471
1
335099
7
27
29
268
+ Addeduuid@^3.3.2
+ Addeduuid@3.4.0(transitive)