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
createAsyncResourceBundle
with 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-resources
Usage
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,
expireAfter: 60 * 60000,
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'
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}IsPendingForFetch
for a failed request will turn back on. Falsie value or Infinity
will 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}IsPendingForFetch
back on). Falsie value or Infinity
will disable staling mechanism. - expireAfter (
Infinity
): similar to staleAfter
but 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
cacheBundle
to cache on meaningful updates. - dependencyKey (null): when given, will listen for values of related selectors:
- as an example, dependency key
userId
will listen to selector selectUserId
- when dependency selector resolves with
null
or undefined
, it will prevent resource from fetching - when dependency selector resolves to a value, this value will be mixed-in into
getPromise
parameters - when resolved value changes, bundle will force-clear itself
- example values used in most cases:
currentUserId
or ['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 is null
or undefined
- equality: (===) - for rare cases when it is needed to override equality check which decides whether dependency value changed or not
Selectors
select${Name}Raw
– just get raw bundle state, to be used internallyselect${Name}
– returns item data or undefined
if there's nothing thereselect${Name}IsPresent
– returns true
if there is something to be returned by select${Name}
(i.e. there was at least one successful load before)select${Name}IsLoading
– returns true
if item is currently loading (irrelevant of whether there is some data or not in select${Name}
)select${Name}IsPendingForFetch
– returns true
if resource thinks it should load now (i.e. pristine or stale or there was an error and retryAfter
has passed or dependencies were specified and changed)select${Name}Error
– returns whatever gerPromise
rejected with previously; reset to null
or new error value after next load is finishedselect${Name}IsReadyForRetry
– returns true
if previous fetch resulted in error and retryAfter
has passedselect${Name}RetryAt
– returns null
or a timestamp at which item fetch will be retriedselect${Name}ErrorIsPermanent
– returns true
if previous fetch resulted in error and error object had permanent
field onselect${Name}IsStale
– returns true
if 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 specified payload
. If payload
is 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,
expireAfter: 60 * 60000,
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] }
}
}
),
}
)
CurrentCarReviews.js
import React from 'react'
import { useConnect } from 'redux-bundler-hook'
import { asyncResources } from 'redux-bundler-async-resources'
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)
or asyncResources.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.itemIsPendingForFetch
for an item that has failed to fetch will turn back on. Falsie value or Infinity
will 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.itemIsPendingForFetch
back on). Falsie value or Infinity
will disable staling mechanism. - expireAfter (
Infinity
): similar to staleAfter
but 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 instruct cacheBundle
to cache on meaningful updates.
Selectors
select${Name}Raw
– as usual, just get raw bundle stateselectItemsOf${Name}
– returns a hash of { [itemId]: item }
; item
to be used with asyncResources
helpers 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 specified payload
. If payload
is 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 that getPromise
previously resolved with or undefined
if it didn't happen beforeitemIsPresent(item)
– true
if getItemData
is currently able to return some data to showitemIsLoading(item)
– true
if item is currently loading (irrelevant of whether it has some data or not, i.e. of itemIsPresent
/ getItemData
result)itemIsPendingForFetch(item, [{ isOnline = undefined }])
– true
if there are any of mentioned conditions are present that result in necessity to trigger doFetchItemOf${Name}
:
- either this item is in pristine state
- or it failed, retry is enabled and
retryAfter
has passed (and error is not permanent) - or it fetched and is stale (either manually or because
staleAfter
has passed) isOnline
is an optional check to not even try loading anything if device is offline; may omit if online check is not needed
getItemError(item)
– something that getPromise
previously rejected with. Will reset on when next fetch will finish (or fail).itemIsReadyForRetry(item)
– true
if this item contains an error, and retryAfter
has passed.itemRetryAt(item)
– returns a timestamp at which item fetch will be retried (if it will be, otherwise null
)itemErrorIsPermanent(item)
– true
if getPromise
has rejected with something that had persistent: true
property in it. Retry behavior will be disabled in this case.itemIsStale(item)
– true
if this item is stale (manually or because staleAfter
has 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"
}
}