- Thoughts on the PENDING Resource
- Implicit dependent resources
- Unfetched Resources
- Loading Overlays
- Recaching newly-saved models
Using dependsOn
in simple cases like the one highlighted in the README is pretty straightforward and very powerful. But PENDING
resources bring additional complexities to your resource logic, some of which are enumerated here:
-
PENDING
critical resources don’t contribute toisLoading
/hasErrored
states, but will keep your component from reaching ahasLoaded
state. Semantically, this makes sense, becausehasLoaded
should only be true when all critical resources have loaded, regardless of when a resource’s request is made. -
When a
PENDING
resource request is not in flight, its model prop will still be the empty model instance whose properties are frozen (as happens when the resource is in the other three possible loading states). This is to more predictably handle our resources in our components. We don’t need to be defensive with syntax like:todosCollection && todosCollection.toJSON(); // unnecessary
Furthermore, if you've defined additional instance methods on your model, they will be present without being defensive:
todosCollection.myInstanceMethod(); // guaranteed not to error regardless of loading state
-
When a previously-
PENDING
but currentlyLOADED
resource has its dependent prop removed, it goes back to aPENDING
state (recall that if the dependent prop is changed, it gets put back into aLOADING
state while the new resource is fetched). This puts us in an interesting state:-
hasInitiallyLoaded
will remain true, as expected. But thePENDING
resource’s prop—assuming the resource’scacheFields
list includes the dependent prop—will now return to the empty model/collection, so any child component that may remain in place afterhasInitiallyLoaded
may need to keep that in mind (ifshouldComponentUpdate
returns false when the new resource fetches, then this shouldn’t matter—see the next point). -
Recall that we can provide the dependent prop in one of two ways:
- We can include it in another resource’s
provides
property, in which case the dependent prop gets set as state within resourcerer.- We can also set state in our client component and pass it to the executor function, but that requires an extra render cycle.
- We can modify the url in the component’s
componentDidUpdate
/useEffect
(either url path or query parameter), which will filter the prop down.
When we provide using method (a), the dependent prop can be changed but not removed. When we provide using method (b), the dependent prop can be changed or removed.
As an example of how we might be able to remove a dependent prop via method (b), consider someone navigating to a
/todos
url that auto-navigates to the first todo item and displays its details. ThetodoItem
details resource depends on atodoId
prop, which it gets in auseEffect
via changing the url once thetodos
resource loads. So now we’re at/todos/todo1234
. But if the user clicks the back button, we’ll be back at/todos
with a cachedtodos
resource andPENDING
todoItem
resource, and all three loading states set tofalse
. (Yes, this is a bit contrived because you should actually replace the history entry in this case, but hopefully it helps to illuminate the issue.)So if we remove the dependent prop, we enter a state where
isLoading
,hasLoaded
, andhasErrored
are all false. And since we have to wait for auseEffect
to re-auto-update the url with the dependent prop, a lifecycle passes with this state, and there’s really nothing we can do about it.And again—
hasInitiallyLoaded
is still true and thetodoItemModel
model prop is empty, which can cause layout issues if you use, for example, an overlaid loader over a previously-rendered component. For this reason, if using classes/withResources
, such a previously-rendered component should usenextProps.hasLoaded
instead of!nextProps.isLoading
in itsshouldComponentUpdate
:// overlay-wrapped component, where a loader will show over previously-rendered children, // which we want to then not update. but this component is also a child of a `withResources` // component that has a dependent resource shouldComponentUpdate(nextProps) { // using `return !nextProps.isLoading;` would update the component in the above // scenario, even though `nextProps.myDependentModel` would be empty return nextProps.hasLoaded; } // this assumes that the parent is handling the `hasErrored` state. if it is not, then // you may need to instead use: shouldComponentUpdate(nextProps) { return !(nextProps.isLoading || areAnyPending(nextProps.myDependentLoadingState)); }
If using
useResources
, we'll want to do the equivalent in our memo:const MemoizedComponent = memo(<Component />, (prevProps, nextProps) => !nextProps.hasLoaded); function Parent() { return ( <div> {isLoading ? <OverlayLoader /> : null} {hasInitiallyLoaded ? <MemoizedComponent /> : null} </div> ); }
- We can include it in another resource’s
-
In the case that the model's
cacheFields
does not include the dependent prop (ie, the prop is used solely for triggering the resource request and doesn't factor into the request data), the model will still exist in the cache when the dependent prop is removed. In this case, the loading state is still returned to PENDING, but the existing model will also be present in the return value.
-
Another way to effectively have a dependent resource is to use a conditional in your getResources
method:
const getResources = (ResourceKeys, props) => ({
[ResourceKeys.TODOS]: {},
...(props.todoId ? {[ResourceKeys.TODO_ITEM]: {data: {id: props.todoId}} : {})
});
In general, using dependsOn
is much more preferable, both in terms of semantics and functionality. The key difference here is that the dependent resource does not get put into a PENDING
state, and hasLoaded
depends on an unpredictable number of resources—for example, in the above scenario, what happens if props.todoId
never arrives? Using dependsOn
, hasLoaded
would not be true, but using the conditional, it would be. This means that with the conditional, you can’t freely make assumptions behind the hasLoaded
flag:
{hasLoaded ? (
// with the conditional, you don't know which resources are available. with
// `dependsOn`, you do
) : null}
That doesn’t mean that the conditional can’t be useful—it’s just that its use should be relegated to components that have two discrete forms—one in which the dependent prop is always present, and one in which the dependent prop is never present. If you’re unsure whether a prop might exist, notably because it comes from a providing resource, you should use dependsOn
. A good example of when to use a conditional is in this fake component that sometimes fetches a user model and sometimes fetches an order model depending on the presence of an orderId
prop:
const getResources = (ResourceKeys, {userId, orderId}) => ({
...userId ? {[ResourceKeys.USER]: {options: {userId}} : {},
...!userId && orderId ? {
[ResourceKeys.ORDER]: {
noncritical: true,
data: {id: orderId}
}
} : {}
});
In this case, when the component is used as an order component (denoted by the presence of the orderId
prop), we fetch the order
resource. Otherwise, we don’t.
For all other uses of dependent resources, we should use dependsOn
.
You may find, at some point in your application, that you have a PUT
endpoint for a resource but no GET
; the 'read portion' of the resource is received as part of some parent resource. For example, imagine you have an accounts resource at /accounts/{account_id}
, whose response has a config
property with some account configuration settings. To update the configuration, you make a PUT
to /accounts/{account_id}/config
. But reading comes from the parent resource. resourcerer
supports this via a providesModels
static property on the model:
// resources_conifg.js
import {ResourceKeys, UnfetchedResources} from 'resourcerer';
ResourceKeys.add({
ACCOUNT: 'account',
ACCOUNT_CONFIG: 'accountConfig'
});
// add the ACCOUNT_CONFIG key to our set of UnfetchedResources
UnfetchedResources.add(ResourceKeys.ACCOUNT_CONFIG);
// account_model.js
export default class AccountModel extends Model {
url() {
return `/accounts/${this.accountId}`;
}
static cacheFields = ['accountId']
static providesModels = (accountModel, ResourceKeys) => [{
data: accountModel.get('config'),
modelKey: ResourceKeys.ACCOUNT_CONFIG,
options: {accountModel}
}]
})
The providesModels
property is a function that takes the parent model and the ResourceKeys
as arguments and returns an array of resource configs. The resource configs have the same schema as the those used in our general executor functions. What this tells resourcerer
to do is, after the parent model returns, instantiate the child model(s) and place them into the ModelCache
. In other components, you can then access the model you know to exist in a withResources
or useResources
declaration:
// child_component.jsx, rendered only after the account model is known to return
@withResources((ResourceKeys, props) => ({[ResourceKeys.ACCOUNT_CONFIG]: {}}))
class ChildComponent extends React.Component {
// component has this.props.accountConfigModel from the cache!
onClickSomething() {
// and now you can update the config directly :)
this.props.accountConfigModel.save();
}
}
In general, this should be used in cases where you can ascertain that the parent model has returned before trying to access the child model. However, if by chance it has not, and the child is not found in the cache, resourcerer
will still not attempt to fetch it, because it is listed within the UnfetchedResources
set. In that case, the model will get instantiated with no seed data and passed as a prop.
Also, note that the modelKey
property is required here instead of optionally being inferred from the resource config's object property, as is the case in our general useResources
/withResources
declarations. This is because here, in contrast, the models are simply placed in the cache and not actually used as props for any component, so they don't need to be named. Accordingly, resource configs are also returned as a list here instead of an object.
The resource config objects within providesModels
have the same schema, as mentioned, as normal. But they also accept an additional optional property, shouldCache
, which is a function that takes the parent model and the resource config as an argument. If the function exists and returns false, the model will not get instantiated nor placed in the cache:
// account_model.js
export default class AccountModel extends Model {
// ...
static cacheFields: ['accountId']
static providesModels = (accountModel, ResourceKeys) => [{
data: accountModel.get('config'),
modelKey: ResourceKeys.ACCOUNT_CONFIG,
options: {accountModel},
// if this returns false, account config won't get instantiated and placed in the ModelCache
shouldCache: (accountModel, config) => accountModel.get('state') === 'ACTIVE'
}]
})
You probably want to show loaders when you are moving between one model and the next. Because your resources are held as state, both exist in this intermediate state.
Both the hook and the HOC provide a hasInitiallyLoaded
prop that is useful here. Here's an example of what it might look like (shown over one of Sift's Insights Charts):
Use it like:
import {useResources} from 'resourcerer';
const getResources = ({TODOS}, props) => ({[TODOS]: {}});
export default function UserTodos(props) {
const {isLoading, hasInitiallyLoaded, todosCollection} = useResources(getResources, props);
return (
<div className='MyComponent'>
// it's up to you to make this an OverlayLoader or InlineLoader, if you so choose.
{isLoading ? <Loader /> : null}
// this will render once first loaded and remain rendered with a previous
// model even while in a loading state. when a new resource request returns,
// this will render with the updated model and the loader will be removed.
{hasInitiallyLoaded ? (
<ul>
{todosCollection.map((todoModel) => (
<li key={todoModel.id}>{todoModel.get('name')}</li>
))}
</ul>
) : null}
</div>
);
}
A common pattern when creating a new instance of a resource is to keep it in state until the user decides to explicitly save it. For example, when setting up a new
TODO, we might fill out a form with its name, all kept in state, at a deep-linkable RESTful url that might be /todos/new
. After the user saves the TODO, we get a
server-provided id
property and we might navigate to /todos/12345
, where 12345
is the new id. This presents an inconvenience: at /new
we want to read from
state, but at /{id}
we want to read from the saved model (this is compounded if the UI allows the user to edit in-place).
To accomodate this scenario, resourcerer
provides a means of re-caching a model instance the first time it receives an id. Thus you can use it with fetch: false
like
this:
import {useResources} from 'resourcerer';
const getResources = ({TODO}, props) => ({
[TODO]: {
data: {id: props.id},
fetch: !!props.id
}
});
export default function UserTodo(props) {
const {todoModel} = useResources(getResources, props),
onChange = (evt) => todoModel.set('name', evt.target.value),
onSubmit = todoModel.save()
.then(([model]) => !props.id ? navigate(`/todos/${model.id}`) : null)
.catch(() => notify('An error occurred');
return (
<form onSubmit={onSubmit}>
<label>
TODO:
<input name='name' onChange={onChange} value={todoModel.get('name')} />
</label>
<button>Save</button>
</form>
);
}
What's so nice about this example is that there's no balancing between React state and model state; in either case you read from
the model requested from resourcerer
. When you load /todos/new
, nothing is fetched and the model is created first client-side;
when you load /todos/{todosId}
, the todos resource is first fetched. Both cases, however, are treated identically. And because of
resourcerer
's recaching, when you save for the first time and navigate from /todos/new
to /todos/{todosId}
, the model is
taken from its 'new' cache key and placed in its 'id' cache key, obviating the need to re-request the resource and moving seamlessly
from one to the other.
NOTE: this is only for models requested individually and not as part of a larger collection. When adding a new model to a collection
that is fetched by resourcerer
, you can accomplish the above without recaching.