@huygn/redux-bundler-async-resources v1.2.1
Redux-Bundler Async Resources
A bundle factory for redux-bundler that clearly manages remote resources.
Motivation
It is questionable that createAsyncResourceBundle should be a native part of redux-bundler in the first place. Either way, it's missing some features that are usually needed and usually re-implemented as extensions.
This package:
- re-implements
createAsyncResourceBundlewith a bit clearer semantics and few additional missing features - adds a new concept:
createAsyncResourcesBundle(note plural form). Instead of a single resource instance, it manages a collection of async resource instances referenced by ID. Each managed instance has it's own lifecycle in terms of loading, expiration etc.
Installation
npm install --save redux-bundler-async-resourcesUsage
If you use React, take a look at redux-bundler-async-resources-hooks
createAsyncResourceBundle
bundles/hotCarDeals.js
import { createSelector } from 'redux-bundler'
import { createAsyncResourceBundle } from 'redux-bundler-async-resources'
export default {
...createAsyncResourceBundle({
name: 'hotCarDeals',
staleAfter: 180000, // refresh every 3 minutes
expireAfter: 60 * 60000, // delete if not refreshed in an hour
getPromise: ({ shopApi }) => shopApi.fetchHotCarDeals(),
}),
reactShouldFetchHotCarDeals: createSelector(
'selectHotCarDealsIsPendingForFetch',
shouldFetch => {
if (shouldFetch) {
return { actionCreator: 'doFetchHotCarDeals' }
}
}
),
}HotCarDeals.js
import React from 'react'
import { useConnect } from 'redux-bundler-hook'
// ... other imports
export default function HotCarDeals() {
const { hotCarDeals, hotCarDealsError } = useConnect('selectHotCarDeals', 'selectHotCarDealsError')
if (!hotCarDeals && hotCarDealsError) {
return <ErrorMessage error={hotCarDealsError} />
}
if (!hotCarDeals) {
return <Spinner />
}
return <CarDealsList deals={hotCarDeals} />
}Options
- name (required): bundle name as usual
- getPromise (required): a function to get usual action creator context parameters; should return a promise that would either resolved with item data or rejected with an error.
- actionBaseType (toUnderscoreNotation(name)): a prefix to be used with internal action types
- retryAfter (60000 i.e. one minute): an interval after which an
select${Name}IsPendingForFetchfor a failed request will turn back on. Falsie value orInfinitywill disable retries. - staleAfter (900000 i.e. 15 minutes): an interval of time after which a successfully fetched item will try to refresh itself (e.g. turn
select${Name}IsPendingForFetchback on). Falsie value orInfinitywill disable staling mechanism. - expireAfter (
Infinity): similar tostaleAfterbut will hard-remove the item from the store, resetting it to pristine state. Useful with caching to to prevent app user to see really old data when re-opening the page. - persist (true): will instruct
cacheBundleto cache on meaningful updates. - dependencyKey (null): when given, will listen for values of related selectors:
- as an example, dependency key
userIdwill listen to selectorselectUserId - when dependency selector resolves with
nullorundefined, it will prevent resource from fetching - when dependency selector resolves to a value, this value will be mixed-in into
getPromiseparameters - when resolved value changes, bundle will force-clear itself
- example values used in most cases:
currentUserIdor['myResourceListPage', 'myResourceListPageSize']' - as shown above, to listen to several selectors, pass an array
- rather than a simple string, each selector can be represented as an object with additional parameters (i.e.
{ key: 'userId', staleOnChange: true '}):- staleOnChange: (false) - if
true, will stale a resource when dependency changes, rather than clearing the store - allowBlank: (false) – if
true, will not lock resource from fetching when resolved value isnullorundefined
- staleOnChange: (false) - if
- as an example, dependency key
Selectors
select${Name}Raw– just get raw bundle state, to be used internallyselect${Name}– returns item data orundefinedif there's nothing thereselect${Name}IsPresent– returnstrueif there is something to be returned byselect${Name}(i.e. there was at least one successful load before)select${Name}IsLoading– returnstrueif item is currently loading (irrelevant of whether there is some data or not inselect${Name})select${Name}IsPendingForFetch– returnstrueif resource thinks it should load now (i.e. pristine or stale or there was an error andretryAfterhas passed or dependencies were specified and changed)select${Name}Error– returns whatevergerPromiserejected with previously; reset tonullor new error value after next load is finishedselect${Name}IsReadyForRetry– returnstrueif previous fetch resulted in error andretryAfterhas passedselect${Name}RetryAt– returnsnullor a timestamp at which item fetch will be retriedselect${Name}ErrorIsPermanent– returnstrueif previous fetch resulted in error and error object hadpermanentfield onselect${Name}IsStale– returnstrueif item is stale (manually or respective interval has passed)
Action Creators
doFetch${Name}– trigger a fetchdoClear${Name}– force-clear a bundle and reset it to pristine statedoMark${Name}AsStale– force-mark resource as outdated. Will not remove item from the bundle, but will turn "refetch me!" flag on.doAdjust${Name}(payload)– if there is some data present, replace item data with specifiedpayload. Ifpayloadis a function, call it a with single parameter (current data value), and replace data with that it returns. Primary use case is when you have some mutation API calls to your resource that always render a predictable change of your resource properties – so you want to save up on re-fetching it and just update in place.
... some other selectors and action creators are present, though mostly technical and are needed for bundle functioning
createAsyncResourcesBundle
createStore.js
import { composeBundles, createSelector } from 'redux-bundler'
import { createAsyncResourcesBundle } from 'redux-bundler-async-resources'
export default composeBundles(
createAsyncResourcesBundle({
name: 'carReviews',
staleAfter: 60000, // refresh every a minute
expireAfter: 60 * 60000, // delete if not refreshed in an hour
getPromise: (carId, { shopApi }) => shopApi.fetchCarReviews(carId),
}),
{
name: 'currentCarReviews',
reducer: (state = null, action) => {
if (action.type === 'currentCarReviews.CHANGED') {
return action.payload
}
return state
},
selectCurrentCarReviewsRaw: state => state.currentCarReviews,
selectCurrentCarReviews: createSelector(
'selectCurrentCarReviewsRaw',
reviewsItem => asyncResources.getItemData(reviewsItem)
),
selectCurrentCarReviewsError: createSelector(
'selectCurrentCarReviewsRaw',
reviewsItem => asyncResources.getItemError(reviewsItem)
),
selectCurrentCarReviewsLoading: createSelector(
'selectCurrentCarReviewsRaw',
reviewsItem => asyncResources.itemIsLoading(reviewsItem)
),
reactCurrentCarReviewsChanged: createSelector(
'selectCurrentCarReviewsRaw',
'selectCurrentCarId',
'selectItemsOfCarReviews',
(prevReviewsItem, carId, carReviews) => {
const reviewsItem = carReviews[carId]
if (prevReviewsItem !== reviewsItem) {
return { type: 'currentCarReviews.CHANGED', payload: reviewsItem }
}
}
),
reactShouldFetchCurrentCarReviews: createSelector(
'selectCurrentCarId',
'selectItemsOfCarReviews',
'selectIsOnline',
(carId, carReviews, isOnline) => {
if (carId && asyncResources.itemIsPendingForFetch(carReviews[carId], { isOnline })) {
return { actionCreator: 'doFetchItemOfCarReviews', args: [carId] }
}
}
),
}
// ... other bundles of your application
)CurrentCarReviews.js
import React from 'react'
import { useConnect } from 'redux-bundler-hook'
import { asyncResources } from 'redux-bundler-async-resources'
// ... other imports
export default function CurrentCarReviews() {
const { currentCarReviews, currentCarReviewsError, currentCarReviewsLoading } = useConnect(
'selectCurrentCarReviews',
'selectCurrentCarReviewsError',
'selectCurrentCarReviewsLoading'
)
if (currentCarReviewsLoading) {
return <Spinner />
}
if (currentCarReviewsError) {
return <ErrorMessage error={currentCarReviewsError} />
}
return <ReviewList reviews={currentCarReviews} />
}Options
- name (required): bundle name as usual
- getPromise (required): a function to get item id as first parameter, and usual action creator context parameters as a second; should return a promise that would either resolved with item data or rejected with an error. In both cases result will appear as
asyncResources.getItemData(itemId)orasyncResources.getItemError(itemId) - actionBaseType (toUnderscoreNotation(name)): a prefix to be used with internal action types
- retryAfter (60000 i.e. one minute): an interval after which an
asyncResources.itemIsPendingForFetchfor an item that has failed to fetch will turn back on. Falsie value orInfinitywill disable retries. - staleAfter (900000 i.e. 15 minutes): an interval of time after which a successfully fetched item will try to refresh itself (e.g. turn
asyncResources.itemIsPendingForFetchback on). Falsie value orInfinitywill disable staling mechanism. - expireAfter (
Infinity): similar tostaleAfterbut will hard-remove the item from the store. Useful with caching to to prevent app user to see really old data when re-opening the page. - persist (true): same behavior as for
createAsyncResource– will instructcacheBundleto cache on meaningful updates.
Selectors
select${Name}Raw– as usual, just get raw bundle stateselectItemsOf${Name}– returns a hash of{ [itemId]: item };itemto be used withasyncResourceshelpers to get meaningful information from it.
Action Creators
doFetchItemOf${Name}(itemId)– trigger a fetch of a specific itemdoClearItemOf${Name}(itemId)– force-remove a certain item from the bundle, resetting it to pristine statedoMarkItemOf${Name}AsStale(itemId)– force-mark certain item as outdated. Will not remove item from the bundle, but will turn "refetch me!" flag on.doAdjustItemOf${Name}(itemId, payload)– if there is some data present, replace item data with specifiedpayload. Ifpayloadis a function, call it a with single parameter (current data value), and replace data with that it returns. Primary use case is when you have some mutation API calls to your resource that always render a predictable change of your resource properties – so you want to save up on re-fetching it and just update in place.
asyncResources helpers
getItemData(item)– will return anything thatgetPromisepreviously resolved with orundefinedif it didn't happen beforeitemIsPresent(item)–trueifgetItemDatais currently able to return some data to showitemIsLoading(item)–trueif item is currently loading (irrelevant of whether it has some data or not, i.e. ofitemIsPresent/getItemDataresult)itemIsPendingForFetch(item, [{ isOnline = undefined }])–trueif there are any of mentioned conditions are present that result in necessity to triggerdoFetchItemOf${Name}:- either this item is in pristine state
- or it failed, retry is enabled and
retryAfterhas passed (and error is not permanent) - or it fetched and is stale (either manually or because
staleAfterhas passed) isOnlineis an optional check to not even try loading anything if device is offline; may omit if online check is not needed
getItemError(item)– something thatgetPromisepreviously rejected with. Will reset on when next fetch will finish (or fail).itemIsReadyForRetry(item)–trueif this item contains an error, andretryAfterhas passed.itemRetryAt(item)– returns a timestamp at which item fetch will be retried (if it will be, otherwisenull)itemErrorIsPermanent(item)–trueifgetPromisehas rejected with something that hadpersistent: trueproperty in it. Retry behavior will be disabled in this case.itemIsStale(item)–trueif this item is stale (manually or becausestaleAfterhas passed since last successful fetch)
Naming helpers
In (rare) cases when you need to async resources in a resource-agnostic manner, there are two helpers available: makeAsyncResourceBundleKeys and makeAsyncResourcesBundleKeys for it's multi-item counterpart.
Calling this with a resource name will return you an object of the following shape (assuming resource name "myResource"):
(similar to)
{
"selectors": {
"raw": "selectMyResourceRaw",
"data": "selectMyResource",
"isLoading": "selectMyResourceIsLoading",
"isPresent": "selectMyResourceIsPresent",
"error": "selectMyResourceError",
"isReadyForRetry": "selectMyResourceIsReadyForRetry",
"errorIsPermanent": "selectMyResourceErrorIsPermanent",
"isStale": "selectMyResourceIsStale",
"isPendingForFetch": "selectMyResourceIsPendingForFetch"
},
"keys": {
"raw": "myResourceRaw",
"data": "myResource",
"isLoading": "myResourceIsLoading",
"isPresent": "myResourceIsPresent",
"error": "myResourceError",
"isReadyForRetry": "myResourceIsReadyForRetry",
"errorIsPermanent": "myResourceErrorIsPermanent",
"isStale": "myResourceIsStale",
"isPendingForFetch": "myResourceIsPendingForFetch"
},
"actionCreators": {
"doFetch": "doFetchMyResource",
"doClear": "doClearMyResource",
"doMarkAsStale": "doMarkMyResourceAsStale",
"doAdjust": "doAdjustMyResource"
},
"reactors": {
"shouldExpire": "reactMyResourceShouldExpire",
"shouldRetry": "reactMyResourceShouldRetry",
"shouldBecomeStale": "reactMyResourceShouldBecomeStale"
}
}... and for makeAsyncResourcesBundleKeys it will be similar to:
{
"selectors": {
"raw": "selectMyResourcesRaw",
"items": "selectItemsOfMyResources",
"nextExpiringItem": "selectNextExpiringItemOfMyResources",
"nextRetryingItem": "selectNextRetryingItemOfMyResources",
"nextStaleItem": "selectNextStaleItemOfMyResources"
},
"keys": {
"raw": "myResourcesRaw",
"items": "itemsOfMyResources",
"nextExpiringItem": "nextExpiringItemOfMyResources",
"nextRetryingItem": "nextRetryingItemOfMyResources",
"nextStaleItem": "nextStaleItemOfMyResources"
},
"actionCreators": {
"doFetch": "doFetchItemOfMyResources",
"doClear": "doClearItemOfMyResources",
"doMarkAsStale": "doMarkItemOfMyResourcesAsStale",
"doAdjust": "doAdjustItemOfMyResources"
},
"reactors": {
"shouldExpire": "reactItemOfMyResourcesShouldExpire",
"shouldRetry": "reactItemOfMyResourcesShouldRetry",
"shouldBecomeStale": "reactItemOfMyResourcesShouldBecomeStale"
}
}